From 238df6a57b23966e1fd1efebfc87ebdac2c02364 Mon Sep 17 00:00:00 2001 From: Calin Rus <127376836+calinrus-dev@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:38:52 +0200 Subject: [PATCH 1/4] feat(desktop-gpui): add conservative upload diagnostics --- .../docs/upload-health-failed-linux.png | Bin 0 -> 108062 bytes .../docs/upload-health-fast-linux.png | Bin 0 -> 106828 bytes .../docs/upload-health-slow-linux.png | Bin 0 -> 106787 bytes apps/desktop-gpui/src/auth.rs | 33 +- apps/desktop-gpui/src/main.rs | 2 + apps/desktop-gpui/src/main_window.rs | 21 + apps/desktop-gpui/src/network_health.rs | 446 ++++++++++++++++++ .../desktop-gpui/src/network_health/policy.rs | 213 +++++++++ apps/desktop-gpui/src/recording.rs | 6 + apps/desktop-gpui/src/session.rs | 3 + apps/desktop-gpui/src/settings_pages.rs | 11 +- apps/desktop-gpui/src/settings_window.rs | 16 + apps/desktop-gpui/src/snapshot.rs | 24 +- apps/desktop-gpui/upload-health.md | 89 ++++ .../unit/upload-health-route.test.ts | 195 ++++++++ apps/web/__tests__/unit/upload-health.test.ts | 138 ++++++ .../app/api/desktop/upload-health/route.ts | 154 ++++++ apps/web/lib/rate-limit.ts | 1 + apps/web/lib/upload-health.ts | 86 ++++ 19 files changed, 1418 insertions(+), 20 deletions(-) create mode 100644 apps/desktop-gpui/docs/upload-health-failed-linux.png create mode 100644 apps/desktop-gpui/docs/upload-health-fast-linux.png create mode 100644 apps/desktop-gpui/docs/upload-health-slow-linux.png create mode 100644 apps/desktop-gpui/src/network_health.rs create mode 100644 apps/desktop-gpui/src/network_health/policy.rs create mode 100644 apps/desktop-gpui/upload-health.md create mode 100644 apps/web/__tests__/unit/upload-health-route.test.ts create mode 100644 apps/web/__tests__/unit/upload-health.test.ts create mode 100644 apps/web/app/api/desktop/upload-health/route.ts create mode 100644 apps/web/lib/upload-health.ts diff --git a/apps/desktop-gpui/docs/upload-health-failed-linux.png b/apps/desktop-gpui/docs/upload-health-failed-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..7aafc5c0eea535dcb060a367fc7389c6f9a2bdcf GIT binary patch literal 108062 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

~aWr)uW@`%*s1qEkm-N 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ⅅ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 literal 0 HcmV?d00001 diff --git a/apps/desktop-gpui/docs/upload-health-fast-linux.png b/apps/desktop-gpui/docs/upload-health-fast-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..63c59a5887580ba9bc1e4057857d7bdce6f654bb GIT binary patch literal 106828 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 literal 0 HcmV?d00001 diff --git a/apps/desktop-gpui/docs/upload-health-slow-linux.png b/apps/desktop-gpui/docs/upload-health-slow-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..6d96bc91e8652f5f22f82835829baf74333b0f67 GIT binary patch literal 106787 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

Z(a%FP~GhSP8gX5AJ9m^Gd}do*HXuEK(o|- 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 literal 0 HcmV?d00001 diff --git a/apps/desktop-gpui/src/auth.rs b/apps/desktop-gpui/src/auth.rs index 0ddbed2aacb..761adcb723d 100644 --- a/apps/desktop-gpui/src/auth.rs +++ b/apps/desktop-gpui/src/auth.rs @@ -345,19 +345,7 @@ pub async fn authed_request_with( path: &str, body: Option, ) -> Result { - let (server, token) = if let Ok(context) = UPLOAD_REQUEST.try_with(Clone::clone) { - let token = context.credentials()?; - (context.server_url, token) - } else { - let auth = store::auth_snapshot(); - ( - server_url(), - auth.token.ok_or(AuthApiError::InvalidAuthentication)?, - ) - }; - let url = format!("{server}{path}"); - let mut request = client.request(method, url); - request = apply_desktop_headers(request).bearer_auth(token); + let mut request = authed_request_builder(client, method, path)?; if let Some(body) = body { request = request .header("Content-Type", "application/json") @@ -376,6 +364,25 @@ pub async fn authed_request_with( Ok(response) } +pub(crate) fn authed_request_builder( + client: &reqwest::Client, + method: reqwest::Method, + path: &str, +) -> Result { + let (server, token) = if let Ok(context) = UPLOAD_REQUEST.try_with(Clone::clone) { + let token = context.credentials()?; + (context.server_url, token) + } else { + let auth = store::auth_snapshot(); + ( + server_url(), + auth.token.ok_or(AuthApiError::InvalidAuthentication)?, + ) + }; + let url = format!("{server}{path}"); + Ok(apply_desktop_headers(client.request(method, url)).bearer_auth(token)) +} + pub fn process_auth_data(params: AuthParams) -> Result<(), String> { let (secret, user_id) = match params { AuthParams::ApiKey { api_key, user_id } => (json!({ "api_key": api_key }), user_id), diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index 9e886003506..fd598f1682d 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -48,6 +48,7 @@ mod main_window; mod media_access; mod menus; mod mode_select_window; +mod network_health; mod onboarding_audio; mod onboarding_doodles; mod onboarding_window; @@ -331,6 +332,7 @@ fn main() { platform::follow_system_reduce_motion(cx); let session = RecordingSession::init(cx); + crate::network_health::init(cx); crate::sign_in::SignIn::init(cx); crate::feeds::Feeds::init(cx); crate::media_access::MediaAccess::init(cx); diff --git a/apps/desktop-gpui/src/main_window.rs b/apps/desktop-gpui/src/main_window.rs index a318d54a0f7..995ae905237 100644 --- a/apps/desktop-gpui/src/main_window.rs +++ b/apps/desktop-gpui/src/main_window.rs @@ -3465,6 +3465,26 @@ impl MainWindow { /// 8px from the window edges (`mx-2`). fn render_header_actions(&self, cx: &mut Context) -> impl IntoElement { let theme = self.theme; + let upload_health = crate::network_health::indicator().map(|health| { + let color = if health.failed { + theme.red_11 + } else if health.limited { + theme.amber_11 + } else { + theme.gray_11 + }; + div() + .id("upload-health") + .tab_index(0) + .flex_shrink_0() + .text_size(px(9.)) + .text_color(color) + .cursor_pointer() + .child(health.label) + .tooltip(move |_, cx| ui::Tooltip::new(&theme, health.tooltip.clone()).view(cx)) + .tooltip_show_delay(ui::TOOLTIP_SHOW_DELAY) + .on_click(cx.listener(|_, _, _, cx| crate::network_health::request(cx, true))) + }); // `IconButton::header`: a 20px hit box with no fill, `text-gray-11` // going to `text-gray-12` on hover. @@ -3509,6 +3529,7 @@ impl MainWindow { .gap(px(4.)) .mx(px(8.)) .min_w_0() + .children(upload_health) .child( icon_button("help", "icons/circle-help.svg", 16.).on_click(cx.listener( |_, _, _window, cx| { diff --git a/apps/desktop-gpui/src/network_health.rs b/apps/desktop-gpui/src/network_health.rs new file mode 100644 index 00000000000..b36fe1e4b33 --- /dev/null +++ b/apps/desktop-gpui/src/network_health.rs @@ -0,0 +1,446 @@ +mod policy; + +use std::{ + sync::{Mutex, MutexGuard, OnceLock}, + time::{Duration, SystemTime}, +}; + +use futures_util::future::{AbortHandle, Abortable}; +use gpui::App; +use policy::{Admission, Measurement, Sample, Scope}; +use serde::Deserialize; + +use crate::{auth, session::RecordingSession, store}; + +const AUTOMATIC_PROBE_INTERVAL: Duration = Duration::from_secs(60 * 60); + +#[derive(Clone, Copy, Default)] +enum Status { + #[default] + Unknown, + Checking, + Ready, + Failed, + Unsupported, +} + +struct Running { + ticket: u64, + abort: AbortHandle, + done: flume::Receiver<()>, +} + +#[derive(Default)] +struct Controller { + admission: Admission, + scope: Option, + measurement: Option, + status: Status, + running: Option, + attempted_at: Option, + applied_width: Option, +} + +fn controller() -> MutexGuard<'static, Controller> { + static STATE: OnceLock> = OnceLock::new(); + STATE + .get_or_init(|| Mutex::new(Controller::default())) + .lock() + .unwrap_or_else(|error| error.into_inner()) +} + +impl Controller { + fn admit_recording(&mut self) { + self.applied_width = None; + self.status = Status::Unknown; + self.admission.admit_recording(); + if let Some(running) = &self.running { + running.abort.abort(); + } + } +} + +fn current_scope() -> Option { + store::auth_snapshot().token?; + Some(Scope { + server: auth::server_url(), + owner: store::store_section("auth") + .get("user_id")? + .as_str()? + .to_string(), + }) +} + +pub(crate) fn invalidate() { + let mut state = controller(); + state.admission.invalidate(); + if let Some(running) = &state.running { + running.abort.abort(); + } + state.measurement = None; + state.attempted_at = None; + state.status = Status::Unknown; +} + +pub(crate) fn recording_admitted() { + controller().admit_recording(); +} + +pub(crate) fn recording_released() { + let mut state = controller(); + state.admission.release_recording(); + state.applied_width = None; +} + +pub(crate) async fn wait_for_probe() { + let done = controller() + .running + .as_ref() + .map(|running| running.done.clone()); + if let Some(done) = done { + let _ = done.recv_async().await; + } +} + +pub(crate) fn max_width(configured: u32) -> u32 { + let Some(scope) = current_scope() else { + return configured; + }; + let mut state = controller(); + let width = state + .measurement + .as_ref() + .filter(|measurement| measurement.fresh_for(&scope, SystemTime::now())) + .and_then(|measurement| measurement.max_width) + .map_or(configured, |cap| configured.min(cap)); + state.applied_width = (width < configured).then_some(width); + width +} + +pub(crate) struct Indicator { + pub label: String, + pub tooltip: String, + pub failed: bool, + pub limited: bool, +} + +pub(crate) fn indicator() -> Option { + let scope = current_scope()?; + let state = controller(); + let measurement = state + .measurement + .as_ref() + .filter(|measurement| measurement.fresh_for(&scope, SystemTime::now())); + let (label, tooltip, failed, limited) = if let Some(width) = state.applied_width { + (format!("Instant limited: {}p", width * 9 / 16), + "This recording uses a lower resolution because repeated upload samples were slow. Your saved setting is unchanged; retest after recording.".into(), false, true) + } else if state.scope.as_ref() == Some(&scope) && matches!(state.status, Status::Checking) { + ( + "Checking upload".into(), + "Checking upload availability and transfer speed.".into(), + false, + false, + ) + } else if let Some(measurement) = measurement { + let speed = measurement + .mbps + .map_or_else(|| "Upload OK".to_string(), |rate| format!("{rate:.1} Mb/s")); + let configured = if store::auth_snapshot().is_upgraded() { + store::GeneralSettings::load().instant_mode_max_resolution + } else { + cap_recording::FREE_INSTANT_MODE_MAX_RESOLUTION + }; + let cap = measurement.max_width.filter(|cap| *cap < configured); + match cap { + Some(width) => ( + format!("{speed} · {}p", width * 9 / 16), + format!("Repeated slow uploads: Instant recordings are limited to {}p. Your saved resolution is unchanged. Click to retest while idle.", width * 9 / 16), + false, true, + ), + None => (speed, "Upload destination is writable. Click to retest while idle. Tests are paused during recording.".into(), false, false), + } + } else { + match if state.scope.as_ref() == Some(&scope) { state.status } else { Status::Unknown } { + Status::Checking => ("Checking upload".into(), "Checking upload availability and transfer speed.".into(), false, false), + Status::Failed => ("Upload check failed".into(), "The upload check failed. Check your connection and storage settings, retry while idle, or contact support. Recording is still available.".into(), true, false), + Status::Unsupported => ("Upload check unavailable".into(), "This server does not support upload diagnostics. Your recording resolution is unchanged.".into(), false, false), + _ => ("Check upload".into(), "Click to check the upload destination and speed while idle.".into(), false, false), + } + }; + Some(Indicator { + label, + tooltip, + failed, + limited, + }) +} + +struct Completion(flume::Sender<()>); + +impl Drop for Completion { + fn drop(&mut self) { + let _ = self.0.send(()); + } +} + +pub(crate) fn request(cx: &mut App, manual: bool) { + let scope = current_scope(); + let launch = { + let mut state = controller(); + if state.scope != scope { + state.admission.invalidate(); + if let Some(running) = &state.running { + running.abort.abort(); + } + state.scope = scope.clone(); + state.measurement = None; + state.attempted_at = None; + state.status = Status::Unknown; + } + let Some(scope) = scope else { + return; + }; + let interval = if manual { + Duration::from_secs(60) + } else { + AUTOMATIC_PROBE_INTERVAL + }; + if state.running.is_some() + || RecordingSession::recording_in_flight(cx) + || state + .attempted_at + .is_some_and(|at| at.elapsed().is_ok_and(|age| age < interval)) + { + return; + } + let Some(ticket) = state.admission.begin_probe() else { + return; + }; + let (abort, registration) = AbortHandle::new_pair(); + let (done, receiver) = flume::bounded(1); + state.running = Some(Running { + ticket, + abort, + done: receiver, + }); + state.status = Status::Checking; + state.attempted_at = Some(SystemTime::now()); + (scope, ticket, registration, Completion(done)) + }; + let (scope, ticket, registration, completion) = launch; + let expected_scope = scope.clone(); + let task = gpui_tokio::Tokio::spawn(cx, async move { + let _completion = completion; + Abortable::new(probe(scope), registration).await + }); + cx.spawn(async move |cx| { + let result = task.await; + cx.update(|cx| { + let mut state = controller(); + if !state + .running + .as_ref() + .is_some_and(|running| running.ticket == ticket) + { + return; + } + state.running = None; + if !state.admission.accepts(ticket) || current_scope().as_ref() != Some(&expected_scope) + { + state.status = Status::Unknown; + if state.measurement.is_none() { + state.attempted_at = None; + } + } else { + match result { + Ok(Ok(Ok(measurement))) => { + state.measurement = Some(measurement); + state.status = Status::Ready; + } + Ok(Ok(Err(ProbeError::Unsupported))) => { + state.measurement = None; + state.status = Status::Unsupported; + } + _ => { + state.measurement = None; + state.status = Status::Failed; + } + } + } + drop(state); + cx.refresh_windows(); + }); + }) + .detach(); + cx.refresh_windows(); +} + +pub(crate) fn init(cx: &mut App) { + cx.spawn(async move |cx| { + loop { + cx.update(|cx| request(cx, false)); + cx.background_executor() + .timer(Duration::from_secs(15)) + .await; + } + }) + .detach(); +} + +#[derive(Debug)] +enum ProbeError { + Failed, + Unsupported, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProbeResponse { + bytes: usize, + body_read_ms: f64, +} + +async fn probe(scope: Scope) -> Result { + let client = auth::api_client().map_err(|_| ProbeError::Failed)?; + let context = auth::UploadRequestContext::for_upload(&scope.server, Some(&scope.owner)) + .map_err(|_| ProbeError::Failed)?; + context + .run(async { + let warm = auth::authed_request_with( + &client, + reqwest::Method::GET, + "/api/desktop/upload-health", + None, + ) + .await + .map_err(|_| ProbeError::Failed)?; + if warm.status() == reqwest::StatusCode::NOT_FOUND { + return Err(ProbeError::Unsupported); + } + if !warm.status().is_success() { + return Err(ProbeError::Failed); + } + warm.bytes().await.map_err(|_| ProbeError::Failed)?; + let mut payload = vec![0_u8; 2 * 1024 * 1024]; + let mut random = 0x3ab1_5fc9_u32; + for byte in &mut payload { + random ^= random << 13; + random ^= random >> 17; + random ^= random << 5; + *byte = random as u8; + } + let payload = bytes::Bytes::from(payload); + let mut samples = [Sample { + bytes: 0, + body_read_ms: 0., + }; 2]; + for (index, sample) in samples.iter_mut().enumerate() { + if current_scope().as_ref() != Some(&scope) { + return Err(ProbeError::Failed); + } + let path = if index == 0 { + "/api/desktop/upload-health?checkStorage=true" + } else { + "/api/desktop/upload-health" + }; + let response = auth::authed_request_builder(&client, reqwest::Method::POST, path) + .map_err(|_| ProbeError::Failed)? + .header("Content-Type", "application/octet-stream") + .timeout(Duration::from_secs(30)) + .body(payload.clone()) + .send() + .await + .map_err(|_| ProbeError::Failed)?; + if !response.status().is_success() { + return Err(ProbeError::Failed); + } + let response = response + .json::() + .await + .map_err(|_| ProbeError::Failed)?; + if response.bytes != payload.len() { + return Err(ProbeError::Failed); + } + *sample = Sample { + bytes: response.bytes, + body_read_ms: response.body_read_ms, + }; + } + Ok(Measurement::from_samples( + scope.clone(), + samples, + SystemTime::now(), + )) + }) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }; + + struct ActiveTransfer(Arc); + + impl Drop for ActiveTransfer { + fn drop(&mut self) { + self.0.store(false, Ordering::SeqCst); + } + } + + #[tokio::test] + async fn recording_waits_for_an_in_flight_transfer_to_be_dropped() { + let active = Arc::new(AtomicBool::new(false)); + let mut state = Controller::default(); + let ticket = state.admission.begin_probe().unwrap(); + let (abort, registration) = AbortHandle::new_pair(); + let (done, receiver) = flume::bounded(1); + let (started, ready) = flume::bounded(1); + state.running = Some(Running { + ticket, + abort, + done: receiver.clone(), + }); + let completion = Completion(done); + let transfer_active = active.clone(); + let task = tokio::spawn(async move { + let _completion = completion; + Abortable::new( + async move { + transfer_active.store(true, Ordering::SeqCst); + let _transfer = ActiveTransfer(transfer_active); + started.send(()).unwrap(); + std::future::pending::<()>().await; + }, + registration, + ) + .await + }); + ready.recv_async().await.unwrap(); + assert!(active.load(Ordering::SeqCst)); + state.admit_recording(); + tokio::time::timeout(Duration::from_secs(1), receiver.recv_async()) + .await + .unwrap() + .unwrap(); + assert!(!active.load(Ordering::SeqCst)); + assert!(!state.admission.accepts(ticket)); + assert_eq!(state.admission.begin_probe(), None); + assert!(task.await.unwrap().is_err()); + } + + #[test] + fn dropping_an_unpolled_probe_still_releases_recording_startup() { + let (done, receiver) = flume::bounded(1); + let completion = Completion(done); + let task = async move { + let _completion = completion; + std::future::pending::<()>().await; + }; + drop(task); + assert_eq!(receiver.try_recv(), Ok(())); + } +} diff --git a/apps/desktop-gpui/src/network_health/policy.rs b/apps/desktop-gpui/src/network_health/policy.rs new file mode 100644 index 00000000000..a80da122b17 --- /dev/null +++ b/apps/desktop-gpui/src/network_health/policy.rs @@ -0,0 +1,213 @@ +use std::time::{Duration, SystemTime}; + +pub const MEASUREMENT_TTL: Duration = Duration::from_secs(15 * 60); + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Scope { + pub server: String, + pub owner: String, +} + +#[derive(Clone, Copy, Debug)] +pub struct Sample { + pub bytes: usize, + pub body_read_ms: f64, +} + +impl Sample { + fn mbps(self) -> Option { + (self.bytes >= 1024 * 1024 + && self.bytes <= 8 * 1024 * 1024 + && self.body_read_ms.is_finite() + && self.body_read_ms > 0.) + .then(|| self.bytes as f64 * 8. / (self.body_read_ms * 1000.)) + } +} + +#[derive(Clone, Debug)] +pub struct Measurement { + pub scope: Scope, + pub checked_at: SystemTime, + pub mbps: Option, + pub max_width: Option, +} + +impl Measurement { + pub fn from_samples(scope: Scope, samples: [Sample; 2], now: SystemTime) -> Self { + let rates = samples[0].mbps().zip(samples[1].mbps()); + let mbps = rates.map(|(first, second)| first.max(second)); + let max_width = rates.and_then(|(first, second)| { + if samples.iter().any(|sample| sample.body_read_ms < 500.) + || first.max(second) > first.min(second) * 2. + { + return None; + } + match first.max(second) { + rate if rate < 5. => Some(1280), + rate if rate < 10. => Some(1920), + rate if rate < 20. => Some(2560), + _ => None, + } + }); + Self { + scope, + checked_at: now, + mbps, + max_width, + } + } + + pub fn fresh_for(&self, scope: &Scope, now: SystemTime) -> bool { + self.scope == *scope + && now + .duration_since(self.checked_at) + .is_ok_and(|age| age < MEASUREMENT_TTL) + } +} + +#[derive(Default)] +pub struct Admission { + recording: bool, + generation: u64, +} + +impl Admission { + pub fn begin_probe(&mut self) -> Option { + if self.recording { + return None; + } + self.invalidate(); + Some(self.generation) + } + + pub fn accepts(&self, generation: u64) -> bool { + !self.recording && self.generation == generation + } + + pub fn admit_recording(&mut self) { + self.recording = true; + self.invalidate(); + } + + pub fn release_recording(&mut self) { + self.recording = false; + } + + pub fn invalidate(&mut self) { + self.generation = self.generation.wrapping_add(1); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scope() -> Scope { + Scope { + server: "https://cap.example".into(), + owner: "owner".into(), + } + } + + fn measure(first_mbps: f64, second_mbps: f64) -> Measurement { + let sample = |mbps| Sample { + bytes: 2 * 1024 * 1024, + body_read_ms: 2. * 1024. * 1024. * 8. / (mbps * 1000.), + }; + Measurement::from_samples( + scope(), + [sample(first_mbps), sample(second_mbps)], + SystemTime::now(), + ) + } + + #[test] + fn only_repeatably_slow_samples_lower_resolution() { + assert_eq!(measure(3., 4.).max_width, Some(1280)); + assert_eq!(measure(6., 7.).max_width, Some(1920)); + assert_eq!(measure(12., 16.).max_width, Some(2560)); + assert_eq!(measure(20., 20.).max_width, None); + } + + #[test] + fn a_fast_or_inconsistent_sample_preserves_quality() { + assert_eq!(measure(3., 100.).max_width, None); + assert_eq!(measure(3., 9.).max_width, None); + assert_eq!(measure(100., 100.).max_width, None); + } + + #[test] + fn invalid_or_buffered_samples_never_apply_a_cap() { + for duration in [0., -1., f64::NAN, f64::INFINITY] { + let sample = Sample { + bytes: 2 * 1024 * 1024, + body_read_ms: duration, + }; + let result = Measurement::from_samples(scope(), [sample; 2], SystemTime::now()); + assert_eq!(result.max_width, None); + assert_eq!(result.mbps, None); + } + let sample = Sample { + bytes: 100, + body_read_ms: 1000., + }; + assert_eq!( + Measurement::from_samples(scope(), [sample; 2], SystemTime::now()).max_width, + None + ); + } + + #[test] + fn threshold_boundaries_use_the_less_restrictive_tier() { + assert_eq!(measure(5., 5.).max_width, Some(1920)); + assert_eq!(measure(10., 10.).max_width, Some(2560)); + assert_eq!(measure(20., 20.).max_width, None); + } + + #[test] + fn stale_account_and_server_measurements_are_ignored() { + let result = measure(3., 3.); + assert!(result.fresh_for(&scope(), result.checked_at)); + assert!(!result.fresh_for(&scope(), result.checked_at + MEASUREMENT_TTL)); + let mut other = scope(); + other.owner = "other".into(); + assert!(!result.fresh_for(&other, result.checked_at)); + other = scope(); + other.server = "https://other.example".into(); + assert!(!result.fresh_for(&other, result.checked_at)); + } + + #[test] + fn recording_admission_rejects_in_flight_and_new_probe_results() { + let mut admission = Admission::default(); + let ticket = admission.begin_probe().unwrap(); + assert!(admission.accepts(ticket)); + admission.admit_recording(); + assert!(!admission.accepts(ticket)); + assert_eq!(admission.begin_probe(), None); + admission.release_recording(); + assert!(!admission.accepts(ticket)); + let next = admission.begin_probe().unwrap(); + assert!(admission.accepts(next)); + assert!(!admission.accepts(ticket)); + } + + #[test] + fn invalidation_rejects_late_results_without_entering_recording() { + let mut admission = Admission::default(); + let ticket = admission.begin_probe().unwrap(); + admission.invalidate(); + assert!(!admission.accepts(ticket)); + assert!(admission.begin_probe().is_some()); + } + + #[test] + fn moving_the_wall_clock_backwards_preserves_the_users_resolution() { + let result = measure(3., 3.); + let earlier = result + .checked_at + .checked_sub(Duration::from_secs(1)) + .unwrap(); + assert!(!result.fresh_for(&scope(), earlier)); + } +} diff --git a/apps/desktop-gpui/src/recording.rs b/apps/desktop-gpui/src/recording.rs index b9bb2cf8b9c..c48dc0e2ab2 100644 --- a/apps/desktop-gpui/src/recording.rs +++ b/apps/desktop-gpui/src/recording.rs @@ -1994,6 +1994,7 @@ async fn start_internal( config: StartConfig, #[cfg(target_os = "linux")] attempt: Option, ) -> anyhow::Result { + crate::network_health::wait_for_probe().await; #[cfg(target_os = "linux")] validate_linux_camera_request( config.mode, @@ -2225,6 +2226,11 @@ async fn start_attempt_with_upload( } else { cap_recording::FREE_INSTANT_MODE_MAX_RESOLUTION }; + let instant_max_resolution = if config.mode == RecordingMode::Instant { + crate::network_health::max_width(instant_max_resolution) + } else { + instant_max_resolution + }; #[cfg(target_os = "macos")] let excluded_windows = { diff --git a/apps/desktop-gpui/src/session.rs b/apps/desktop-gpui/src/session.rs index ad48a204a55..78aed97beed 100644 --- a/apps/desktop-gpui/src/session.rs +++ b/apps/desktop-gpui/src/session.rs @@ -675,6 +675,7 @@ impl RecordingSession { self.clean_control.invalidate(); self.clean_control.uncertain = false; } + crate::network_health::recording_admitted(); self.phase = Phase::Starting; self.error = None; self.storage_warning = false; @@ -1700,6 +1701,7 @@ impl RecordingSession { { self.failure_monitor = None; } + crate::network_health::recording_admitted(); self.phase = Phase::Starting; self.started_at = None; cx.notify(); @@ -1950,6 +1952,7 @@ impl RecordingSession { self.storage_monitor = None; self.countdown_remaining = None; self.phase = Phase::Idle; + crate::network_health::recording_released(); self.mic_muted = false; self.storage_warning = false; self.stopped_elapsed = None; diff --git a/apps/desktop-gpui/src/settings_pages.rs b/apps/desktop-gpui/src/settings_pages.rs index c0d5d6bb421..7437f1e8c1b 100644 --- a/apps/desktop-gpui/src/settings_pages.rs +++ b/apps/desktop-gpui/src/settings_pages.rs @@ -20,12 +20,12 @@ use std::{ time::Duration, }; +use cap_cli_install as cli_install; use gpui::{ AppContext as _, Context, Entity, FontWeight, Hsla, InteractiveElement, IntoElement, MouseButton, ParentElement, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::FluentBuilder, px, rgb, svg, }; -use cap_cli_install as cli_install; use serde::Deserialize; use serde_json::{Map, Value, json}; @@ -4543,6 +4543,7 @@ impl SettingsWindow { Ok((200, _)) => { this.s3_fetch(window, cx); this.info_dialog("S3 configuration saved successfully", window, cx); + crate::network_health::invalidate(); } _ => this.pages.s3.error = Some("Failed to save S3 config".to_string()), } @@ -4579,6 +4580,7 @@ impl SettingsWindow { Ok((200, _)) => { this.s3_fetch(window, cx); this.info_dialog("S3 configuration deleted successfully", window, cx); + crate::network_health::invalidate(); } _ => this.pages.s3.error = Some("Failed to delete S3 config".to_string()), } @@ -4775,6 +4777,7 @@ impl SettingsWindow { this.pages.storage = Some(parsed); } if connected { + crate::network_health::invalidate(); this.pages.gdrive.waiting = false; this.gdrive_fetch_s3_presence(window, cx); } @@ -4892,7 +4895,10 @@ impl SettingsWindow { |this, result, window, cx| { this.pages.gdrive.set_active_pending = false; match result { - Ok((200, _)) => this.gdrive_refresh(window, cx), + Ok((200, _)) => { + crate::network_health::invalidate(); + this.gdrive_refresh(window, cx); + } _ => { this.pages.gdrive.error = Some("Failed to update active storage provider".to_string()) @@ -4934,6 +4940,7 @@ impl SettingsWindow { Ok((200, _)) => { this.gdrive_refresh(window, cx); this.info_dialog("Google Drive disconnected", window, cx); + crate::network_health::invalidate(); } _ => { this.pages.gdrive.error = diff --git a/apps/desktop-gpui/src/settings_window.rs b/apps/desktop-gpui/src/settings_window.rs index cd7dd5b65d4..2cb02ee2f42 100644 --- a/apps/desktop-gpui/src/settings_window.rs +++ b/apps/desktop-gpui/src/settings_window.rs @@ -3209,6 +3209,22 @@ impl SettingsWindow { ); let mut rows = vec![resolution.into_any_element()]; + if let Some(health) = crate::network_health::indicator() { + rows.push( + div() + .p(px(12.)) + .text_size(px(12.)) + .text_color(Hsla::from(if health.failed { + theme.red_11 + } else if health.limited { + theme.amber_11 + } else { + theme.gray_11 + })) + .child(health.label) + .into_any_element(), + ); + } if let Some(InstantQualityNotice::SaveFailed) = self.instant_quality_notice { rows.push( div() diff --git a/apps/desktop-gpui/src/snapshot.rs b/apps/desktop-gpui/src/snapshot.rs index ea53a80bfc3..40747f1ba03 100644 --- a/apps/desktop-gpui/src/snapshot.rs +++ b/apps/desktop-gpui/src/snapshot.rs @@ -38,15 +38,29 @@ pub fn arm_from_env(cx: &mut App) { } else if target == "main" { Some(cx.global::().main.into()) } else { - cx.global::() - .onboarding - .map(Into::into) - }; + cx.global::() + .onboarding + .map(Into::into) + }; let Some(handle) = handle else { tracing::warn!("snapshot: window handle missing"); return None; }; - match handle.update(cx, |_, window, _| crate::platform::window_number(window)) { + match handle.update(cx, |_, window, _| { + #[cfg(target_os = "linux")] + { + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + match HasWindowHandle::window_handle(window).ok()?.as_raw() { + RawWindowHandle::Xlib(handle) => isize::try_from(handle.window).ok(), + RawWindowHandle::Xcb(handle) => { + isize::try_from(handle.window.get()).ok() + } + _ => None, + } + } + #[cfg(not(target_os = "linux"))] + crate::platform::window_number(window) + }) { Ok(number) => number, Err(error) => { tracing::warn!("snapshot: onboarding update failed: {error}"); diff --git a/apps/desktop-gpui/upload-health.md b/apps/desktop-gpui/upload-health.md new file mode 100644 index 00000000000..89710966e4c --- /dev/null +++ b/apps/desktop-gpui/upload-health.md @@ -0,0 +1,89 @@ +# Upload diagnostics + +The signed-in GPUI app checks its upload path at startup and at most once an hour +while idle. The header indicator can request a manual check, with a one-minute +cooldown. Signing out or changing the server invalidates the account-bound result; +saving/deleting S3 settings or connecting, disconnecting, or switching Google Drive +also invalidates it. Checks do not block recording when diagnostics are unavailable. + +## Measurement and resolution + +The client reuses one HTTP client, warms the authenticated connection with a GET, +then sends two 2 MiB binary samples. The server measures the interval between the +first and last nonempty body chunks. Authentication, cold start, storage access, +and object cleanup are outside that interval. A proxy that buffers an entire +sample can produce an inconclusive result; that result preserves the configured +resolution. + +A cap requires two valid samples, both lasting at least 500 ms, whose rates differ +by no more than a factor of two. The faster sample selects the maximum width: + +| Transfer rate | Maximum width | +| --- | --- | +| Below 5 Mbps | 1280 (720p) | +| 5 to below 10 Mbps | 1920 (1080p) | +| 10 to below 20 Mbps | 2560 (1440p) | +| At least 20 Mbps, inconsistent or inconclusive | Configured resolution | + +The effective width is the minimum of this cap and the existing plan/user limit. +Saved settings and Studio recordings are unchanged. A measurement expires after +15 minutes, including time spent asleep; a backwards wall-clock change invalidates +it. The header and Quality settings show an active cap. Once recording starts, +the applied-cap indicator stays visible for that recording even if the measurement +expires. + +Recording admission cancels the active probe and invalidates its generation. +Engine startup waits for the probe future to be dropped before continuing; late +results cannot update the measurement or start another probe during recording. + +## Storage check and bounds + +Only the first sample checks storage. After validating current organization +membership, it resolves the same writable storage access used for recordings +(organization/personal S3, Google Drive, or default storage). It writes at most +1 KiB of synthetic data under `/.cap-upload-health/` and attempts +cleanup even when writing fails. Write failures make the check unavailable; +cleanup failures are logged and do not turn a successful upload into a failure. +The check verifies storage write access, not screen capture or encoder correctness. + +Body input is streamed with an 8 MiB limit; the reader retains only the small +storage sample. Storage writes time out after ten seconds, cleanup after three, +and the request after 25 seconds. A check transfers 4 MiB in total. The process +limiter allows four POSTs per authenticated user per minute and retains at most +1024 rate-limit entries. For a distributed deployment, configure the existing +Vercel Firewall integration's `rl_desktop_upload_health` rule with the desired +per-user limit; the process limit alone is not a distributed quota. + +## Verification + +Native Linux UI captures use an isolated profile and synthetic API responses; +they demonstrate rendering, not measured network performance or a real recording: + +- [Slow upload with a visible 720p cap](docs/upload-health-slow-linux.png) +- [Fast upload without a cap](docs/upload-health-fast-linux.png) +- [Failed upload check](docs/upload-health-failed-linux.png) + +```sh +bun run --cwd apps/web test __tests__/unit/upload-health.test.ts __tests__/unit/upload-health-route.test.ts +./scripts/prepare-gpui-dependency.sh +# Run scripts/setup.js with the same target/native dependencies as desktop CI. +cd apps/desktop-gpui +cargo check --locked -p cap-desktop-gpui +cargo test --locked -p cap-desktop-gpui --bin cap-gpui network_health +``` + +Manual desktop checks before acceptance: + +1. On macOS and Windows, sign in and capture the header and Quality settings with + a successful probe and an applied cap. Confirm the app layout still fits. +2. Compare a fast connection with added RTT against a genuinely throttled upload. + A single slow sample or an inconclusive/buffered sample must not lower quality. +3. Start recording during the first POST, during storage processing, and between + samples. Confirm no later sample starts and recording proceeds after cancellation. +4. Change account, server and selected storage; the old result must not be applied. +5. Make the selected storage unwritable while keeping the app API reachable. The + indicator must report the failed check; merely reaching the app is insufficient. +6. Deny deletion after allowing writes. The upload check stays successful and + cleanup is logged. Operators can remove residual objects under the probe prefix. +7. Check an older self-hosted server without this endpoint: diagnostics are + unavailable and the configured recording resolution is preserved. diff --git a/apps/web/__tests__/unit/upload-health-route.test.ts b/apps/web/__tests__/unit/upload-health-route.test.ts new file mode 100644 index 00000000000..c0023d98152 --- /dev/null +++ b/apps/web/__tests__/unit/upload-health-route.test.ts @@ -0,0 +1,195 @@ +import { + HttpApiBuilder, + HttpServer, + HttpServerRequest, +} from "@effect/platform"; +import { Context, Effect, Layer, Option } from "effect"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + writes: [] as string[], + deletes: [] as string[], + storageAvailable: true, + cleanupAvailable: true, + organizationAllowed: true, + rateLimited: false, +})); + +vi.mock("@cap/web-backend", async () => { + const { Context } = await import("effect"); + return { Storage: Context.GenericTag("UploadHealthTestStorage") }; +}); + +vi.mock("@/lib/rate-limit", () => ({ + isRateLimited: async () => state.rateLimited, + RATE_LIMIT_IDS: { DESKTOP_UPLOAD_HEALTH: "rl_desktop_upload_health" }, +})); + +vi.mock("@/app/api/desktop/[...route]/organizationStorage", () => ({ + getAccessibleOrganization: async () => + state.organizationAllowed ? { id: "org" } : null, +})); + +vi.mock("@/lib/server", async () => { + const { CurrentUser, HttpAuthMiddleware, Organisation, User } = await import( + "@cap/web-domain" + ); + const { HttpApiError } = await import("@effect/platform"); + const storage = Context.GenericTag("UploadHealthTestStorage"); + const authentication = Layer.succeed( + HttpAuthMiddleware, + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + if (request.headers.authorization !== "Bearer test") { + return yield* new HttpApiError.Unauthorized(); + } + return CurrentUser.of({ + id: User.UserId.make("test-user"), + email: "dev@example.test", + activeOrganizationId: Organisation.OrganisationId.make("org"), + iconUrlOrKey: Option.none(), + }); + }), + ); + const access = { + putObject: (key: string) => + Effect.suspend(() => { + state.writes.push(key); + return state.storageAvailable + ? Effect.void + : Effect.fail("write failed"); + }), + deleteObject: (key: string) => + Effect.suspend(() => { + state.deletes.push(key); + return state.cleanupAvailable + ? Effect.void + : Effect.fail("delete failed"); + }), + }; + return { + apiToHandler: (live: Parameters[0]) => + HttpApiBuilder.toWebHandler( + live.pipe( + Layer.provide(authentication), + Layer.provide( + Layer.succeed(storage, { + getWritableAccessForUser: () => Effect.succeed({ access }), + }), + ), + Layer.provide(HttpServer.layerContext), + ), + ).handler, + }; +}); + +import { GET, POST } from "@/app/api/desktop/upload-health/route"; + +function request( + checkStorage = true, + body = new Uint8Array(1024), + token = "test", +) { + return new Request( + `http://localhost/api/desktop/upload-health${checkStorage ? "?checkStorage=true" : ""}`, + { + method: "POST", + headers: { authorization: `Bearer ${token}` }, + body, + }, + ); +} + +let clock = 0; +beforeEach(() => { + clock += 60_000; + vi.spyOn(Date, "now").mockReturnValue(clock); + state.writes = []; + state.deletes = []; + state.storageAvailable = true; + state.cleanupAvailable = true; + state.organizationAllowed = true; + state.rateLimited = false; +}); + +describe("authenticated upload-health API", () => { + it("rejects unauthenticated probes without touching storage", async () => { + const response = await POST(request(true, new Uint8Array(1), "wrong")); + expect(response.status).toBe(401); + expect(state.writes).toEqual([]); + }); + + it("warms the connection without writing or deleting an object", async () => { + const response = await GET( + new Request("http://localhost/api/desktop/upload-health", { + headers: { authorization: "Bearer test" }, + }), + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + expect(state.writes).toEqual([]); + }); + + it("checks the selected storage under the user's prefix and returns body timing", async () => { + const response = await POST(request()); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ bytes: 1024, bodyReadMs: 0 }); + expect(state.writes[0]).toMatch(/^test-user\/\.cap-upload-health\//); + expect(state.deletes).toEqual(state.writes); + }); + + it("measures subsequent samples without another storage write", async () => { + const response = await POST(request(false)); + expect(response.status).toBe(200); + expect(state.writes).toEqual([]); + }); + + it("rejects a stale organization membership before touching storage", async () => { + state.organizationAllowed = false; + const response = await POST(request()); + expect(response.status).toBe(403); + expect(state.writes).toEqual([]); + }); + + it("returns unavailable when the chosen storage cannot accept a write", async () => { + state.storageAvailable = false; + const response = await POST(request()); + expect(response.status).toBe(503); + }); + + it("rejects empty and oversized streamed bodies without a storage write", async () => { + expect((await POST(request(true, new Uint8Array()))).status).toBe(400); + expect( + (await POST(request(true, new Uint8Array(8 * 1024 * 1024 + 1)))).status, + ).toBe(413); + expect(state.writes).toEqual([]); + }); + + it("rejects an oversized Content-Length before reading or writing", async () => { + const probe = request(); + probe.headers.set("content-length", String(8 * 1024 * 1024 + 1)); + expect((await POST(probe)).status).toBe(413); + expect(state.writes).toEqual([]); + }); + + it("keeps upload availability successful when object cleanup fails", async () => { + state.cleanupAvailable = false; + vi.spyOn(console, "warn").mockImplementation(() => {}); + expect((await POST(request())).status).toBe(200); + expect(console.warn).toHaveBeenCalledOnce(); + }); + + it("rate-limits repeated authenticated probes", async () => { + for (let index = 0; index < 4; index++) { + expect((await POST(request(false))).status).toBe(200); + } + expect((await POST(request())).status).toBe(429); + expect(state.writes).toEqual([]); + }); + + it("rejects a distributed rate-limit decision before writing", async () => { + state.rateLimited = true; + expect((await POST(request())).status).toBe(429); + expect(state.writes).toEqual([]); + }); +}); diff --git a/apps/web/__tests__/unit/upload-health.test.ts b/apps/web/__tests__/unit/upload-health.test.ts new file mode 100644 index 00000000000..34d75b5c2f6 --- /dev/null +++ b/apps/web/__tests__/unit/upload-health.test.ts @@ -0,0 +1,138 @@ +import { Data, Effect } from "effect"; +import { describe, expect, it, vi } from "vitest"; +import { + checkUploadStorage, + MAX_UPLOAD_PROBE_BYTES, + UploadProbeBody, + UploadProbeLimiter, + UploadProbeTooLarge, +} from "@/lib/upload-health"; + +class TestStorageError extends Data.TaggedError("TestStorageError")<{ + message: string; +}> {} + +describe("upload measurement", () => { + it("times body transfer separately from cold-start and storage latency", () => { + let now = 12_000; + const probe = new UploadProbeBody(() => now); + now += 4000; + probe.append(new Uint8Array(1024)); + now += 800; + probe.append(new Uint8Array(1024)); + now += 30_000; + expect(probe.result()).toEqual({ bytes: 2048, bodyReadMs: 800 }); + }); + + it("reports buffered or empty bodies as inconclusive timing", () => { + const probe = new UploadProbeBody(() => 10); + expect(probe.result()).toEqual({ bytes: 0, bodyReadMs: 0 }); + probe.append(new Uint8Array(1024)); + expect(probe.result()).toEqual({ bytes: 1024, bodyReadMs: 0 }); + }); + + it("bounds streamed bodies even when Content-Length is absent or false", () => { + const probe = new UploadProbeBody(); + probe.append(new Uint8Array(MAX_UPLOAD_PROBE_BYTES)); + expect(() => probe.append(new Uint8Array(1))).toThrow(UploadProbeTooLarge); + expect(probe.result().bytes).toBe(MAX_UPLOAD_PROBE_BYTES); + expect(probe.sample().byteLength).toBe(1024); + }); + + it("does not retain the upload or an alias of its storage sample", () => { + const probe = new UploadProbeBody(); + const bytes = new Uint8Array(2048).fill(7); + probe.append(bytes); + bytes.fill(0); + expect(probe.sample()).toEqual(new Uint8Array(1024).fill(7)); + }); +}); + +describe("selected-storage health check", () => { + const fixture = () => ({ + userId: "owner", + bytes: new Uint8Array([1, 2, 3]), + put: vi.fn((): Effect.Effect => Effect.void), + remove: vi.fn((): Effect.Effect => Effect.void), + onCleanupFailure: vi.fn(), + uuid: () => "test-id", + }); + + it("writes under the authenticated user's prefix and cleans up", async () => { + const deps = fixture(); + await Effect.runPromise(checkUploadStorage(deps)); + expect(deps.put).toHaveBeenCalledWith( + "owner/.cap-upload-health/test-id", + deps.bytes, + ); + expect(deps.remove).toHaveBeenCalledWith( + "owner/.cap-upload-health/test-id", + ); + }); + + it("keeps a successful write healthy when cleanup fails", async () => { + const deps = fixture(); + deps.remove.mockReturnValue( + Effect.fail(new TestStorageError({ message: "delete denied" })), + ); + await expect( + Effect.runPromise(checkUploadStorage(deps)), + ).resolves.toBeUndefined(); + expect(deps.onCleanupFailure).toHaveBeenCalledOnce(); + }); + + it("reports an unavailable storage destination and still attempts cleanup", async () => { + const deps = fixture(); + deps.put.mockReturnValue( + Effect.fail(new TestStorageError({ message: "write failed" })), + ); + await expect(Effect.runPromise(checkUploadStorage(deps))).rejects.toThrow( + "write failed", + ); + expect(deps.remove).toHaveBeenCalledOnce(); + }); +}); + +describe("storage effect execution", () => { + it("executes cleanup after the write effect fails", async () => { + const events: string[] = []; + await expect( + Effect.runPromise( + checkUploadStorage({ + userId: "owner", + bytes: new Uint8Array([1]), + put: () => + Effect.sync(() => events.push("write")).pipe( + Effect.andThen( + Effect.fail(new TestStorageError({ message: "failed" })), + ), + ), + remove: () => + Effect.sync(() => { + events.push("delete"); + }), + onCleanupFailure: () => {}, + }), + ), + ).rejects.toThrow("failed"); + expect(events).toEqual(["write", "delete"]); + }); +}); + +describe("upload probe rate limit", () => { + it("allows two samples and a retry, then rejects repeated checks", () => { + const limiter = new UploadProbeLimiter(); + for (let i = 0; i < 4; i++) expect(limiter.admit("owner", 0)).toBe(true); + expect(limiter.admit("owner", 59_999)).toBe(false); + expect(limiter.admit("other", 59_999)).toBe(true); + expect(limiter.admit("owner", 60_000)).toBe(true); + }); + + it("bounds its per-process state and expires old entries", () => { + const limiter = new UploadProbeLimiter(); + for (let i = 0; i < 1024; i++) + expect(limiter.admit(String(i), 0)).toBe(true); + expect(limiter.admit("extra", 0)).toBe(false); + expect(limiter.admit("extra", 60_000)).toBe(true); + }); +}); diff --git a/apps/web/app/api/desktop/upload-health/route.ts b/apps/web/app/api/desktop/upload-health/route.ts new file mode 100644 index 00000000000..28dc3e7b5e5 --- /dev/null +++ b/apps/web/app/api/desktop/upload-health/route.ts @@ -0,0 +1,154 @@ +import { Storage } from "@cap/web-backend"; +import { CurrentUser, HttpAuthMiddleware } from "@cap/web-domain"; +import { + HttpApi, + HttpApiBuilder, + HttpApiEndpoint, + HttpApiError, + HttpApiGroup, + HttpApiSchema, + HttpServerRequest, +} from "@effect/platform"; +import { Effect, Layer, Schema, Stream } from "effect"; +import { isRateLimited, RATE_LIMIT_IDS } from "@/lib/rate-limit"; +import { apiToHandler } from "@/lib/server"; +import { + checkUploadStorage, + MAX_UPLOAD_PROBE_BYTES, + UploadProbeBody, + UploadProbeLimiter, + UploadProbeTooLarge, +} from "@/lib/upload-health"; +import { getAccessibleOrganization } from "../[...route]/organizationStorage"; + +class ProbeTooLarge extends HttpApiSchema.EmptyError()({ + tag: "ProbeTooLarge", + status: 413, +}) {} + +class ProbeRateLimited extends HttpApiSchema.EmptyError()({ + tag: "ProbeRateLimited", + status: 429, +}) {} + +class Api extends HttpApi.make("DesktopUploadHealth").add( + HttpApiGroup.make("root") + .add( + HttpApiEndpoint.get("warm")`/api/desktop/upload-health`.addSuccess( + Schema.Struct({ ok: Schema.Literal(true) }), + ), + ) + .add( + HttpApiEndpoint.post("probe")`/api/desktop/upload-health` + .setUrlParams( + Schema.Struct({ + checkStorage: Schema.optional(Schema.Literal("true")), + }), + ) + .addSuccess( + Schema.Struct({ bytes: Schema.Number, bodyReadMs: Schema.Number }), + ) + .addError(ProbeTooLarge) + .addError(ProbeRateLimited) + .addError(HttpApiError.BadRequest) + .addError(HttpApiError.Forbidden) + .addError(HttpApiError.ServiceUnavailable) + .addError(HttpApiError.RequestTimeout), + ) + .middleware(HttpAuthMiddleware), +) {} + +const limiter = new UploadProbeLimiter(); + +const ApiLive = HttpApiBuilder.api(Api).pipe( + Layer.provide( + HttpApiBuilder.group(Api, "root", (handlers) => + handlers + .handle("warm", () => Effect.succeed({ ok: true as const })) + .handle("probe", ({ urlParams }) => + Effect.gen(function* () { + const user = yield* CurrentUser; + const request = yield* HttpServerRequest.HttpServerRequest; + if ( + !limiter.admit(user.id) || + (yield* Effect.promise(() => + isRateLimited(RATE_LIMIT_IDS.DESKTOP_UPLOAD_HEALTH, { + key: user.id, + headers: new Headers(request.headers), + }), + )) + ) { + return yield* new ProbeRateLimited(); + } + if ( + Number(request.headers["content-length"]) > MAX_UPLOAD_PROBE_BYTES + ) { + return yield* new ProbeTooLarge(); + } + const probe = new UploadProbeBody(); + yield* request.stream.pipe( + Stream.runForEach((chunk) => + Effect.try({ + try: () => probe.append(chunk), + catch: (error) => + error instanceof UploadProbeTooLarge + ? new ProbeTooLarge() + : new HttpApiError.BadRequest(), + }), + ), + Effect.catchTag( + "RequestError", + () => new HttpApiError.BadRequest(), + ), + ); + const result = probe.result(); + if (!result.bytes) return yield* new HttpApiError.BadRequest(); + if (urlParams.checkStorage) { + if (user.activeOrganizationId) { + const organization = yield* Effect.tryPromise({ + try: () => + getAccessibleOrganization( + user.id, + user.activeOrganizationId, + ), + catch: () => new HttpApiError.ServiceUnavailable(), + }); + if (!organization) return yield* new HttpApiError.Forbidden(); + } + const storage = yield* Storage; + const { access } = yield* storage + .getWritableAccessForUser(user.id, user.activeOrganizationId) + .pipe( + Effect.mapError(() => new HttpApiError.ServiceUnavailable()), + ); + yield* checkUploadStorage({ + userId: user.id, + bytes: probe.sample(), + put: (key, bytes) => + access.putObject(key, Buffer.from(bytes), { + contentType: "application/octet-stream", + }), + remove: (key) => access.deleteObject(key), + onCleanupFailure: () => + console.warn("Upload-health probe cleanup failed"), + }).pipe( + Effect.mapError(() => new HttpApiError.ServiceUnavailable()), + ); + } + return result; + }).pipe( + Effect.timeoutFail({ + duration: "25 seconds", + onTimeout: () => new HttpApiError.RequestTimeout(), + }), + ), + ), + ), + ), +); + +const handler = apiToHandler(ApiLive); +export const GET = handler; +export const POST = handler; +export const OPTIONS = handler; +export const maxDuration = 30; diff --git a/apps/web/lib/rate-limit.ts b/apps/web/lib/rate-limit.ts index bd28a25c4f0..5ea454cbc22 100644 --- a/apps/web/lib/rate-limit.ts +++ b/apps/web/lib/rate-limit.ts @@ -95,6 +95,7 @@ export const RATE_LIMIT_IDS = { GUEST_CHECKOUT: "rl_guest_checkout", /** Unauthed desktop log → Discord forwarding (spam). Suggested: 10 / 1m per IP. */ DESKTOP_LOGS: "rl_desktop_logs", + DESKTOP_UPLOAD_HEALTH: "rl_desktop_upload_health", /** Unauthed docs Ask AI questions (Anthropic cost). Suggested: 10 / 1m per IP. */ DOCS_ASK: "rl_docs_ask", } as const; diff --git a/apps/web/lib/upload-health.ts b/apps/web/lib/upload-health.ts new file mode 100644 index 00000000000..8377e36a0ce --- /dev/null +++ b/apps/web/lib/upload-health.ts @@ -0,0 +1,86 @@ +import { Effect } from "effect"; + +export const MAX_UPLOAD_PROBE_BYTES = 8 * 1024 * 1024; + +export class UploadProbeTooLarge extends Error {} + +export class UploadProbeBody { + private startedAt: number | undefined; + private finishedAt: number | undefined; + private bytes = 0; + private storageSample = new Uint8Array(); + + constructor(private readonly now: () => number = () => performance.now()) {} + + append(chunk: Uint8Array) { + if (this.bytes + chunk.byteLength > MAX_UPLOAD_PROBE_BYTES) { + throw new UploadProbeTooLarge(); + } + if (!chunk.byteLength) return; + const now = this.now(); + this.startedAt ??= now; + this.finishedAt = now; + if (!this.bytes) this.storageSample = chunk.slice(0, 1024); + this.bytes += chunk.byteLength; + } + + result() { + return { + bytes: this.bytes, + bodyReadMs: Math.max(0, (this.finishedAt ?? 0) - (this.startedAt ?? 0)), + }; + } + + sample() { + return this.storageSample; + } +} + +export function checkUploadStorage({ + userId, + bytes, + put, + remove, + onCleanupFailure, + uuid = () => crypto.randomUUID(), +}: { + userId: string; + bytes: Uint8Array; + put: (key: string, bytes: Uint8Array) => Effect.Effect; + remove: (key: string) => Effect.Effect; + onCleanupFailure: (error: unknown) => void; + uuid?: () => string; +}) { + const key = `${userId}/.cap-upload-health/${uuid()}`; + return put(key, bytes).pipe( + Effect.timeout("10 seconds"), + Effect.ensuring( + remove(key).pipe( + Effect.timeout("3 seconds"), + Effect.catchAll((error) => Effect.sync(() => onCleanupFailure(error))), + ), + ), + ); +} + +export class UploadProbeLimiter { + private readonly windows = new Map< + string, + { until: number; count: number } + >(); + + admit(userId: string, now = Date.now()) { + for (const [key, window] of this.windows) { + if (window.until <= now) this.windows.delete(key); + } + const window = this.windows.get(userId); + if (window) { + if (window.count >= 4) return false; + window.count++; + return true; + } + if (this.windows.size >= 1024) return false; + this.windows.set(userId, { until: now + 60_000, count: 1 }); + return true; + } +} From ca0e87f28d6a8c1b85ea0d3ffbd5f40e3c4a89cd Mon Sep 17 00:00:00 2001 From: Calin Rus <127376836+calinrus-dev@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:39:20 +0200 Subject: [PATCH 2/4] fix(desktop-gpui): harden upload diagnostics and storage targeting --- apps/desktop-gpui/src/network_health.rs | 35 ++- .../desktop-gpui/src/network_health/policy.rs | 49 ++++- apps/desktop-gpui/src/recording.rs | 23 +- apps/desktop-gpui/src/session.rs | 81 ++++--- apps/desktop-gpui/src/upload.rs | 41 +++- apps/desktop-gpui/upload-health.md | 24 +- .../unit/desktop-video-create.test.ts | 3 + .../unit/google-drive-storage.test.ts | 65 ++++++ .../unit/upload-health-route.test.ts | 208 +++++++++++++++--- apps/web/app/api/desktop/[...route]/video.ts | 72 ++---- .../app/api/desktop/upload-health/route.ts | 50 +++-- .../web/lib/desktop-recording-organization.ts | 87 ++++++++ .../web-backend/src/Storage/GoogleDrive.ts | 2 + packages/web-backend/src/Storage/index.ts | 105 +++++---- 14 files changed, 627 insertions(+), 218 deletions(-) create mode 100644 apps/web/lib/desktop-recording-organization.ts diff --git a/apps/desktop-gpui/src/network_health.rs b/apps/desktop-gpui/src/network_health.rs index b36fe1e4b33..1a31eab705a 100644 --- a/apps/desktop-gpui/src/network_health.rs +++ b/apps/desktop-gpui/src/network_health.rs @@ -68,6 +68,11 @@ fn current_scope() -> Option { .get("user_id")? .as_str()? .to_string(), + organization: store::store_section(store::RECORDING_SETTINGS) + .get("organizationId") + .and_then(serde_json::Value::as_str) + .filter(|id| !id.is_empty()) + .map(str::to_string), }) } @@ -92,6 +97,11 @@ pub(crate) fn recording_released() { state.applied_width = None; } +#[cfg(test)] +pub(crate) fn probe_admitted_for_test() -> bool { + controller().admission.begin_probe().is_some() +} + pub(crate) async fn wait_for_probe() { let done = controller() .running @@ -102,7 +112,7 @@ pub(crate) async fn wait_for_probe() { } } -pub(crate) fn max_width(configured: u32) -> u32 { +pub(crate) fn max_width(configured: u32, organization: Option<&str>) -> u32 { let Some(scope) = current_scope() else { return configured; }; @@ -110,8 +120,7 @@ pub(crate) fn max_width(configured: u32) -> u32 { let width = state .measurement .as_ref() - .filter(|measurement| measurement.fresh_for(&scope, SystemTime::now())) - .and_then(|measurement| measurement.max_width) + .and_then(|measurement| measurement.max_width_for(&scope, organization, SystemTime::now())) .map_or(configured, |cap| configured.min(cap)); state.applied_width = (width < configured).then_some(width); width @@ -298,6 +307,7 @@ enum ProbeError { struct ProbeResponse { bytes: usize, body_read_ms: f64, + organization_id: String, } async fn probe(scope: Scope) -> Result { @@ -334,6 +344,7 @@ async fn probe(scope: Scope) -> Result { bytes: 0, body_read_ms: 0., }; 2]; + let mut organization: Option = None; for (index, sample) in samples.iter_mut().enumerate() { if current_scope().as_ref() != Some(&scope) { return Err(ProbeError::Failed); @@ -343,8 +354,13 @@ async fn probe(scope: Scope) -> Result { } else { "/api/desktop/upload-health" }; - let response = auth::authed_request_builder(&client, reqwest::Method::POST, path) - .map_err(|_| ProbeError::Failed)? + let mut request = + auth::authed_request_builder(&client, reqwest::Method::POST, path) + .map_err(|_| ProbeError::Failed)?; + if let Some(id) = organization.as_ref().or(scope.organization.as_ref()) { + request = request.query(&[("orgId", id)]); + } + let response = request .header("Content-Type", "application/octet-stream") .timeout(Duration::from_secs(30)) .body(payload.clone()) @@ -358,9 +374,15 @@ async fn probe(scope: Scope) -> Result { .json::() .await .map_err(|_| ProbeError::Failed)?; - if response.bytes != payload.len() { + if response.bytes != payload.len() + || response.organization_id.is_empty() + || organization + .as_ref() + .is_some_and(|id| *id != response.organization_id) + { return Err(ProbeError::Failed); } + organization = Some(response.organization_id); *sample = Sample { bytes: response.bytes, body_read_ms: response.body_read_ms, @@ -368,6 +390,7 @@ async fn probe(scope: Scope) -> Result { } Ok(Measurement::from_samples( scope.clone(), + organization.ok_or(ProbeError::Failed)?, samples, SystemTime::now(), )) diff --git a/apps/desktop-gpui/src/network_health/policy.rs b/apps/desktop-gpui/src/network_health/policy.rs index a80da122b17..59dc4a3b1d9 100644 --- a/apps/desktop-gpui/src/network_health/policy.rs +++ b/apps/desktop-gpui/src/network_health/policy.rs @@ -6,6 +6,7 @@ pub const MEASUREMENT_TTL: Duration = Duration::from_secs(15 * 60); pub struct Scope { pub server: String, pub owner: String, + pub organization: Option, } #[derive(Clone, Copy, Debug)] @@ -27,13 +28,19 @@ impl Sample { #[derive(Clone, Debug)] pub struct Measurement { pub scope: Scope, + pub organization: String, pub checked_at: SystemTime, pub mbps: Option, pub max_width: Option, } impl Measurement { - pub fn from_samples(scope: Scope, samples: [Sample; 2], now: SystemTime) -> Self { + pub fn from_samples( + scope: Scope, + organization: String, + samples: [Sample; 2], + now: SystemTime, + ) -> Self { let rates = samples[0].mbps().zip(samples[1].mbps()); let mbps = rates.map(|(first, second)| first.max(second)); let max_width = rates.and_then(|(first, second)| { @@ -51,6 +58,7 @@ impl Measurement { }); Self { scope, + organization, checked_at: now, mbps, max_width, @@ -63,6 +71,17 @@ impl Measurement { .duration_since(self.checked_at) .is_ok_and(|age| age < MEASUREMENT_TTL) } + + pub fn max_width_for( + &self, + scope: &Scope, + organization: Option<&str>, + now: SystemTime, + ) -> Option { + (self.fresh_for(scope, now) && organization == Some(self.organization.as_str())) + .then_some(self.max_width) + .flatten() + } } #[derive(Default)] @@ -106,6 +125,7 @@ mod tests { Scope { server: "https://cap.example".into(), owner: "owner".into(), + organization: None, } } @@ -116,6 +136,7 @@ mod tests { }; Measurement::from_samples( scope(), + "org-1".into(), [sample(first_mbps), sample(second_mbps)], SystemTime::now(), ) @@ -143,7 +164,8 @@ mod tests { bytes: 2 * 1024 * 1024, body_read_ms: duration, }; - let result = Measurement::from_samples(scope(), [sample; 2], SystemTime::now()); + let result = + Measurement::from_samples(scope(), "org-1".into(), [sample; 2], SystemTime::now()); assert_eq!(result.max_width, None); assert_eq!(result.mbps, None); } @@ -152,7 +174,8 @@ mod tests { body_read_ms: 1000., }; assert_eq!( - Measurement::from_samples(scope(), [sample; 2], SystemTime::now()).max_width, + Measurement::from_samples(scope(), "org-1".into(), [sample; 2], SystemTime::now()) + .max_width, None ); } @@ -192,6 +215,26 @@ mod tests { assert!(!admission.accepts(ticket)); } + #[test] + fn storage_selection_and_actual_recording_destination_bound_the_cap() { + let result = measure(3., 3.); + assert_eq!( + result.max_width_for(&scope(), Some("org-1"), result.checked_at), + Some(1280) + ); + assert_eq!( + result.max_width_for(&scope(), Some("org-2"), result.checked_at), + None + ); + assert_eq!( + result.max_width_for(&scope(), None, result.checked_at), + None + ); + let mut other = scope(); + other.organization = Some("different-selection".into()); + assert!(!result.fresh_for(&other, result.checked_at)); + } + #[test] fn invalidation_rejects_late_results_without_entering_recording() { let mut admission = Admission::default(); diff --git a/apps/desktop-gpui/src/recording.rs b/apps/desktop-gpui/src/recording.rs index c48dc0e2ab2..f3d0586db4a 100644 --- a/apps/desktop-gpui/src/recording.rs +++ b/apps/desktop-gpui/src/recording.rs @@ -2021,24 +2021,24 @@ async fn start_internal( .filter(|organization| !organization.is_empty()) .map(str::to_string); - let pre_created_video = if config.mode == RecordingMode::Instant { - Some( - crate::upload::prepare_instant_upload( - matches!(config.target, ScreenCaptureTarget::CameraOnly), - project_name, - organization_id, - ) - .await - .map_err(anyhow::Error::msg)?, + let (pre_created_video, upload_organization_id) = if config.mode == RecordingMode::Instant { + let (video, organization) = crate::upload::prepare_instant_upload( + matches!(config.target, ScreenCaptureTarget::CameraOnly), + project_name, + organization_id, ) + .await + .map_err(anyhow::Error::msg)?; + (Some(video), organization) } else { - None + (None, None) }; let result = start_attempt_with_upload( config, project_dir, pre_created_video.clone(), + upload_organization_id, #[cfg(target_os = "linux")] attempt.clone(), ) @@ -2071,6 +2071,7 @@ async fn start_attempt_with_upload( config: StartConfig, project_dir: PathBuf, pre_created_video: Option, + upload_organization_id: Option, #[cfg(target_os = "linux")] attempt: Option, ) -> anyhow::Result { #[cfg(target_os = "linux")] @@ -2227,7 +2228,7 @@ async fn start_attempt_with_upload( cap_recording::FREE_INSTANT_MODE_MAX_RESOLUTION }; let instant_max_resolution = if config.mode == RecordingMode::Instant { - crate::network_health::max_width(instant_max_resolution) + crate::network_health::max_width(instant_max_resolution, upload_organization_id.as_deref()) } else { instant_max_resolution }; diff --git a/apps/desktop-gpui/src/session.rs b/apps/desktop-gpui/src/session.rs index 78aed97beed..758af25e8aa 100644 --- a/apps/desktop-gpui/src/session.rs +++ b/apps/desktop-gpui/src/session.rs @@ -462,8 +462,8 @@ struct SessionGlobal(Entity); impl Global for SessionGlobal {} impl RecordingSession { - pub fn init(cx: &mut App) -> Entity { - let session = cx.new(|_| Self { + fn new() -> Self { + Self { phase: Phase::Idle, active: None, error: None, @@ -502,7 +502,11 @@ impl RecordingSession { clean_control: CleanControlState::default(), #[cfg(target_os = "linux")] show_controls_after_pause: false, - }); + } + } + + pub fn init(cx: &mut App) -> Entity { + let session = cx.new(|_| Self::new()); cx.set_global(SessionGlobal(session.clone())); session } @@ -832,6 +836,8 @@ impl RecordingSession { } else { false }; + #[cfg(not(target_os = "linux"))] + let startup_joined = true; this.update(cx, |this, cx| { if this.recording_generation != generation || this.phase != Phase::Starting { if let Ok(Ok(active)) = result { @@ -849,39 +855,11 @@ impl RecordingSession { } Ok(Err(error)) => { tracing::error!("recording failed to start: {error:#}"); - this.error = Some(format!("{error:#}")); - this.abort_start_cue(); - this.countdown_remaining = None; - #[cfg(target_os = "linux")] - { - this.phase = if startup_joined { - Phase::Idle - } else { - Phase::Starting - }; - } - #[cfg(not(target_os = "linux"))] - { - this.phase = Phase::Idle; - } + this.fail_start(format!("{error:#}"), startup_joined); } Err(join_error) => { tracing::error!("recording start task died: {join_error}"); - this.error = Some("Recording task failed.".into()); - this.abort_start_cue(); - this.countdown_remaining = None; - #[cfg(target_os = "linux")] - { - this.phase = if startup_joined { - Phase::Idle - } else { - Phase::Starting - }; - } - #[cfg(not(target_os = "linux"))] - { - this.phase = Phase::Idle; - } + this.fail_start("Recording task failed.".into(), startup_joined); } } #[cfg(target_os = "linux")] @@ -906,6 +884,20 @@ impl RecordingSession { .detach(); } + fn fail_start(&mut self, error: String, cleanup_joined: bool) { + self.error = Some(error); + self.abort_start_cue(); + self.countdown_remaining = None; + self.phase = if cleanup_joined { + Phase::Idle + } else { + Phase::Starting + }; + if cleanup_joined { + crate::network_health::recording_released(); + } + } + fn activate_primed(&mut self, cx: &mut Context) { if let Some(active) = self.primed.take() { self.activate(active, cx); @@ -2656,4 +2648,27 @@ mod studio_editor_presentation_tests { state.closed(2); assert_eq!(state, StudioEditorPresentation::Dismissed); } + #[test] + fn failed_starts_release_upload_checks_only_after_capture_cleanup() { + let mut session = RecordingSession::new(); + for error in [ + "Missing device", + "Upload setup failed", + "Recording task failed.", + ] { + crate::network_health::recording_admitted(); + session.phase = Phase::Starting; + assert!(!crate::network_health::probe_admitted_for_test()); + session.fail_start(error.into(), true); + assert_eq!(session.phase, Phase::Idle); + assert_eq!(session.error.as_deref(), Some(error)); + assert!(crate::network_health::probe_admitted_for_test()); + } + crate::network_health::recording_admitted(); + session.fail_start("Cleanup unconfirmed".into(), false); + assert_eq!(session.phase, Phase::Starting); + assert!(!crate::network_health::probe_admitted_for_test()); + session.fail_start("Cleanup joined".into(), true); + assert!(crate::network_health::probe_admitted_for_test()); + } } diff --git a/apps/desktop-gpui/src/upload.rs b/apps/desktop-gpui/src/upload.rs index d8f11085955..c56a92b6970 100644 --- a/apps/desktop-gpui/src/upload.rs +++ b/apps/desktop-gpui/src/upload.rs @@ -42,6 +42,14 @@ struct SignedUploadTarget { headers: HashMap, } +#[derive(Deserialize)] +struct CreatedVideo { + #[serde(flatten)] + config: S3UploadMeta, + #[serde(default, rename = "organizationId")] + organization_id: Option, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum UploadResult { Success(String), @@ -233,7 +241,7 @@ pub async fn prepare_instant_upload( camera_only: bool, project_name: String, organization_id: Option, -) -> Result { +) -> Result<(VideoUploadInfo, Option), String> { if store_auth_missing() { return Err("Please sign in to use instant recording".to_string()); } @@ -260,11 +268,14 @@ pub async fn prepare_instant_upload( error => format!("Could not create the shareable link: {error}"), })?; - Ok(VideoUploadInfo { - id: config.id.clone(), - link: format!("{}/s/{}", auth::server_url(), config.id), - config, - }) + Ok(( + VideoUploadInfo { + id: config.config.id.clone(), + link: format!("{}/s/{}", auth::server_url(), config.config.id), + config: config.config, + }, + config.organization_id, + )) } pub(crate) fn start_instant_upload( @@ -1421,6 +1432,7 @@ async fn create_or_get_video( "desktopMP4", ) .await + .map(|created| created.config) } async fn create_or_get_video_with_mode( @@ -1430,7 +1442,7 @@ async fn create_or_get_video_with_mode( meta: Option<&VideoMeta>, organization_id: Option, recording_mode: &str, -) -> Result { +) -> Result { let mut path = format!( "/api/desktop/video/create?recordingMode={}", urlencoding(recording_mode) @@ -1472,7 +1484,7 @@ async fn create_or_get_video_with_mode( .text() .await .map_err(|error| AuthApiError::Other(format!("Failed to read response body: {error}")))?; - serde_json::from_str::(&text) + serde_json::from_str::(&text) .map_err(|_| AuthApiError::Other("Invalid video creation response".into())) } @@ -2505,6 +2517,19 @@ fn urlencoding(value: &str) -> String { mod tests { use super::*; + #[test] + fn created_video_accepts_legacy_and_organization_bound_responses() { + for organization in [None, Some("org-recording")] { + let mut response = json!({ "id": "video-recording" }); + if let Some(id) = organization { + response["organizationId"] = json!(id); + } + let created: CreatedVideo = serde_json::from_value(response).unwrap(); + assert_eq!(created.config.id, "video-recording"); + assert_eq!(created.organization_id.as_deref(), organization); + } + } + #[tokio::test] async fn presigned_transfer_errors_never_expose_secret_urls() { let client = reqwest::Client::new(); diff --git a/apps/desktop-gpui/upload-health.md b/apps/desktop-gpui/upload-health.md index 89710966e4c..2778f1d8fc0 100644 --- a/apps/desktop-gpui/upload-health.md +++ b/apps/desktop-gpui/upload-health.md @@ -4,7 +4,8 @@ The signed-in GPUI app checks its upload path at startup and at most once an hou while idle. The header indicator can request a manual check, with a one-minute cooldown. Signing out or changing the server invalidates the account-bound result; saving/deleting S3 settings or connecting, disconnecting, or switching Google Drive -also invalidates it. Checks do not block recording when diagnostics are unavailable. +also invalidates it. Changing the selected recording organization invalidates the +result. Checks do not block recording when diagnostics are unavailable. ## Measurement and resolution @@ -35,16 +36,29 @@ expires. Recording admission cancels the active probe and invalidates its generation. Engine startup waits for the probe future to be dropped before continuing; late results cannot update the measurement or start another probe during recording. +Failed startup releases admission only after capture cleanup has joined. A cap +also requires the organization returned when creating the recording to match the +probe destination; missing organization information from an older server preserves +the configured resolution. ## Storage check and bounds -Only the first sample checks storage. After validating current organization -membership, it resolves the same writable storage access used for recordings +Only the first sample checks storage. The endpoint uses the recording organization +selection order: a valid saved organization, a valid default, then the earliest +owned or joined organization. Both samples must resolve to the same organization. +After validating current membership, it resolves the same writable access used for recordings (organization/personal S3, Google Drive, or default storage). It writes at most 1 KiB of synthetic data under `/.cap-upload-health/` and attempts cleanup even when writing fails. Write failures make the check unavailable; cleanup failures are logged and do not turn a successful upload into a failure. The check verifies storage write access, not screen capture or encoder correctness. +Drive probes use a null video ID and the configured root folder, without creating +recording folders or warning files. Ordinary recording uploads retain their existing +folder layout and video association. + +Cookie-authenticated POSTs require same-origin Fetch Metadata, or an exact configured +Origin when Fetch Metadata is absent. Validated API keys remain usable by native +clients without browser headers; malformed authorization cannot bypass this guard. Body input is streamed with an 8 MiB limit; the reader retains only the small storage sample. Storage writes time out after ten seconds, cleanup after three, @@ -64,12 +78,14 @@ they demonstrate rendering, not measured network performance or a real recording - [Failed upload check](docs/upload-health-failed-linux.png) ```sh -bun run --cwd apps/web test __tests__/unit/upload-health.test.ts __tests__/unit/upload-health-route.test.ts +bun run --cwd apps/web test __tests__/unit/upload-health.test.ts __tests__/unit/upload-health-route.test.ts __tests__/unit/desktop-video-create.test.ts __tests__/unit/google-drive-storage.test.ts ./scripts/prepare-gpui-dependency.sh # Run scripts/setup.js with the same target/native dependencies as desktop CI. cd apps/desktop-gpui cargo check --locked -p cap-desktop-gpui cargo test --locked -p cap-desktop-gpui --bin cap-gpui network_health +cargo test --locked -p cap-desktop-gpui --bin cap-gpui failed_starts_release_upload_checks_only_after_capture_cleanup +cargo test --locked -p cap-desktop-gpui --bin cap-gpui created_video_accepts_legacy_and_organization_bound_responses ``` Manual desktop checks before acceptance: diff --git a/apps/web/__tests__/unit/desktop-video-create.test.ts b/apps/web/__tests__/unit/desktop-video-create.test.ts index 795c0aaeb93..07688d33575 100644 --- a/apps/web/__tests__/unit/desktop-video-create.test.ts +++ b/apps/web/__tests__/unit/desktop-video-create.test.ts @@ -500,6 +500,7 @@ describe("GET /create", () => { const body = await response.json(); expect(body.id).toBeTruthy(); + expect(body.organizationId).toBe(orgValues?.id); }); it("does not provision when the user already belongs to an org", async () => { @@ -633,6 +634,7 @@ describe("GET /create", () => { ); expect(response.status).toBe(200); + expect((await response.json()).organizationId).toBe("org-1"); expect(mockDb.transaction).toHaveBeenCalledTimes(1); expect(insertedValues(schema.videos)).toMatchObject({ @@ -660,6 +662,7 @@ describe("GET /create", () => { const response = await app.request("https://cap.test/create?orgId=org-2"); expect(response.status).toBe(200); + expect((await response.json()).organizationId).toBe("org-2"); expect(mockDb.transaction).toHaveBeenCalledTimes(1); expect(insertedValues(schema.videos)).toMatchObject({ diff --git a/apps/web/__tests__/unit/google-drive-storage.test.ts b/apps/web/__tests__/unit/google-drive-storage.test.ts index b426c9b57af..a4f13cb01c2 100644 --- a/apps/web/__tests__/unit/google-drive-storage.test.ts +++ b/apps/web/__tests__/unit/google-drive-storage.test.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { Organisation, Storage, User, Video } from "@cap/web-domain"; import { Cause, Effect, Exit, Layer, Option } from "effect"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { checkUploadStorage } from "@/lib/upload-health"; vi.mock("@cap/env", () => ({ serverEnv: () => ({ @@ -1845,6 +1846,70 @@ const getStorageAccess = (harness: RepoHarness) => { ); }; +describe("Google Drive upload-health objects", () => { + it.each(["video", "userVideo"] as const)( + "probes %s storage without video IDs or recording folders", + async (layout) => { + const harness = makeRepoHarness(); + vi.spyOn(harness.repo, "getGoogleDriveConfig").mockReturnValue( + Effect.succeed(makeConfig(layout)), + ); + const reserve = harness.repo.reserveObject; + vi.spyOn(harness.repo, "reserveObject").mockImplementation((input) => { + if (input.videoId && input.videoId.length > 15) + return Effect.die(new Error("videoId exceeds database width")); + return reserve(input); + }); + let uploaded = false; + const fetchMock = vi.fn( + async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + if (url.includes("/files/generateIds")) + return jsonResponse({ ids: ["probe-file"] }); + if (url.includes("uploadType=resumable") && init?.method === "POST") { + expect(requestBody(init)).toMatchObject({ + parents: ["root-folder"], + appProperties: { + capObjectKey: `${videoOwnerId}/.cap-upload-health/probe`, + }, + }); + return response(200, { Location: "https://upload.test/probe" }); + } + if (url === "https://upload.test/probe" && init?.method === "PUT") { + uploaded = true; + return response(200); + } + if (url.includes("/files/probe-file") && init?.method === "DELETE") + return response(204); + throw new Error( + `Unexpected Drive probe request: ${init?.method} ${url}`, + ); + }, + ); + vi.stubGlobal("fetch", fetchMock); + const access = await getStorageAccess(harness); + await Effect.runPromise( + checkUploadStorage({ + userId: videoOwnerId, + bytes: new Uint8Array([1, 2, 3]), + uuid: () => "probe", + put: (key, bytes) => access.putProbeObject(key, bytes), + remove: (key) => access.deleteObject(key), + onCleanupFailure: () => { + throw new Error("Cleanup failed"); + }, + }), + ); + expect(uploaded).toBe(true); + expect(harness.reservations).toHaveLength(1); + expect(harness.reservations[0]?.videoId).toBeNull(); + expect(harness.reservations[0]?.metadata?.videoId).toBeUndefined(); + expect(harness.objects.size).toBe(0); + expect(fetchMock).toHaveBeenCalledTimes(4); + }, + ); +}); + const getStorageObjectExit = async (harness: RepoHarness) => { const access = await getStorageAccess(harness); return Effect.runPromiseExit(access.getObject(resultKey)); diff --git a/apps/web/__tests__/unit/upload-health-route.test.ts b/apps/web/__tests__/unit/upload-health-route.test.ts index c0023d98152..77c8f95f010 100644 --- a/apps/web/__tests__/unit/upload-health-route.test.ts +++ b/apps/web/__tests__/unit/upload-health-route.test.ts @@ -1,9 +1,5 @@ -import { - HttpApiBuilder, - HttpServer, - HttpServerRequest, -} from "@effect/platform"; -import { Context, Effect, Layer, Option } from "effect"; +import { HttpApiBuilder, HttpServer } from "@effect/platform"; +import { Context, Effect, Layer } from "effect"; import { beforeEach, describe, expect, it, vi } from "vitest"; const state = vi.hoisted(() => ({ @@ -12,6 +8,10 @@ const state = vi.hoisted(() => ({ storageAvailable: true, cleanupAvailable: true, organizationAllowed: true, + cookieValid: false, + apiKeyValid: true, + rateChecks: 0, + selectedOrganization: "", rateLimited: false, })); @@ -21,38 +21,85 @@ vi.mock("@cap/web-backend", async () => { }); vi.mock("@/lib/rate-limit", () => ({ - isRateLimited: async () => state.rateLimited, + isRateLimited: async () => { + state.rateChecks++; + return state.rateLimited; + }, RATE_LIMIT_IDS: { DESKTOP_UPLOAD_HEALTH: "rl_desktop_upload_health" }, })); -vi.mock("@/app/api/desktop/[...route]/organizationStorage", () => ({ - getAccessibleOrganization: async () => - state.organizationAllowed ? { id: "org" } : null, +vi.mock("@/lib/desktop-recording-organization", async (importOriginal) => { + const { Organisation } = await import("@cap/web-domain"); + const original = + await importOriginal< + typeof import("@/lib/desktop-recording-organization") + >(); + return { + ...original, + getDesktopRecordingOrganization: async ( + _user: string, + requested?: string, + ) => + state.organizationAllowed + ? original.selectDesktopRecordingOrganization( + [{ id: "org-default" }, { id: "org-selected" }].map(({ id }) => ({ + id: Organisation.OrganisationId.make(id), + })), + requested ? Organisation.OrganisationId.make(requested) : undefined, + Organisation.OrganisationId.make("org-default"), + ) + : undefined, + }; +}); + +vi.mock("@cap/env", () => ({ + serverEnv: () => ({ WEB_URL: "https://cap.test" }), +})); +vi.mock("@cap/database/auth/auth-options", () => ({ + getServerSession: async () => + state.cookieValid ? { user: { id: "test-user" } } : null, })); vi.mock("@/lib/server", async () => { - const { CurrentUser, HttpAuthMiddleware, Organisation, User } = await import( - "@cap/web-domain" - ); - const { HttpApiError } = await import("@effect/platform"); const storage = Context.GenericTag("UploadHealthTestStorage"); - const authentication = Layer.succeed( - HttpAuthMiddleware, - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - if (request.headers.authorization !== "Bearer test") { - return yield* new HttpApiError.Unauthorized(); - } - return CurrentUser.of({ - id: User.UserId.make("test-user"), - email: "dev@example.test", - activeOrganizationId: Organisation.OrganisationId.make("org"), - iconUrlOrKey: Option.none(), - }); - }), + const { HttpAuthMiddlewareLive } = await import( + "../../../../packages/web-backend/src/Auth" + ); + const { Database } = await import( + "../../../../packages/web-backend/src/Database" ); + const user = { + id: "test-user", + email: "dev@example.test", + activeOrganizationId: "org-dashboard", + image: null, + }; + const database = { + use: (run: (db: unknown) => Promise) => + Effect.promise(() => + run({ + select: () => ({ + from: () => ({ + leftJoin: () => ({ + where: async () => (state.apiKeyValid ? [{ users: user }] : []), + }), + where: async () => [user], + }), + }), + }), + ), + }; + const authentication = HttpAuthMiddlewareLive.pipe( + Layer.provide( + Layer.succeed( + Database, + database as unknown as Context.Tag.Service, + ), + ), + ); + const access = { - putObject: (key: string) => + putProbeObject: (key: string) => Effect.suspend(() => { state.writes.push(key); return state.storageAvailable @@ -74,7 +121,13 @@ vi.mock("@/lib/server", async () => { Layer.provide(authentication), Layer.provide( Layer.succeed(storage, { - getWritableAccessForUser: () => Effect.succeed({ access }), + getWritableAccessForUser: ( + _user: string, + organization: string, + ) => { + state.selectedOrganization = organization; + return Effect.succeed({ access }); + }, }), ), Layer.provide(HttpServer.layerContext), @@ -88,7 +141,7 @@ import { GET, POST } from "@/app/api/desktop/upload-health/route"; function request( checkStorage = true, body = new Uint8Array(1024), - token = "test", + token = "12345678-1234-1234-1234-123456789abc", ) { return new Request( `http://localhost/api/desktop/upload-health${checkStorage ? "?checkStorage=true" : ""}`, @@ -110,6 +163,10 @@ beforeEach(() => { state.cleanupAvailable = true; state.organizationAllowed = true; state.rateLimited = false; + state.cookieValid = false; + state.apiKeyValid = true; + state.rateChecks = 0; + state.selectedOrganization = ""; }); describe("authenticated upload-health API", () => { @@ -122,7 +179,9 @@ describe("authenticated upload-health API", () => { it("warms the connection without writing or deleting an object", async () => { const response = await GET( new Request("http://localhost/api/desktop/upload-health", { - headers: { authorization: "Bearer test" }, + headers: { + authorization: "Bearer 12345678-1234-1234-1234-123456789abc", + }, }), ); expect(response.status).toBe(200); @@ -133,7 +192,11 @@ describe("authenticated upload-health API", () => { it("checks the selected storage under the user's prefix and returns body timing", async () => { const response = await POST(request()); expect(response.status).toBe(200); - expect(await response.json()).toEqual({ bytes: 1024, bodyReadMs: 0 }); + expect(await response.json()).toEqual({ + bytes: 1024, + bodyReadMs: 0, + organizationId: "org-default", + }); expect(state.writes[0]).toMatch(/^test-user\/\.cap-upload-health\//); expect(state.deletes).toEqual(state.writes); }); @@ -144,10 +207,10 @@ describe("authenticated upload-health API", () => { expect(state.writes).toEqual([]); }); - it("rejects a stale organization membership before touching storage", async () => { + it("reports no available organization without touching storage", async () => { state.organizationAllowed = false; const response = await POST(request()); - expect(response.status).toBe(403); + expect(response.status).toBe(503); expect(state.writes).toEqual([]); }); @@ -193,3 +256,78 @@ describe("authenticated upload-health API", () => { expect(state.writes).toEqual([]); }); }); + +describe("upload probe credential boundary", () => { + const cookieProbe = (headers: Record = {}) => { + state.cookieValid = true; + return new Request( + "https://cap.test/api/desktop/upload-health?checkStorage=true", + { + method: "POST", + headers: { + cookie: "next-auth.session-token=valid", + "content-type": "text/plain", + ...headers, + }, + body: "probe", + }, + ); + }; + it.each>([ + {}, + { origin: "https://attacker.test" }, + { origin: "null" }, + { "sec-fetch-site": "cross-site", origin: "https://attacker.test" }, + { "sec-fetch-site": "same-site", origin: "https://sub.cap.test" }, + { "sec-fetch-site": "none", origin: "https://cap.test" }, + { authorization: "Bearer invalid", origin: "https://attacker.test" }, + { authorization: "Bearer", origin: "https://attacker.test" }, + ])( + "rejects ambient cookie POSTs before rate limiting/storage: %j", + async (headers) => { + expect((await POST(cookieProbe(headers))).status).toBe(403); + expect(state.rateChecks).toBe(0); + expect(state.writes).toEqual([]); + expect(state.deletes).toEqual([]); + }, + ); + it.each>([ + { "sec-fetch-site": "same-origin" }, + { origin: "https://cap.test" }, + ])("allows legitimate same-origin cookies: %j", async (headers) => { + expect((await POST(cookieProbe(headers))).status).toBe(200); + expect(state.writes).toHaveLength(1); + }); + it("does not fall back to a cookie for an invalid API key", async () => { + state.apiKeyValid = false; + expect( + ( + await POST( + cookieProbe({ + authorization: "Bearer 00000000-0000-0000-0000-000000000000", + origin: "https://attacker.test", + }), + ) + ).status, + ).toBe(401); + expect(state.writes).toEqual([]); + }); + it("checks the saved desktop organization instead of the dashboard organization", async () => { + const probe = request(); + const url = new URL(probe.url); + url.searchParams.set("orgId", "org-selected"); + const response = await POST(new Request(url, probe)); + expect(response.status).toBe(200); + expect(state.selectedOrganization).toBe("org-selected"); + expect(await response.json()).toMatchObject({ + organizationId: "org-selected", + }); + }); + it("uses the default organization for a stale desktop selection", async () => { + const probe = request(); + const url = new URL(probe.url); + url.searchParams.set("orgId", "departed-org"); + expect((await POST(new Request(url, probe))).status).toBe(200); + expect(state.selectedOrganization).toBe("org-default"); + }); +}); diff --git a/apps/web/app/api/desktop/[...route]/video.ts b/apps/web/app/api/desktop/[...route]/video.ts index 8565e429fd9..25981a2379e 100644 --- a/apps/web/app/api/desktop/[...route]/video.ts +++ b/apps/web/app/api/desktop/[...route]/video.ts @@ -21,6 +21,10 @@ import { Effect, Option } from "effect"; import { Hono } from "hono"; import { after } from "next/server"; import { z } from "zod"; +import { + mergeUserOrganizationSelections, + selectDesktopRecordingOrganization, +} from "@/lib/desktop-recording-organization"; import { invalidateGoogleDriveStorageQuotaCache } from "@/lib/google-drive-storage-quota"; import { maybeStartLiveTranscription } from "@/lib/live-transcribe"; import { runPromise } from "@/lib/server"; @@ -35,28 +39,6 @@ import { withAuth } from "../../utils"; export const app = new Hono().use(withAuth); -type UserOrganizationSelection = { - id: Organisation.OrganisationId; - name: string; - createdAt: Date; -}; - -function mergeUserOrganizationSelections( - ...rowSets: UserOrganizationSelection[][] -) { - const organizationsById = new Map(); - - for (const rows of rowSets) { - for (const row of rows) { - organizationsById.set(row.id, row); - } - } - - return Array.from(organizationsById.values()) - .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) - .map(({ createdAt, ...organization }) => organization); -} - app.get("/new-id", (c) => c.json({ id: nanoId() })); app.get( @@ -239,35 +221,22 @@ app.get( userOrganizations.push({ id: organizationId, name: "My Organization" }); } - const userOrgIds = userOrganizations.map((org) => org.id); - - let videoOrgId: Organisation.OrganisationId; - // Desktop persists orgId in settings and keeps sending it after the - // user leaves the org, so an unknown orgId falls back to the - // default/first org below instead of hard-failing every upload. - if (orgId && userOrgIds.includes(orgId)) { - videoOrgId = orgId; - } else if (user.defaultOrgId) { - // User's defaultOrgId is no longer valid, switch to first available org - if (!userOrgIds.includes(user.defaultOrgId)) { - if (!userOrganizations[0]) - return c.json({ error: "no_valid_org" }, { status: 403 }); - - videoOrgId = userOrganizations[0].id; - - // Update user's defaultOrgId to the new valid org - await db() - .update(users) - .set({ - defaultOrgId: videoOrgId, - }) - .where(eq(users.id, user.id)); - } else videoOrgId = user.defaultOrgId; - } else { - // No orgId provided and no defaultOrgId, use first available org - if (!userOrganizations[0]) - return c.json({ error: "no_valid_org" }, { status: 403 }); - videoOrgId = userOrganizations[0].id; + const videoOrgId = selectDesktopRecordingOrganization( + userOrganizations, + orgId, + user.defaultOrgId, + ); + if (!videoOrgId) + return c.json({ error: "no_valid_org" }, { status: 403 }); + if ( + orgId !== videoOrgId && + user.defaultOrgId && + !userOrganizations.some((org) => org.id === user.defaultOrgId) + ) { + await db() + .update(users) + .set({ defaultOrgId: videoOrgId }) + .where(eq(users.id, user.id)); } const idToUse = Video.VideoId.make( @@ -391,6 +360,7 @@ app.get( return c.json({ id: idToUse, + organizationId: videoOrgId, // All deprecated user_id: user.id, aws_region: "n/a", diff --git a/apps/web/app/api/desktop/upload-health/route.ts b/apps/web/app/api/desktop/upload-health/route.ts index 28dc3e7b5e5..725c997c74b 100644 --- a/apps/web/app/api/desktop/upload-health/route.ts +++ b/apps/web/app/api/desktop/upload-health/route.ts @@ -1,5 +1,6 @@ +import { serverEnv } from "@cap/env"; import { Storage } from "@cap/web-backend"; -import { CurrentUser, HttpAuthMiddleware } from "@cap/web-domain"; +import { CurrentUser, HttpAuthMiddleware, Organisation } from "@cap/web-domain"; import { HttpApi, HttpApiBuilder, @@ -10,6 +11,7 @@ import { HttpServerRequest, } from "@effect/platform"; import { Effect, Layer, Schema, Stream } from "effect"; +import { getDesktopRecordingOrganization } from "@/lib/desktop-recording-organization"; import { isRateLimited, RATE_LIMIT_IDS } from "@/lib/rate-limit"; import { apiToHandler } from "@/lib/server"; import { @@ -19,7 +21,6 @@ import { UploadProbeLimiter, UploadProbeTooLarge, } from "@/lib/upload-health"; -import { getAccessibleOrganization } from "../[...route]/organizationStorage"; class ProbeTooLarge extends HttpApiSchema.EmptyError()({ tag: "ProbeTooLarge", @@ -43,10 +44,15 @@ class Api extends HttpApi.make("DesktopUploadHealth").add( .setUrlParams( Schema.Struct({ checkStorage: Schema.optional(Schema.Literal("true")), + orgId: Schema.optional(Organisation.OrganisationId), }), ) .addSuccess( - Schema.Struct({ bytes: Schema.Number, bodyReadMs: Schema.Number }), + Schema.Struct({ + bytes: Schema.Number, + bodyReadMs: Schema.Number, + organizationId: Organisation.OrganisationId, + }), ) .addError(ProbeTooLarge) .addError(ProbeRateLimited) @@ -69,6 +75,18 @@ const ApiLive = HttpApiBuilder.api(Api).pipe( Effect.gen(function* () { const user = yield* CurrentUser; const request = yield* HttpServerRequest.HttpServerRequest; + const apiKey = request.headers.authorization?.split(" ")[1]; + if (apiKey?.length !== 36) { + const site = request.headers["sec-fetch-site"]; + if ( + site !== undefined + ? site !== "same-origin" + : request.headers.origin !== + new URL(serverEnv().WEB_URL).origin + ) { + return yield* new HttpApiError.Forbidden(); + } + } if ( !limiter.admit(user.id) || (yield* Effect.promise(() => @@ -103,21 +121,17 @@ const ApiLive = HttpApiBuilder.api(Api).pipe( ); const result = probe.result(); if (!result.bytes) return yield* new HttpApiError.BadRequest(); + const organizationId = yield* Effect.tryPromise({ + try: () => + getDesktopRecordingOrganization(user.id, urlParams.orgId), + catch: () => new HttpApiError.ServiceUnavailable(), + }); + if (!organizationId) + return yield* new HttpApiError.ServiceUnavailable(); if (urlParams.checkStorage) { - if (user.activeOrganizationId) { - const organization = yield* Effect.tryPromise({ - try: () => - getAccessibleOrganization( - user.id, - user.activeOrganizationId, - ), - catch: () => new HttpApiError.ServiceUnavailable(), - }); - if (!organization) return yield* new HttpApiError.Forbidden(); - } const storage = yield* Storage; const { access } = yield* storage - .getWritableAccessForUser(user.id, user.activeOrganizationId) + .getWritableAccessForUser(user.id, organizationId) .pipe( Effect.mapError(() => new HttpApiError.ServiceUnavailable()), ); @@ -125,9 +139,7 @@ const ApiLive = HttpApiBuilder.api(Api).pipe( userId: user.id, bytes: probe.sample(), put: (key, bytes) => - access.putObject(key, Buffer.from(bytes), { - contentType: "application/octet-stream", - }), + access.putProbeObject(key, Buffer.from(bytes)), remove: (key) => access.deleteObject(key), onCleanupFailure: () => console.warn("Upload-health probe cleanup failed"), @@ -135,7 +147,7 @@ const ApiLive = HttpApiBuilder.api(Api).pipe( Effect.mapError(() => new HttpApiError.ServiceUnavailable()), ); } - return result; + return { ...result, organizationId }; }).pipe( Effect.timeoutFail({ duration: "25 seconds", diff --git a/apps/web/lib/desktop-recording-organization.ts b/apps/web/lib/desktop-recording-organization.ts new file mode 100644 index 00000000000..25690027988 --- /dev/null +++ b/apps/web/lib/desktop-recording-organization.ts @@ -0,0 +1,87 @@ +import { db } from "@cap/database"; +import { + organizationMembers, + organizations, + users, +} from "@cap/database/schema"; +import type { Organisation, User } from "@cap/web-domain"; +import { and, eq, isNull } from "drizzle-orm"; + +type UserOrganizationSelection = { + id: Organisation.OrganisationId; + name: string; + createdAt: Date; +}; + +export function mergeUserOrganizationSelections( + ...rowSets: UserOrganizationSelection[][] +) { + const byId = new Map(); + for (const rows of rowSets) { + for (const row of rows) byId.set(row.id, row); + } + return Array.from(byId.values()) + .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) + .map(({ createdAt, ...organization }) => organization); +} + +export function selectDesktopRecordingOrganization( + available: Array<{ id: Organisation.OrganisationId }>, + requested?: Organisation.OrganisationId, + defaultId?: Organisation.OrganisationId | null, +) { + return ( + [requested, defaultId].find( + (id): id is Organisation.OrganisationId => + !!id && available.some((organization) => organization.id === id), + ) ?? available[0]?.id + ); +} + +export async function getDesktopRecordingOrganization( + userId: User.UserId, + requested?: Organisation.OrganisationId, +) { + const [[user], owned, member] = await Promise.all([ + db() + .select({ defaultOrgId: users.defaultOrgId }) + .from(users) + .where(eq(users.id, userId)) + .limit(1), + db() + .select({ + id: organizations.id, + name: organizations.name, + createdAt: organizations.createdAt, + }) + .from(organizations) + .where( + and( + eq(organizations.ownerId, userId), + isNull(organizations.tombstoneAt), + ), + ), + db() + .select({ + id: organizations.id, + name: organizations.name, + createdAt: organizations.createdAt, + }) + .from(organizationMembers) + .innerJoin( + organizations, + eq(organizations.id, organizationMembers.organizationId), + ) + .where( + and( + eq(organizationMembers.userId, userId), + isNull(organizations.tombstoneAt), + ), + ), + ]); + return selectDesktopRecordingOrganization( + mergeUserOrganizationSelections(owned, member), + requested, + user?.defaultOrgId, + ); +} diff --git a/packages/web-backend/src/Storage/GoogleDrive.ts b/packages/web-backend/src/Storage/GoogleDrive.ts index ead4134c34e..9d99bfda484 100644 --- a/packages/web-backend/src/Storage/GoogleDrive.ts +++ b/packages/web-backend/src/Storage/GoogleDrive.ts @@ -92,6 +92,7 @@ export type CreateGoogleDriveUploadInput = { contentType: string; contentLength?: number; videoTitle?: string; + skipRecordingFolders?: boolean; }; const normalizeContentType = (contentType?: string | null) => @@ -1014,6 +1015,7 @@ const getGoogleDriveUploadParentId = ( tokenStore?: GoogleDriveTokenStore, ) => Effect.gen(function* () { + if (input.skipRecordingFolders) return config.folderId; const folderParts = getDriveFolderParts(input.key, config); let parentId = config.folderId; const pathParts: string[] = []; diff --git a/packages/web-backend/src/Storage/index.ts b/packages/web-backend/src/Storage/index.ts index 10455252b30..9394bc6a4d7 100644 --- a/packages/web-backend/src/Storage/index.ts +++ b/packages/web-backend/src/Storage/index.ts @@ -462,6 +462,10 @@ const makeS3Access = (s3: S3BucketAccess) => ({ body: Parameters[1], fields?: Parameters[2], ) => mapStorageError(s3.putObject(key, body, fields)).pipe(Effect.asVoid), + putProbeObject: (key: string, body: Uint8Array) => + mapStorageError( + s3.putObject(key, body, { contentType: "application/octet-stream" }), + ).pipe(Effect.asVoid), copyObject: ( source: string, key: string, @@ -809,6 +813,56 @@ const makeGoogleDriveAccess = ({ ), }; + const putObject = ( + key: string, + body: string | Uint8Array | ArrayBuffer, + fields?: { contentType?: string; contentLength?: number }, + probe = false, + ) => + Effect.gen(function* () { + const contentType = fields?.contentType ?? "application/octet-stream"; + const contentLength = + fields?.contentLength ?? + (typeof body === "string" + ? new TextEncoder().encode(body).byteLength + : body.byteLength); + const uploadUrl = yield* createGoogleDriveResumableUpload( + repo, + config, + { + integrationId, + ownerId, + videoId: probe ? null : parseObjectKeyVideoId(key), + skipRecordingFolders: probe, + key, + contentType, + contentLength, + }, + tokenStore, + ).pipe(mapStorageError); + const uploadBody = + body instanceof Uint8Array ? new Uint8Array(body).buffer : body; + const response = yield* Effect.tryPromise({ + try: () => + fetch(uploadUrl, { + method: "PUT", + headers: getGoogleDriveUploadHeaders(contentType, contentLength), + body: uploadBody, + }), + catch: (cause) => new StorageDomain.StorageError({ cause }), + }); + if (!response.ok) { + return yield* Effect.fail( + new StorageDomain.StorageError({ + cause: new Error(`Google Drive upload failed: ${response.status}`), + }), + ); + } + yield* mapStorageError( + repo.markObjectComplete(integrationId, key, contentLength), + ); + }); + return { provider: "googleDrive" as const, bucketName: "google-drive", @@ -904,54 +958,9 @@ const makeGoogleDriveAccess = ({ key: string, body: string | Uint8Array | ArrayBuffer, fields?: { contentType?: string; contentLength?: number }, - ) => - Effect.gen(function* () { - const contentType = fields?.contentType ?? "application/octet-stream"; - const contentLength = - fields?.contentLength ?? - (typeof body === "string" - ? new TextEncoder().encode(body).byteLength - : body.byteLength); - const uploadUrl = yield* createGoogleDriveResumableUpload( - repo, - config, - { - integrationId, - ownerId, - videoId: parseVideoIdFromObjectKey(key).pipe( - Option.map((id) => id as Video.VideoId), - Option.getOrNull, - ), - key, - contentType, - contentLength, - }, - tokenStore, - ).pipe(mapStorageError); - const uploadBody = - body instanceof Uint8Array ? new Uint8Array(body).buffer : body; - const response = yield* Effect.tryPromise({ - try: () => - fetch(uploadUrl, { - method: "PUT", - headers: getGoogleDriveUploadHeaders(contentType, contentLength), - body: uploadBody, - }), - catch: (cause) => new StorageDomain.StorageError({ cause }), - }); - if (!response.ok) { - return yield* Effect.fail( - new StorageDomain.StorageError({ - cause: new Error( - `Google Drive upload failed: ${response.status}`, - ), - }), - ); - } - yield* mapStorageError( - repo.markObjectComplete(integrationId, key, contentLength), - ); - }), + ) => putObject(key, body, fields), + putProbeObject: (key: string, body: Uint8Array) => + putObject(key, body, undefined, true), copyObject, copyObjectForRecording, deleteObject, From f7a1202e7a1d4e3cff4877809e989f410c711a33 Mon Sep 17 00:00:00 2001 From: Calin Rus <127376836+calinrus-dev@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:30:41 +0200 Subject: [PATCH 3/4] fix(recording): bound sparse capture fragments and absorb encoder bursts --- crates/enc-ffmpeg/src/mux/segmented_stream.rs | 61 ++++++++-- crates/enc-ffmpeg/src/video/h264.rs | 77 ++++++++++--- crates/enc-ffmpeg/src/video/h264_packet.rs | 105 ++++++++++++++++-- .../enc-ffmpeg/src/video/keyframe_interval.rs | 73 ++++++++++++ crates/enc-ffmpeg/src/video/mod.rs | 1 + .../output_pipeline/macos_fragmented_m4s.rs | 44 ++++---- .../src/output_pipeline/oop_fragmented_m4s.rs | 24 ++-- crates/verify/expectations.toml | 20 ---- 8 files changed, 319 insertions(+), 86 deletions(-) create mode 100644 crates/enc-ffmpeg/src/video/keyframe_interval.rs diff --git a/crates/enc-ffmpeg/src/mux/segmented_stream.rs b/crates/enc-ffmpeg/src/mux/segmented_stream.rs index d252a921c6c..9b66fcab774 100644 --- a/crates/enc-ffmpeg/src/mux/segmented_stream.rs +++ b/crates/enc-ffmpeg/src/mux/segmented_stream.rs @@ -273,10 +273,11 @@ impl SegmentedVideoEncoder { #[cfg(not(target_os = "macos"))] let hw_attempt: Option = None; - let encoder = match hw_attempt { + let mut encoder = match hw_attempt { Some(encoder) => encoder, None => builder.build(&mut output)?, }; + encoder.set_keyframe_interval(config.segment_duration); output.write_header()?; @@ -1446,13 +1447,6 @@ mod tests { #[test] fn default_config_cuts_segments_from_encoder_keyframe_cadence() { - // Production always runs segment_duration == the encoder GOP - // (DEFAULT_KEYFRAME_INTERVAL_SECS), so segment cuts depend on the - // encoder emitting keyframes at its configured cadence — no caller - // forces I-frames. Pin that contract: if the GOP options regress - // (g/keyint_min or the default interval), segments stop cutting and - // this fails. Test helpers that force I-frames at shorter cadences - // cannot catch that. ffmpeg::init().ok(); let temp = tempfile::tempdir().unwrap(); @@ -1503,4 +1497,55 @@ mod tests { ); assert!(crate::remux::probe_video_can_decode(&remuxed_path).unwrap_or(false)); } + + #[test] + fn sparse_capture_cuts_bounded_segments_without_losing_frames() { + ffmpeg::init().unwrap(); + for pixel_format in [cap_media_info::Pixel::NV12, cap_media_info::Pixel::BGRA] { + let temp = tempfile::tempdir().unwrap(); + let mut info = test_video_info(); + info.pixel_format = pixel_format; + let mut encoder = SegmentedVideoEncoder::init( + temp.path().to_path_buf(), + info, + SegmentedVideoEncoderConfig::default(), + ) + .unwrap(); + for index in 0..37 { + let mut frame = ffmpeg::frame::Video::new(pixel_format, info.width, info.height); + for plane in 0..frame.planes() { + frame.data_mut(plane).fill(128); + } + encoder + .queue_frame(frame, Duration::from_micros(index * 1_000_000 / 3)) + .unwrap(); + } + encoder.finish().unwrap(); + let mut segments: Vec<_> = std::fs::read_dir(temp.path()) + .unwrap() + .map(|entry| entry.unwrap().path()) + .filter(|path| path.extension().is_some_and(|ext| ext == "m4s")) + .collect(); + segments.sort(); + assert!( + segments.len() >= 6, + "sparse capture must close fragments before EOF" + ); + for path in &segments { + let metadata = read_fragment_metadata(path).unwrap(); + assert!(metadata.duration <= Duration::from_millis(2334)); + } + let output = temp.path().join("sparse.mp4"); + crate::remux::concatenate_m4s_segments_with_init( + &temp.path().join(INIT_SEGMENT_NAME), + &segments, + &output, + ) + .unwrap(); + let mut input = ffmpeg::format::input(&output).unwrap(); + let packets: Vec<_> = input.packets().map(|(_, packet)| packet).collect(); + assert_eq!(packets.len(), 37); + assert!(crate::remux::probe_video_can_decode(&output).unwrap()); + } + } } diff --git a/crates/enc-ffmpeg/src/video/h264.rs b/crates/enc-ffmpeg/src/video/h264.rs index 2cdc2f6105f..ad7461c5bce 100644 --- a/crates/enc-ffmpeg/src/video/h264.rs +++ b/crates/enc-ffmpeg/src/video/h264.rs @@ -289,6 +289,7 @@ impl H264EncoderBuilder { Ok(H264Encoder { base: EncoderBase::new(stream_index), + keyframes: None, encoder, converter, output_format, @@ -423,6 +424,7 @@ impl H264EncoderBuilder { Ok(H264Encoder { base: EncoderBase::new(stream_index), + keyframes: None, encoder, converter: None, output_format: ffmpeg::format::Pixel::VIDEOTOOLBOX, @@ -811,6 +813,7 @@ fn open_video_encoder_inner( } pub struct H264Encoder { + keyframes: Option, base: EncoderBase, encoder: encoder::Video, converter: Option, @@ -844,6 +847,10 @@ pub enum QueueFrameError { } impl H264Encoder { + pub fn set_keyframe_interval(&mut self, interval: Duration) { + self.keyframes = Some(super::keyframe_interval::KeyframeInterval::new(interval)); + } + const TIME_BASE: i32 = 90000; pub fn builder(input_config: VideoInfo) -> H264EncoderBuilder { @@ -910,14 +917,28 @@ impl H264Encoder { .run(&frame, converted) .map_err(QueueFrameError::Converter)?; converted.set_pts(pts); - converted as &frame::Video + if self.keyframes.is_some() { + converted.set_kind(frame.kind()); + } + converted as &mut frame::Video } else { - &frame + &mut frame }; - self.base - .send_frame(frame_to_send, output, &mut self.encoder) - .map_err(QueueFrameError::Encode)?; + let original_kind = frame_to_send.kind(); + let keyframe_checkpoint = self.keyframes; + if let Some(keyframes) = &mut self.keyframes { + keyframes.apply(frame_to_send, timestamp); + } + + let result = self + .base + .send_frame(frame_to_send, output, &mut self.encoder); + frame_to_send.set_kind(original_kind); + if result.is_err() { + self.keyframes = keyframe_checkpoint; + } + result.map_err(QueueFrameError::Encode)?; Ok(()) } @@ -940,14 +961,28 @@ impl H264Encoder { .run(frame, converted) .map_err(QueueFrameError::Converter)?; converted.set_pts(pts); - converted as &frame::Video + if self.keyframes.is_some() { + converted.set_kind(frame.kind()); + } + converted as &mut frame::Video } else { - frame as &frame::Video + frame }; - self.base - .send_frame(frame_to_send, output, &mut self.encoder) - .map_err(QueueFrameError::Encode)?; + let original_kind = frame_to_send.kind(); + let keyframe_checkpoint = self.keyframes; + if let Some(keyframes) = &mut self.keyframes { + keyframes.apply(frame_to_send, timestamp); + } + + let result = self + .base + .send_frame(frame_to_send, output, &mut self.encoder); + frame_to_send.set_kind(original_kind); + if result.is_err() { + self.keyframes = keyframe_checkpoint; + } + result.map_err(QueueFrameError::Encode)?; Ok(()) } @@ -971,9 +1006,16 @@ impl H264Encoder { self.base .update_pts(&mut frame, timestamp, &mut self.encoder); - self.base - .send_frame(&frame, output, &mut self.encoder) - .map_err(QueueFrameError::Encode)?; + let keyframe_checkpoint = self.keyframes; + if let Some(keyframes) = &mut self.keyframes { + keyframes.apply(&mut frame, timestamp); + } + + let result = self.base.send_frame(&frame, output, &mut self.encoder); + if result.is_err() { + self.keyframes = keyframe_checkpoint; + } + result.map_err(QueueFrameError::Encode)?; Ok(()) } @@ -1241,6 +1283,9 @@ fn get_codec_and_options( } } "h264_nvenc" => { + if !is_export { + options.set("forced-idr", "1"); + } if is_export { options.set("preset", "p5"); options.set("tune", "hq"); @@ -1258,6 +1303,9 @@ fn get_codec_and_options( options.set("g", &keyframe_interval_str); } "h264_qsv" => { + if !is_export { + options.set("forced_idr", "1"); + } if is_export { options.set("preset", "medium"); options.set("look_ahead", "1"); @@ -1290,6 +1338,9 @@ fn get_codec_and_options( options.set("g", &keyframe_interval_str); } "libx264" => { + if !is_export { + options.set("forced-idr", "1"); + } if let Some(ref crf_val) = crf_str { options.set("preset", "slow"); options.set("crf", crf_val); diff --git a/crates/enc-ffmpeg/src/video/h264_packet.rs b/crates/enc-ffmpeg/src/video/h264_packet.rs index fb7596f7c21..8d71d243475 100644 --- a/crates/enc-ffmpeg/src/video/h264_packet.rs +++ b/crates/enc-ffmpeg/src/video/h264_packet.rs @@ -10,6 +10,7 @@ use ffmpeg::{ use crate::video::h264::OpenedVideoEncoder; pub struct H264PacketEncoder { + keyframes: Option, encoder: encoder::Video, converter: Option, converted_frame_pool: Option, @@ -53,6 +54,7 @@ impl H264PacketEncoder { frame_rate: Rational, ) -> Self { Self { + keyframes: None, encoder: opened.encoder, converter: opened.converter, converted_frame_pool: opened.converted_frame_pool, @@ -76,6 +78,10 @@ impl H264PacketEncoder { &self.codec_name } + pub fn set_keyframe_interval(&mut self, interval: Duration) { + self.keyframes = Some(super::keyframe_interval::KeyframeInterval::new(interval)); + } + pub fn time_base(&self) -> Rational { self.encoder_time_base } @@ -144,14 +150,26 @@ impl H264PacketEncoder { .run(&frame, converted) .map_err(EncodePacketError::Converter)?; converted.set_pts(pts); - converted as &frame::Video + if self.keyframes.is_some() { + converted.set_kind(frame.kind()); + } + converted as &mut frame::Video } else { - &frame + &mut frame }; - self.encoder - .send_frame(frame_to_send) - .map_err(EncodePacketError::Encode)?; + let original_kind = frame_to_send.kind(); + let keyframe_checkpoint = self.keyframes; + if let Some(keyframes) = &mut self.keyframes { + keyframes.apply(frame_to_send, timestamp); + } + + let result = self.encoder.send_frame(frame_to_send); + frame_to_send.set_kind(original_kind); + if result.is_err() { + self.keyframes = keyframe_checkpoint; + } + result.map_err(EncodePacketError::Encode)?; self.drain_packets(on_packet) } @@ -177,14 +195,26 @@ impl H264PacketEncoder { .run(frame, converted) .map_err(EncodePacketError::Converter)?; converted.set_pts(pts); - converted as &frame::Video + if self.keyframes.is_some() { + converted.set_kind(frame.kind()); + } + converted as &mut frame::Video } else { - frame as &frame::Video + frame }; - self.encoder - .send_frame(frame_to_send) - .map_err(EncodePacketError::Encode)?; + let original_kind = frame_to_send.kind(); + let keyframe_checkpoint = self.keyframes; + if let Some(keyframes) = &mut self.keyframes { + keyframes.apply(frame_to_send, timestamp); + } + + let result = self.encoder.send_frame(frame_to_send); + frame_to_send.set_kind(original_kind); + if result.is_err() { + self.keyframes = keyframe_checkpoint; + } + result.map_err(EncodePacketError::Encode)?; self.drain_packets(on_packet) } @@ -384,4 +414,59 @@ mod tests { "expected at least one packet after encoding 10 frames + flush" ); } + + #[test] + fn standalone_sparse_capture_preserves_frames_and_bounds_keyframe_spacing() { + ffmpeg::init().unwrap(); + for reusable in [false, true] { + let mut encoder = H264EncoderBuilder::new(test_video_info()) + .with_preset(H264Preset::Ultrafast) + .build_standalone() + .unwrap(); + encoder.set_keyframe_interval(Duration::from_secs(2)); + let time_base = encoder.time_base(); + let mut packets = Vec::new(); + let mut frame = create_test_frame(320, 240); + let mut converted = None; + for index in 0..37 { + let timestamp = Duration::from_micros(index * 1_000_000 / 3); + let on_packet = |packet| { + packets.push(packet); + Ok(()) + }; + if reusable { + encoder + .encode_frame_reusable(&mut frame, &mut converted, timestamp, on_packet) + .unwrap(); + assert_eq!(frame.kind(), ffmpeg::picture::Type::None); + } else { + encoder + .encode_frame(create_test_frame(320, 240), timestamp, on_packet) + .unwrap(); + } + } + encoder + .flush(|packet| { + packets.push(packet); + Ok(()) + }) + .unwrap(); + assert_eq!(packets.len(), 37); + let keyframes: Vec<_> = packets.iter().filter(|packet| packet.is_keyframe).collect(); + assert!( + keyframes.len() >= 7, + "{} keyframes: {:?}", + encoder.codec_name(), + keyframes + .iter() + .map(|packet| packet.pts) + .collect::>() + ); + for pair in keyframes.windows(2) { + let seconds = (pair[1].pts - pair[0].pts) as f64 * time_base.numerator() as f64 + / time_base.denominator() as f64; + assert!(seconds <= 2.334); + } + } + } } diff --git a/crates/enc-ffmpeg/src/video/keyframe_interval.rs b/crates/enc-ffmpeg/src/video/keyframe_interval.rs new file mode 100644 index 00000000000..ea8b2e5810f --- /dev/null +++ b/crates/enc-ffmpeg/src/video/keyframe_interval.rs @@ -0,0 +1,73 @@ +use std::time::Duration; + +#[derive(Clone, Copy)] +pub(super) struct KeyframeInterval { + interval: Duration, + last_keyframe: Option, +} + +impl KeyframeInterval { + pub(super) fn new(interval: Duration) -> Self { + Self { + interval, + last_keyframe: None, + } + } + + pub(super) fn apply(&mut self, frame: &mut ffmpeg::frame::Video, timestamp: Duration) { + if timestamp == Duration::MAX { + return; + } + let force = frame.kind() == ffmpeg::picture::Type::I + || self.last_keyframe.is_none_or(|previous| { + timestamp + .checked_sub(previous) + .is_none_or(|elapsed| elapsed >= self.interval) + }); + if force { + frame.set_kind(ffmpeg::picture::Type::I); + self.last_keyframe = Some(timestamp); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sparse_frames_cut_by_capture_time() { + let mut cadence = KeyframeInterval::new(Duration::from_secs(2)); + let mut frame = ffmpeg::frame::Video::empty(); + for (millis, expected) in [ + (7_000, ffmpeg::picture::Type::I), + (7_333, ffmpeg::picture::Type::None), + (8_999, ffmpeg::picture::Type::None), + (9_000, ffmpeg::picture::Type::I), + (9_333, ffmpeg::picture::Type::None), + (14_000, ffmpeg::picture::Type::I), + (14_333, ffmpeg::picture::Type::None), + ] { + frame.set_kind(ffmpeg::picture::Type::None); + cadence.apply(&mut frame, Duration::from_millis(millis)); + assert_eq!(frame.kind(), expected); + } + } + + #[test] + fn unavailable_timestamps_preserve_caller_frame_types_and_do_not_poison_cadence() { + let mut cadence = KeyframeInterval::new(Duration::from_secs(2)); + let mut frame = ffmpeg::frame::Video::empty(); + frame.set_kind(ffmpeg::picture::Type::P); + cadence.apply(&mut frame, Duration::MAX); + assert_eq!(frame.kind(), ffmpeg::picture::Type::P); + cadence.apply(&mut frame, Duration::ZERO); + assert_eq!(frame.kind(), ffmpeg::picture::Type::I); + frame.set_kind(ffmpeg::picture::Type::None); + cadence.apply(&mut frame, Duration::from_secs(2)); + assert_eq!(frame.kind(), ffmpeg::picture::Type::I); + frame.set_kind(ffmpeg::picture::Type::None); + cadence.apply(&mut frame, Duration::from_secs(1)); + assert_eq!(frame.kind(), ffmpeg::picture::Type::I); + } +} diff --git a/crates/enc-ffmpeg/src/video/mod.rs b/crates/enc-ffmpeg/src/video/mod.rs index 104261fc075..76253ea59cb 100644 --- a/crates/enc-ffmpeg/src/video/mod.rs +++ b/crates/enc-ffmpeg/src/video/mod.rs @@ -2,6 +2,7 @@ pub mod colour; pub mod h264; pub mod h264_packet; pub mod hevc; +mod keyframe_interval; pub mod prores; #[cfg(target_os = "macos")] pub mod videotoolbox_hw; diff --git a/crates/recording/src/output_pipeline/macos_fragmented_m4s.rs b/crates/recording/src/output_pipeline/macos_fragmented_m4s.rs index d2c6f7cec34..815d7950767 100644 --- a/crates/recording/src/output_pipeline/macos_fragmented_m4s.rs +++ b/crates/recording/src/output_pipeline/macos_fragmented_m4s.rs @@ -15,11 +15,7 @@ use cap_enc_ffmpeg::segmented_stream::{ use cap_media_info::{AudioInfo, VideoInfo}; use std::{ path::PathBuf, - sync::{ - Arc, Mutex, - atomic::AtomicBool, - mpsc::{SyncSender, sync_channel}, - }, + sync::{Arc, Mutex, atomic::AtomicBool, mpsc::sync_channel}, thread::JoinHandle, time::Duration, }; @@ -148,7 +144,7 @@ fn finish_segmented_encoder( } struct EncoderState { - video_tx: SyncSender, Duration)>>, + video_tx: flume::Sender, Duration)>>, encoder: Arc>, encoder_handle: Option>>, } @@ -247,11 +243,11 @@ impl Muxer for MacOSFragmentedM4SMuxer { std::thread::sleep(Duration::from_millis(50)); match state.video_tx.try_send(None) { Ok(()) => return, - Err(std::sync::mpsc::TrySendError::Disconnected(_)) => { + Err(flume::TrySendError::Disconnected(_)) => { trace!("M4S encoder channel closed during stop retry"); return; } - Err(std::sync::mpsc::TrySendError::Full(_)) => {} + Err(flume::TrySendError::Full(_)) => {} } } warn!("M4S encoder channel still full after retries, finish() will deliver sentinel"); @@ -288,7 +284,7 @@ impl MacOSFragmentedM4SMuxer { ); let (video_tx, video_rx) = - sync_channel::, Duration)>>(buffer_size); + flume::bounded::, Duration)>>(buffer_size); let (ready_tx, ready_rx) = sync_channel::>(1); let encoder_config = SegmentedVideoEncoderConfig { @@ -514,18 +510,18 @@ impl VideoMuxer for MacOSFragmentedM4SMuxer { } if let Some(state) = &self.state { - match state - .video_tx - .try_send(Some((frame.sample_buf, adjusted_timestamp))) - { + match state.video_tx.send_timeout( + Some((frame.sample_buf, adjusted_timestamp)), + Duration::from_millis(super::core::STALL_BUDGET_MS), + ) { Ok(()) => { self.frame_drops.record_frame(); } Err(e) => match e { - std::sync::mpsc::TrySendError::Full(_) => { + flume::SendTimeoutError::Timeout(_) => { self.frame_drops.record_drop(); } - std::sync::mpsc::TrySendError::Disconnected(_) => { + flume::SendTimeoutError::Disconnected(_) => { return Err(anyhow!("M4S encoder channel disconnected")); } }, @@ -636,11 +632,11 @@ impl Muxer for MacOSFragmentedM4SCameraMuxer { std::thread::sleep(Duration::from_millis(50)); match state.video_tx.try_send(None) { Ok(()) => return, - Err(std::sync::mpsc::TrySendError::Disconnected(_)) => { + Err(flume::TrySendError::Disconnected(_)) => { trace!("M4S camera encoder channel closed during stop retry"); return; } - Err(std::sync::mpsc::TrySendError::Full(_)) => {} + Err(flume::TrySendError::Full(_)) => {} } } warn!( @@ -679,7 +675,7 @@ impl MacOSFragmentedM4SCameraMuxer { ); let (video_tx, video_rx) = - sync_channel::, Duration)>>(buffer_size); + flume::bounded::, Duration)>>(buffer_size); let (ready_tx, ready_rx) = sync_channel::>(1); let encoder_config = SegmentedVideoEncoderConfig { @@ -860,18 +856,18 @@ impl VideoMuxer for MacOSFragmentedM4SCameraMuxer { } if let Some(state) = &self.state { - match state - .video_tx - .try_send(Some((frame.sample_buf, adjusted_timestamp))) - { + match state.video_tx.send_timeout( + Some((frame.sample_buf, adjusted_timestamp)), + Duration::from_millis(super::core::STALL_BUDGET_MS), + ) { Ok(()) => { self.frame_drops.record_frame(); } Err(e) => match e { - std::sync::mpsc::TrySendError::Full(_) => { + flume::SendTimeoutError::Timeout(_) => { self.frame_drops.record_drop(); } - std::sync::mpsc::TrySendError::Disconnected(_) => { + flume::SendTimeoutError::Disconnected(_) => { return Err(anyhow!("M4S camera encoder channel disconnected")); } }, diff --git a/crates/recording/src/output_pipeline/oop_fragmented_m4s.rs b/crates/recording/src/output_pipeline/oop_fragmented_m4s.rs index 9afbb643412..63bf94f8b73 100644 --- a/crates/recording/src/output_pipeline/oop_fragmented_m4s.rs +++ b/crates/recording/src/output_pipeline/oop_fragmented_m4s.rs @@ -107,7 +107,7 @@ impl FrameDropTracker { type VideoFrameItem = Option<(cidre::arc::R, Duration)>; struct EncoderState { - video_tx: std::sync::mpsc::SyncSender, + video_tx: flume::Sender, encoder_handle: Option>>, } @@ -205,11 +205,11 @@ impl Muxer for OutOfProcessFragmentedM4SMuxer { std::thread::sleep(Duration::from_millis(50)); match state.video_tx.try_send(None) { Ok(()) => return, - Err(std::sync::mpsc::TrySendError::Disconnected(_)) => { + Err(flume::TrySendError::Disconnected(_)) => { trace!("OOP M4S encoder channel closed during stop retry"); return; } - Err(std::sync::mpsc::TrySendError::Full(_)) => {} + Err(flume::TrySendError::Full(_)) => {} } } warn!( @@ -261,7 +261,7 @@ impl OutOfProcessFragmentedM4SMuxer { let bin_path = resolve_muxer_binary().with_context(|| "cap-muxer binary not found for OOP muxer")?; - let (video_tx, video_rx) = std::sync::mpsc::sync_channel::(buffer_size); + let (video_tx, video_rx) = flume::bounded::(buffer_size); let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel::>(1); let base_path = self.base_path.clone(); @@ -376,6 +376,7 @@ impl OutOfProcessFragmentedM4SMuxer { FramePool::new(pixel_format, video_config.width, video_config.height); let mut encoder = encoder; + encoder.set_keyframe_interval(segment_duration); let mut subprocess = subprocess; let mut slow_convert_count = 0u32; @@ -488,7 +489,8 @@ impl OutOfProcessFragmentedM4SMuxer { dispatch_packet(&mut subprocess, pkt) }); match rebuild_builder.clone().build_standalone() { - Ok(new_enc) => { + Ok(mut new_enc) => { + new_enc.set_keyframe_interval(segment_duration); let old_name = encoder.codec_name().to_string(); let new_name = new_enc.codec_name().to_string(); info!( @@ -659,18 +661,18 @@ impl VideoMuxer for OutOfProcessFragmentedM4SMuxer { } if let Some(state) = &self.state { - match state - .video_tx - .try_send(Some((frame.sample_buf, adjusted_timestamp))) - { + match state.video_tx.send_timeout( + Some((frame.sample_buf, adjusted_timestamp)), + Duration::from_millis(super::core::STALL_BUDGET_MS), + ) { Ok(()) => { self.frame_drops.record_frame(); } Err(e) => match e { - std::sync::mpsc::TrySendError::Full(_) => { + flume::SendTimeoutError::Timeout(_) => { self.frame_drops.record_drop(); } - std::sync::mpsc::TrySendError::Disconnected(_) => { + flume::SendTimeoutError::Disconnected(_) => { return Err(anyhow!("OOP M4S encoder channel disconnected")); } }, diff --git a/crates/verify/expectations.toml b/crates/verify/expectations.toml index ea47214b2ef..94233df4a7f 100644 --- a/crates/verify/expectations.toml +++ b/crates/verify/expectations.toml @@ -361,23 +361,3 @@ hosts = ["github-hosted", "local"] bound = { count = 1, screen-pictures = 1 } reason = "NEW (ScreenCaptureKit idle resume, see above): the first 15 fps picture after the 3 s hold is missing when the resume takes longer than its 67 ms; runners 5 of 6 runs (flaky because the 70 to 82 ms resume straddles the 67 ms picture). Bound: the one hold's picture" link = "https://github.com/CapSoftware/Cap/actions/runs/38745219281" -# Crash safety (crates/verify/tests/crash.rs). Measured on Blacksmith runners -# (run 38850661948: macos-15 libx264, ubuntu-2404, windows-2025) with the suite -# merged into main 3ec0c9497; each fix has its own proof branch -# ci/fixproof-crash- (runs 38850662667, 38850661576, 38850663436, -# 38850662102), which removes exactly its entries. Fixes: #2640 (bug A), -# #2641 (bug B1), #2643 (bug C), #2644 (bug E). - -[[xfail]] -case = "crash/recorder-kill/inproc/static-screen" -class = "loss-time" -bound = { count = 1, secs = [2.5, 20.5] } -reason = "NEW (bug B1): fragments are cut only at keyframes and every recording encoder's keyframe interval is a frame count (g = 2 s x nominal fps; libx264 keyint 60 here, VideoToolbox 12), so when the screen delivers 3 frames per second a fragment spans 20 s of capture and a crash after 11 s loses all of it (3 of 3 platforms; macOS runners use libx264). Bound: at most one 20 s fragment of capture" -link = "https://github.com/CapSoftware/Cap/pull/2641" - -[[xfail]] -case = "crash/recorder-kill/inproc/static-screen" -class = "missing" -bound = { count = 1 } -reason = "NEW (bug B1, see loss-time): no fragment of the static recording is complete when the recorder dies, so recovery has nothing (no frames)" -link = "https://github.com/CapSoftware/Cap/pull/2641" From 3e5cd737cdd1fae31be5082d09f23011406e8062 Mon Sep 17 00:00:00 2001 From: Calin Rus <127376836+calinrus-dev@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:55:09 +0200 Subject: [PATCH 4/4] fix(recording): flush Studio media without changing keyframe cadence --- crates/cap-muxer/src/main.rs | 5 + crates/enc-ffmpeg/src/mux/segmented_stream.rs | 61 +---- crates/enc-ffmpeg/src/video/h264.rs | 77 +----- crates/enc-ffmpeg/src/video/h264_packet.rs | 105 +------- .../enc-ffmpeg/src/video/keyframe_interval.rs | 73 ------ crates/enc-ffmpeg/src/video/mod.rs | 1 - .../src/output_pipeline/oop_fragmented_m4s.rs | 4 +- crates/recording/tests/oop_muxer.rs | 98 +++++++ crates/verify/tests/crash.rs | 245 +++++++++++++----- 9 files changed, 316 insertions(+), 353 deletions(-) delete mode 100644 crates/enc-ffmpeg/src/video/keyframe_interval.rs diff --git a/crates/cap-muxer/src/main.rs b/crates/cap-muxer/src/main.rs index fc7092ff31b..73629a7aed6 100644 --- a/crates/cap-muxer/src/main.rs +++ b/crates/cap-muxer/src/main.rs @@ -454,6 +454,11 @@ fn open_output(state: &mut State, params: &StartParams) -> Result = None; - let mut encoder = match hw_attempt { + let encoder = match hw_attempt { Some(encoder) => encoder, None => builder.build(&mut output)?, }; - encoder.set_keyframe_interval(config.segment_duration); output.write_header()?; @@ -1447,6 +1446,13 @@ mod tests { #[test] fn default_config_cuts_segments_from_encoder_keyframe_cadence() { + // Production always runs segment_duration == the encoder GOP + // (DEFAULT_KEYFRAME_INTERVAL_SECS), so segment cuts depend on the + // encoder emitting keyframes at its configured cadence — no caller + // forces I-frames. Pin that contract: if the GOP options regress + // (g/keyint_min or the default interval), segments stop cutting and + // this fails. Test helpers that force I-frames at shorter cadences + // cannot catch that. ffmpeg::init().ok(); let temp = tempfile::tempdir().unwrap(); @@ -1497,55 +1503,4 @@ mod tests { ); assert!(crate::remux::probe_video_can_decode(&remuxed_path).unwrap_or(false)); } - - #[test] - fn sparse_capture_cuts_bounded_segments_without_losing_frames() { - ffmpeg::init().unwrap(); - for pixel_format in [cap_media_info::Pixel::NV12, cap_media_info::Pixel::BGRA] { - let temp = tempfile::tempdir().unwrap(); - let mut info = test_video_info(); - info.pixel_format = pixel_format; - let mut encoder = SegmentedVideoEncoder::init( - temp.path().to_path_buf(), - info, - SegmentedVideoEncoderConfig::default(), - ) - .unwrap(); - for index in 0..37 { - let mut frame = ffmpeg::frame::Video::new(pixel_format, info.width, info.height); - for plane in 0..frame.planes() { - frame.data_mut(plane).fill(128); - } - encoder - .queue_frame(frame, Duration::from_micros(index * 1_000_000 / 3)) - .unwrap(); - } - encoder.finish().unwrap(); - let mut segments: Vec<_> = std::fs::read_dir(temp.path()) - .unwrap() - .map(|entry| entry.unwrap().path()) - .filter(|path| path.extension().is_some_and(|ext| ext == "m4s")) - .collect(); - segments.sort(); - assert!( - segments.len() >= 6, - "sparse capture must close fragments before EOF" - ); - for path in &segments { - let metadata = read_fragment_metadata(path).unwrap(); - assert!(metadata.duration <= Duration::from_millis(2334)); - } - let output = temp.path().join("sparse.mp4"); - crate::remux::concatenate_m4s_segments_with_init( - &temp.path().join(INIT_SEGMENT_NAME), - &segments, - &output, - ) - .unwrap(); - let mut input = ffmpeg::format::input(&output).unwrap(); - let packets: Vec<_> = input.packets().map(|(_, packet)| packet).collect(); - assert_eq!(packets.len(), 37); - assert!(crate::remux::probe_video_can_decode(&output).unwrap()); - } - } } diff --git a/crates/enc-ffmpeg/src/video/h264.rs b/crates/enc-ffmpeg/src/video/h264.rs index ad7461c5bce..2cdc2f6105f 100644 --- a/crates/enc-ffmpeg/src/video/h264.rs +++ b/crates/enc-ffmpeg/src/video/h264.rs @@ -289,7 +289,6 @@ impl H264EncoderBuilder { Ok(H264Encoder { base: EncoderBase::new(stream_index), - keyframes: None, encoder, converter, output_format, @@ -424,7 +423,6 @@ impl H264EncoderBuilder { Ok(H264Encoder { base: EncoderBase::new(stream_index), - keyframes: None, encoder, converter: None, output_format: ffmpeg::format::Pixel::VIDEOTOOLBOX, @@ -813,7 +811,6 @@ fn open_video_encoder_inner( } pub struct H264Encoder { - keyframes: Option, base: EncoderBase, encoder: encoder::Video, converter: Option, @@ -847,10 +844,6 @@ pub enum QueueFrameError { } impl H264Encoder { - pub fn set_keyframe_interval(&mut self, interval: Duration) { - self.keyframes = Some(super::keyframe_interval::KeyframeInterval::new(interval)); - } - const TIME_BASE: i32 = 90000; pub fn builder(input_config: VideoInfo) -> H264EncoderBuilder { @@ -917,28 +910,14 @@ impl H264Encoder { .run(&frame, converted) .map_err(QueueFrameError::Converter)?; converted.set_pts(pts); - if self.keyframes.is_some() { - converted.set_kind(frame.kind()); - } - converted as &mut frame::Video + converted as &frame::Video } else { - &mut frame + &frame }; - let original_kind = frame_to_send.kind(); - let keyframe_checkpoint = self.keyframes; - if let Some(keyframes) = &mut self.keyframes { - keyframes.apply(frame_to_send, timestamp); - } - - let result = self - .base - .send_frame(frame_to_send, output, &mut self.encoder); - frame_to_send.set_kind(original_kind); - if result.is_err() { - self.keyframes = keyframe_checkpoint; - } - result.map_err(QueueFrameError::Encode)?; + self.base + .send_frame(frame_to_send, output, &mut self.encoder) + .map_err(QueueFrameError::Encode)?; Ok(()) } @@ -961,28 +940,14 @@ impl H264Encoder { .run(frame, converted) .map_err(QueueFrameError::Converter)?; converted.set_pts(pts); - if self.keyframes.is_some() { - converted.set_kind(frame.kind()); - } - converted as &mut frame::Video + converted as &frame::Video } else { - frame + frame as &frame::Video }; - let original_kind = frame_to_send.kind(); - let keyframe_checkpoint = self.keyframes; - if let Some(keyframes) = &mut self.keyframes { - keyframes.apply(frame_to_send, timestamp); - } - - let result = self - .base - .send_frame(frame_to_send, output, &mut self.encoder); - frame_to_send.set_kind(original_kind); - if result.is_err() { - self.keyframes = keyframe_checkpoint; - } - result.map_err(QueueFrameError::Encode)?; + self.base + .send_frame(frame_to_send, output, &mut self.encoder) + .map_err(QueueFrameError::Encode)?; Ok(()) } @@ -1006,16 +971,9 @@ impl H264Encoder { self.base .update_pts(&mut frame, timestamp, &mut self.encoder); - let keyframe_checkpoint = self.keyframes; - if let Some(keyframes) = &mut self.keyframes { - keyframes.apply(&mut frame, timestamp); - } - - let result = self.base.send_frame(&frame, output, &mut self.encoder); - if result.is_err() { - self.keyframes = keyframe_checkpoint; - } - result.map_err(QueueFrameError::Encode)?; + self.base + .send_frame(&frame, output, &mut self.encoder) + .map_err(QueueFrameError::Encode)?; Ok(()) } @@ -1283,9 +1241,6 @@ fn get_codec_and_options( } } "h264_nvenc" => { - if !is_export { - options.set("forced-idr", "1"); - } if is_export { options.set("preset", "p5"); options.set("tune", "hq"); @@ -1303,9 +1258,6 @@ fn get_codec_and_options( options.set("g", &keyframe_interval_str); } "h264_qsv" => { - if !is_export { - options.set("forced_idr", "1"); - } if is_export { options.set("preset", "medium"); options.set("look_ahead", "1"); @@ -1338,9 +1290,6 @@ fn get_codec_and_options( options.set("g", &keyframe_interval_str); } "libx264" => { - if !is_export { - options.set("forced-idr", "1"); - } if let Some(ref crf_val) = crf_str { options.set("preset", "slow"); options.set("crf", crf_val); diff --git a/crates/enc-ffmpeg/src/video/h264_packet.rs b/crates/enc-ffmpeg/src/video/h264_packet.rs index 8d71d243475..fb7596f7c21 100644 --- a/crates/enc-ffmpeg/src/video/h264_packet.rs +++ b/crates/enc-ffmpeg/src/video/h264_packet.rs @@ -10,7 +10,6 @@ use ffmpeg::{ use crate::video::h264::OpenedVideoEncoder; pub struct H264PacketEncoder { - keyframes: Option, encoder: encoder::Video, converter: Option, converted_frame_pool: Option, @@ -54,7 +53,6 @@ impl H264PacketEncoder { frame_rate: Rational, ) -> Self { Self { - keyframes: None, encoder: opened.encoder, converter: opened.converter, converted_frame_pool: opened.converted_frame_pool, @@ -78,10 +76,6 @@ impl H264PacketEncoder { &self.codec_name } - pub fn set_keyframe_interval(&mut self, interval: Duration) { - self.keyframes = Some(super::keyframe_interval::KeyframeInterval::new(interval)); - } - pub fn time_base(&self) -> Rational { self.encoder_time_base } @@ -150,26 +144,14 @@ impl H264PacketEncoder { .run(&frame, converted) .map_err(EncodePacketError::Converter)?; converted.set_pts(pts); - if self.keyframes.is_some() { - converted.set_kind(frame.kind()); - } - converted as &mut frame::Video + converted as &frame::Video } else { - &mut frame + &frame }; - let original_kind = frame_to_send.kind(); - let keyframe_checkpoint = self.keyframes; - if let Some(keyframes) = &mut self.keyframes { - keyframes.apply(frame_to_send, timestamp); - } - - let result = self.encoder.send_frame(frame_to_send); - frame_to_send.set_kind(original_kind); - if result.is_err() { - self.keyframes = keyframe_checkpoint; - } - result.map_err(EncodePacketError::Encode)?; + self.encoder + .send_frame(frame_to_send) + .map_err(EncodePacketError::Encode)?; self.drain_packets(on_packet) } @@ -195,26 +177,14 @@ impl H264PacketEncoder { .run(frame, converted) .map_err(EncodePacketError::Converter)?; converted.set_pts(pts); - if self.keyframes.is_some() { - converted.set_kind(frame.kind()); - } - converted as &mut frame::Video + converted as &frame::Video } else { - frame + frame as &frame::Video }; - let original_kind = frame_to_send.kind(); - let keyframe_checkpoint = self.keyframes; - if let Some(keyframes) = &mut self.keyframes { - keyframes.apply(frame_to_send, timestamp); - } - - let result = self.encoder.send_frame(frame_to_send); - frame_to_send.set_kind(original_kind); - if result.is_err() { - self.keyframes = keyframe_checkpoint; - } - result.map_err(EncodePacketError::Encode)?; + self.encoder + .send_frame(frame_to_send) + .map_err(EncodePacketError::Encode)?; self.drain_packets(on_packet) } @@ -414,59 +384,4 @@ mod tests { "expected at least one packet after encoding 10 frames + flush" ); } - - #[test] - fn standalone_sparse_capture_preserves_frames_and_bounds_keyframe_spacing() { - ffmpeg::init().unwrap(); - for reusable in [false, true] { - let mut encoder = H264EncoderBuilder::new(test_video_info()) - .with_preset(H264Preset::Ultrafast) - .build_standalone() - .unwrap(); - encoder.set_keyframe_interval(Duration::from_secs(2)); - let time_base = encoder.time_base(); - let mut packets = Vec::new(); - let mut frame = create_test_frame(320, 240); - let mut converted = None; - for index in 0..37 { - let timestamp = Duration::from_micros(index * 1_000_000 / 3); - let on_packet = |packet| { - packets.push(packet); - Ok(()) - }; - if reusable { - encoder - .encode_frame_reusable(&mut frame, &mut converted, timestamp, on_packet) - .unwrap(); - assert_eq!(frame.kind(), ffmpeg::picture::Type::None); - } else { - encoder - .encode_frame(create_test_frame(320, 240), timestamp, on_packet) - .unwrap(); - } - } - encoder - .flush(|packet| { - packets.push(packet); - Ok(()) - }) - .unwrap(); - assert_eq!(packets.len(), 37); - let keyframes: Vec<_> = packets.iter().filter(|packet| packet.is_keyframe).collect(); - assert!( - keyframes.len() >= 7, - "{} keyframes: {:?}", - encoder.codec_name(), - keyframes - .iter() - .map(|packet| packet.pts) - .collect::>() - ); - for pair in keyframes.windows(2) { - let seconds = (pair[1].pts - pair[0].pts) as f64 * time_base.numerator() as f64 - / time_base.denominator() as f64; - assert!(seconds <= 2.334); - } - } - } } diff --git a/crates/enc-ffmpeg/src/video/keyframe_interval.rs b/crates/enc-ffmpeg/src/video/keyframe_interval.rs deleted file mode 100644 index ea8b2e5810f..00000000000 --- a/crates/enc-ffmpeg/src/video/keyframe_interval.rs +++ /dev/null @@ -1,73 +0,0 @@ -use std::time::Duration; - -#[derive(Clone, Copy)] -pub(super) struct KeyframeInterval { - interval: Duration, - last_keyframe: Option, -} - -impl KeyframeInterval { - pub(super) fn new(interval: Duration) -> Self { - Self { - interval, - last_keyframe: None, - } - } - - pub(super) fn apply(&mut self, frame: &mut ffmpeg::frame::Video, timestamp: Duration) { - if timestamp == Duration::MAX { - return; - } - let force = frame.kind() == ffmpeg::picture::Type::I - || self.last_keyframe.is_none_or(|previous| { - timestamp - .checked_sub(previous) - .is_none_or(|elapsed| elapsed >= self.interval) - }); - if force { - frame.set_kind(ffmpeg::picture::Type::I); - self.last_keyframe = Some(timestamp); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn sparse_frames_cut_by_capture_time() { - let mut cadence = KeyframeInterval::new(Duration::from_secs(2)); - let mut frame = ffmpeg::frame::Video::empty(); - for (millis, expected) in [ - (7_000, ffmpeg::picture::Type::I), - (7_333, ffmpeg::picture::Type::None), - (8_999, ffmpeg::picture::Type::None), - (9_000, ffmpeg::picture::Type::I), - (9_333, ffmpeg::picture::Type::None), - (14_000, ffmpeg::picture::Type::I), - (14_333, ffmpeg::picture::Type::None), - ] { - frame.set_kind(ffmpeg::picture::Type::None); - cadence.apply(&mut frame, Duration::from_millis(millis)); - assert_eq!(frame.kind(), expected); - } - } - - #[test] - fn unavailable_timestamps_preserve_caller_frame_types_and_do_not_poison_cadence() { - let mut cadence = KeyframeInterval::new(Duration::from_secs(2)); - let mut frame = ffmpeg::frame::Video::empty(); - frame.set_kind(ffmpeg::picture::Type::P); - cadence.apply(&mut frame, Duration::MAX); - assert_eq!(frame.kind(), ffmpeg::picture::Type::P); - cadence.apply(&mut frame, Duration::ZERO); - assert_eq!(frame.kind(), ffmpeg::picture::Type::I); - frame.set_kind(ffmpeg::picture::Type::None); - cadence.apply(&mut frame, Duration::from_secs(2)); - assert_eq!(frame.kind(), ffmpeg::picture::Type::I); - frame.set_kind(ffmpeg::picture::Type::None); - cadence.apply(&mut frame, Duration::from_secs(1)); - assert_eq!(frame.kind(), ffmpeg::picture::Type::I); - } -} diff --git a/crates/enc-ffmpeg/src/video/mod.rs b/crates/enc-ffmpeg/src/video/mod.rs index 76253ea59cb..104261fc075 100644 --- a/crates/enc-ffmpeg/src/video/mod.rs +++ b/crates/enc-ffmpeg/src/video/mod.rs @@ -2,7 +2,6 @@ pub mod colour; pub mod h264; pub mod h264_packet; pub mod hevc; -mod keyframe_interval; pub mod prores; #[cfg(target_os = "macos")] pub mod videotoolbox_hw; diff --git a/crates/recording/src/output_pipeline/oop_fragmented_m4s.rs b/crates/recording/src/output_pipeline/oop_fragmented_m4s.rs index 63bf94f8b73..256989abc8e 100644 --- a/crates/recording/src/output_pipeline/oop_fragmented_m4s.rs +++ b/crates/recording/src/output_pipeline/oop_fragmented_m4s.rs @@ -376,7 +376,6 @@ impl OutOfProcessFragmentedM4SMuxer { FramePool::new(pixel_format, video_config.width, video_config.height); let mut encoder = encoder; - encoder.set_keyframe_interval(segment_duration); let mut subprocess = subprocess; let mut slow_convert_count = 0u32; @@ -489,8 +488,7 @@ impl OutOfProcessFragmentedM4SMuxer { dispatch_packet(&mut subprocess, pkt) }); match rebuild_builder.clone().build_standalone() { - Ok(mut new_enc) => { - new_enc.set_keyframe_interval(segment_duration); + Ok(new_enc) => { let old_name = encoder.codec_name().to_string(); let new_name = new_enc.codec_name().to_string(); info!( diff --git a/crates/recording/tests/oop_muxer.rs b/crates/recording/tests/oop_muxer.rs index bd7b889bfbc..dc26743cd14 100644 --- a/crates/recording/tests/oop_muxer.rs +++ b/crates/recording/tests/oop_muxer.rs @@ -52,6 +52,104 @@ fn minimal_video_config(output_dir: &std::path::Path, extradata: Vec) -> Mux } } +#[test] +fn sparse_subprocess_flushes_recoverable_media_without_extra_keyframes() { + use cap_enc_ffmpeg::{ + fragmented_mp4::read_fragment_prefix, + h264::{H264EncoderBuilder, H264Preset}, + }; + use cap_media_info::{Pixel, VideoInfo}; + use std::time::{Duration, Instant}; + + ffmpeg::init().unwrap(); + let directory = TempDir::new().unwrap(); + let output = directory.path().join("video"); + let info = VideoInfo { + pixel_format: Pixel::NV12, + width: TEST_WIDTH, + height: TEST_HEIGHT, + time_base: ffmpeg::Rational(1, 90_000), + frame_rate: ffmpeg::Rational(TEST_FPS as i32, 1), + }; + let mut encoder = H264EncoderBuilder::new(info) + .with_preset(H264Preset::Ultrafast) + .with_encoder_priority_override(&["libx264"]) + .build_standalone() + .unwrap(); + let config = minimal_video_config(&output, encoder.extradata()); + let mut packets = Vec::new(); + for index in 0..37 { + let mut frame = ffmpeg::frame::Video::new(Pixel::NV12, TEST_WIDTH, TEST_HEIGHT); + for plane in 0..frame.planes() { + frame.data_mut(plane).fill(128); + } + encoder + .encode_frame( + frame, + Duration::from_micros(index * 1_000_000 / 3), + |packet| { + packets.push(packet); + Ok(()) + }, + ) + .unwrap(); + } + encoder + .flush(|packet| { + packets.push(packet); + Ok(()) + }) + .unwrap(); + assert_eq!(packets.len(), 37); + assert_eq!( + packets.iter().filter(|packet| packet.is_keyframe).count(), + 1 + ); + let mut subprocess = MuxerSubprocess::spawn(setup_muxer_binary(), config, None).unwrap(); + for packet in &packets { + subprocess + .write_video_packet( + packet.pts, + packet.dts, + packet.duration.max(0) as u64, + packet.is_keyframe, + &packet.data, + ) + .unwrap(); + } + let init = output.join("init.mp4"); + let pending = output.join("segment_001.m4s.tmp"); + let started = Instant::now(); + loop { + if read_fragment_prefix(&init, &pending) + .is_ok_and(|prefix| prefix.duration >= Duration::from_secs(10)) + { + break; + } + assert!( + started.elapsed() < Duration::from_secs(15), + "complete fragments were not flushed before EOF" + ); + std::thread::sleep(Duration::from_millis(10)); + } + let report = subprocess.finish().unwrap(); + assert_eq!(report.packets_written, 37); + let mut bytes = std::fs::read(init).unwrap(); + bytes.extend(std::fs::read(output.join("segment_001.m4s")).unwrap()); + let assembled = directory.path().join("assembled.mp4"); + std::fs::write(&assembled, bytes).unwrap(); + let mut input = ffmpeg::format::input(&assembled).unwrap(); + let output_packets: Vec<_> = input.packets().map(|(_, packet)| packet).collect(); + assert_eq!(output_packets.len(), 37); + assert_eq!( + output_packets + .iter() + .filter(|packet| packet.is_key()) + .count(), + 1 + ); +} + #[test] fn subprocess_spawns_and_finishes_cleanly_without_packets() { let bin = setup_muxer_binary(); diff --git a/crates/verify/tests/crash.rs b/crates/verify/tests/crash.rs index 33c3b8c2061..b916c42881c 100644 --- a/crates/verify/tests/crash.rs +++ b/crates/verify/tests/crash.rs @@ -16,9 +16,9 @@ //! against what is physically on disk after the failure: every fragment whose //! box structure is complete must come back frame for frame at its capture //! time. Frames lost to a failure must be the newest ones and fit in the -//! fragment being written: one GOP (`GOP_SECS` x fps frames, the encoder's -//! keyframe interval, so the longest a segment can be when frames arrive at -//! the nominal rate) plus `IN_FLIGHT` frames still inside the pipeline. +//! timed fragment being written plus `IN_FLIGHT` frames still inside the +//! pipeline. A segment can contain several recoverable moof/mdat pairs +//! without introducing extra keyframes. mod common; @@ -46,11 +46,10 @@ use cap_verify::{ const FPS: u32 = 30; const WIDTH: u32 = 640; const HEIGHT: u32 = 360; -/// `DEFAULT_KEYFRAME_INTERVAL_SECS` in crates/enc-ffmpeg/src/video/h264.rs: -/// the encoder emits a keyframe every `GOP_SECS * fps` frames and both DASH -/// writers cut a segment only at a keyframe. -const GOP_SECS: f64 = 2.0; -const GOP: u32 = (GOP_SECS as u32) * FPS; +/// Studio flushes complete moof/mdat pairs at this capture-time interval. +/// The encoder GOP and the enclosing segment can be longer on a static screen. +const FRAGMENT_SECS: f64 = 2.0; +const FRAGMENT_FRAMES: u32 = (FRAGMENT_SECS as u32) * FPS; /// Frames that can still be inside the pipeline when the process dies: the /// one the encoder holds back to stamp real durations (segmented_stream.rs /// `queue_frame`), the one being encoded, and the one in the muxer channel. @@ -182,7 +181,10 @@ mod source { Arm::InProcess => { builder .build::>( - MacOSFragmentedM4SMuxerConfig::default(), + MacOSFragmentedM4SMuxerConfig { + timed_fragments: true, + ..Default::default() + }, ) .await } @@ -245,6 +247,7 @@ mod source { // The Studio display config (capture_pipeline.rs, X11Capture): // 2 s segments. .build::>(SegmentedVideoMuxerConfig { + timed_fragments: true, segment_duration: Duration::from_secs(2), ..Default::default() }) @@ -443,10 +446,11 @@ fn read_truth(path: &Path) -> anyhow::Result<(Vec, bool, Vec)> { continue; }; // A kill can cut the last line short. - if let (Ok(index), Ok(secs)) = (index.parse(), secs.parse()) { - if line.len() > 2 && secs_complete(line) { - handed.push(Handed { index, secs }); - } + if let (Ok(index), Ok(secs)) = (index.parse(), secs.parse()) + && line.len() > 2 + && secs_complete(line) + { + handed.push(Handed { index, secs }); } } Some("stop") => stopped = true, @@ -639,35 +643,86 @@ fn wait_for_exit(pids: &[u32], case: &mut Case) { } } -/// Top-level ISO BMFF boxes span the file exactly and include moof + mdat: -/// the writer finished this fragment (independent of recovery.rs). -fn fragment_complete(path: &Path) -> bool { - let Ok(data) = std::fs::read(path) else { - return false; +fn box_bounds(data: &[u8], at: usize) -> Option<(usize, usize)> { + let header = data.get(at..at.checked_add(8)?)?; + let size = u32::from_be_bytes(header[..4].try_into().ok()?); + let (size, header_size) = match size { + 0 => return None, + 1 => ( + usize::try_from(u64::from_be_bytes( + data.get(at.checked_add(8)?..at.checked_add(16)?)? + .try_into() + .ok()?, + )) + .ok()?, + 16, + ), + size => (size as usize, 8), }; - let mut at = 0usize; - let (mut moof, mut mdat) = (false, false); - while at + 8 <= data.len() { - let size32 = u32::from_be_bytes(data[at..at + 4].try_into().unwrap()) as usize; - let kind = &data[at + 4..at + 8]; - let size = match size32 { - 0 => return false, - 1 => { - if at + 16 > data.len() { - return false; + let end = at.checked_add(size)?; + (size >= header_size && end <= data.len()).then_some((at + header_size, end)) +} + +fn moof_sample_count(data: &[u8]) -> Option { + let mut at = 0; + let mut samples = 0usize; + while at < data.len() { + let (payload, end) = box_bounds(data, at)?; + if &data[at + 4..at + 8] == b"traf" { + let mut child = payload; + while child < end { + let (run, next) = box_bounds(&data[..end], child)?; + if &data[child + 4..child + 8] == b"trun" { + let count = u32::from_be_bytes(data[run..next].get(4..8)?.try_into().ok()?); + samples = samples.checked_add(count as usize)?; } - u64::from_be_bytes(data[at + 8..at + 16].try_into().unwrap()) as usize + child = next; } - n => n, - }; - if size < 8 { - return false; } - moof |= kind == b"moof"; - mdat |= kind == b"mdat"; - at += size; + at = end; + } + Some(samples) +} + +fn complete_prefix(data: &[u8]) -> Option<(usize, Vec)> { + let mut at = 0; + let mut complete = 0; + let mut pending = None; + let mut fragments = Vec::new(); + while let Some((payload, end)) = box_bounds(data, at) { + match &data[at + 4..at + 8] { + b"moof" => { + if pending.is_some() { + break; + } + let Some(samples) = + moof_sample_count(&data[payload..end]).filter(|count| *count > 0) + else { + break; + }; + pending = Some(samples); + } + b"mdat" => { + let Some(samples) = pending.take() else { + break; + }; + fragments.push(samples); + complete = end; + } + _ => {} + } + at = end; } - at == data.len() && moof && mdat + (complete > 0).then_some((complete, fragments)) +} + +fn has_complete_fragment(path: &Path) -> bool { + std::fs::read(path).is_ok_and(|data| complete_prefix(&data).is_some()) +} + +fn fragment_complete(path: &Path) -> bool { + std::fs::read(path) + .is_ok_and(|data| complete_prefix(&data).is_some_and(|(length, _)| length == data.len())) } fn segment_index(name: &str) -> Option { @@ -678,9 +733,8 @@ fn segment_index(name: &str) -> Option { .ok() } -/// Physically complete fragments of one writer directory (finished -/// `segment_NNN.m4s`, or a finished `.m4s.tmp` the writer had not renamed -/// yet), in index order. +/// Segments containing complete moof/mdat pairs, including a verified prefix +/// of an open `.m4s.tmp`, in index order. fn complete_fragments(dir: &Path) -> Vec { let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new(); @@ -694,7 +748,7 @@ fn complete_fragments(dir: &Path) -> Vec { let Some(index) = segment_index(name) else { continue; }; - if !fragment_complete(&path) { + if !has_complete_fragment(&path) { continue; } // Prefer the renamed file when both exist. @@ -735,10 +789,22 @@ fn decode_fragments( if fragments.is_empty() { return Ok(Vec::new()); } - let mut joined = std::fs::read(dir.join("init.mp4")).context("init.mp4")?; + let mut bytes = Vec::new(); for fragment in fragments { - joined.extend(std::fs::read(fragment)?); + let data = std::fs::read(fragment)?; + let (length, _) = complete_prefix(&data).context("no complete video fragment")?; + bytes.extend_from_slice(&data[..length]); } + decode_fragment_bytes(dir, &bytes, scratch) +} + +fn decode_fragment_bytes( + dir: &Path, + bytes: &[u8], + scratch: &Path, +) -> anyhow::Result> { + let mut joined = std::fs::read(dir.join("init.mp4")).context("init.mp4")?; + joined.extend_from_slice(bytes); let path = scratch.join(format!( "joined-{}.mp4", dir.file_name().unwrap_or_default().to_string_lossy() @@ -772,11 +838,18 @@ fn fragments_on_disk(display: &Path, scratch: &Path) -> anyhow::Result(), + "video sample tables and decoded frame count differ" + ); + let mut offset = 0; + for count in samples { + out.push(frames[offset..offset + count].to_vec()); + offset += count; + } } } Ok(out) @@ -838,7 +911,7 @@ fn on_disk(display: &Path, scratch: &Path) -> anyhow::Result> { /// A fragment covers the capture time from its first frame to the first /// frame of the next one, and the encoders must start a new one once -/// `GOP_SECS` have passed: the span may exceed `GOP_SECS` only by the +/// `FRAGMENT_SECS` have passed: the span may exceed `FRAGMENT_SECS` only by the /// interval before the frame that closed it. fn check_fragment_spans(case: &mut Case, fragments: &[Vec<(u32, f64)>], handed: &[Handed]) { let time: BTreeMap = handed.iter().map(|h| (h.index, h.secs)).collect(); @@ -856,11 +929,11 @@ fn check_fragment_spans(case: &mut Case, fragments: &[Vec<(u32, f64)>], handed: }; let span = next - start; worst = worst.max(span); - if span > GOP_SECS + (next - end) + 1e-6 { + if span > FRAGMENT_SECS + (next - end) + 1e-6 { case.problem( "fragment-span", format!( - "fragment of frames {}..={} covers {span:.2}s of capture, more than {GOP_SECS}s plus the {:.2}s before the frame that closed it", + "fragment of frames {}..={} covers {span:.2}s of capture, more than {FRAGMENT_SECS}s plus the {:.2}s before the frame that closed it", first.0, last.0, next - end @@ -1044,7 +1117,7 @@ fn check_recovery( ), ); } - let bound = GOP + IN_FLIGHT; + let bound = FRAGMENT_FRAMES + IN_FLIGHT; // In time: one fragment of capture time, plus the in-flight frames and // the frame that closes the fragment at the delivered interval (a static // screen delivers far fewer frames than the nominal rate). @@ -1052,7 +1125,7 @@ fn check_recovery( .windows(2) .map(|w| w[1].secs - w[0].secs) .fold(1.0 / f64::from(FPS), f64::max); - let bound_secs = GOP_SECS + f64::from(IN_FLIGHT + 1) * interval; + let bound_secs = FRAGMENT_SECS + f64::from(IN_FLIGHT + 1) * interval; case.note(format!( "handed {} frames, complete on disk up to {:?}, lost tail {lost_tail} frames / {lost_secs:.2}s (bound {bound} frames / {bound_secs:.2}s)", handed.len(), @@ -1063,7 +1136,7 @@ fn check_recovery( case.problem( "loss", format!( - "the crash lost the last {lost_tail} handed frames, more than one fragment ({GOP}) plus {IN_FLIGHT} in flight" + "the crash lost the last {lost_tail} handed frames, more than one fragment ({FRAGMENT_FRAMES}) plus {IN_FLIGHT} in flight" ), ); } @@ -1072,7 +1145,7 @@ fn check_recovery( case.problem( "loss-time", format!( - "the crash lost {lost_secs:.2}s of capture, more than one {GOP_SECS}s fragment plus {} frames in flight ({bound_secs:.2}s at the delivered interval)", IN_FLIGHT + 1 + "the crash lost {lost_secs:.2}s of capture, more than one {FRAGMENT_SECS}s fragment plus {} frames in flight ({bound_secs:.2}s at the delivered interval)", IN_FLIGHT + 1 ), ); } @@ -1148,7 +1221,7 @@ const STATIC_STEP: u32 = 10; const STATIC_SECONDS: f64 = 14.0; const STATIC_KILL_SECS: f64 = 11.0; -/// Kills at 2*GOP + k for k = 0..=12 and reports the first k at which the +/// Kills at 2*FRAGMENT_FRAMES + k for k = 0..=12 and reports the first k at which the /// second fragment was complete on disk. fn writer_latency(arm: Arm, case: &mut Case) -> anyhow::Result<()> { let mut first_complete = None; @@ -1159,14 +1232,14 @@ fn writer_latency(arm: Arm, case: &mut Case) -> anyhow::Result<()> { let display = scratch.join("display"); let truth = scratch.join("truth.txt"); let mut child = ChildRecorder::spawn(&display, &truth, arm, 5.0, 1)?; - let n = 2 * GOP + k; + let n = 2 * FRAGMENT_FRAMES + k; child.wait_until(Duration::from_secs(40), |handed, _| { handed.last().is_some_and(|h| h.index >= n) })?; let orphans = child.kill()?; wait_for_exit(&orphans, case); let physical = on_disk(&display, &scratch)?; - let done = physical.iter().any(|f| f.0 == 2 * GOP - 1); + let done = physical.iter().any(|f| f.0 == 2 * FRAGMENT_FRAMES - 1); case.note(format!( "kill after frame {n}: fragment 2 complete = {done}" )); @@ -1197,18 +1270,18 @@ fn kill_points(seed: u64) -> Vec<(String, KillPoint)> { let mut points = Vec::new(); // Mid-fragment, at seeded random frames past the first fragment. for k in 0..2 { - let lo = GOP + 15; + let lo = FRAGMENT_FRAMES + 15; let index = lo + (rng.next_u64() % u64::from(total - lo - 10)) as u32; points.push((format!("random{k}"), KillPoint::Frame(index))); } // Around the second rollover: the keyframe that closes fragment 2 is - // frame 2*GOP; the writer renames the segment when the packet after it + // frame 2*FRAGMENT_FRAMES; the writer renames the segment when the packet after it // arrives (the encoder holds one back), and the manifest learns about // it on its next 10-frame scan. for offset in [0u32, 2, 5] { points.push(( format!("rollover+{offset}"), - KillPoint::Frame(2 * GOP + offset), + KillPoint::Frame(2 * FRAGMENT_FRAMES + offset), )); } for delay in [0u64, 40] { @@ -1290,7 +1363,7 @@ fn muxer_kill(rt: &tokio::runtime::Runtime, case: &mut Case) -> anyhow::Result<( let scratch = scratch_root().join("muxer-kill"); fresh(&scratch)?; let display = scratch.join("display"); - let kill_at = GOP + GOP / 2; + let kill_at = FRAGMENT_FRAMES + FRAGMENT_FRAMES / 2; let me = std::process::id(); let killed = std::sync::Arc::new(std::sync::Mutex::new(None::)); let killed_hook = killed.clone(); @@ -1349,7 +1422,7 @@ fn muxer_kill(rt: &tokio::runtime::Runtime, case: &mut Case) -> anyhow::Result<( recovered.len(), run.handed.len() )); - if gap > 2 * GOP + IN_FLIGHT { + if gap > 2 * FRAGMENT_FRAMES + IN_FLIGHT { case.problem( "loss", format!( @@ -1576,7 +1649,7 @@ fn crash_safety() { settle(case); } // A mostly static screen: one frame every STATIC_STEP slots. A - // fragment must still close every GOP_SECS of capture time. + // fragment must still close every FRAGMENT_SECS of capture time. let id = format!("crash/recorder-kill/{}/static-screen", arm.label()); if report::selected(&id) { let mut case = Case::new(&id); @@ -1730,3 +1803,47 @@ fn pending_fragment_bound_rejects_other_complete_frame_loss() { assert!(case.classes.contains("dropped-complete")); assert!(!case.classes.contains("loss")); } + +#[test] +fn complete_prefix_keeps_whole_moof_pairs_before_every_truncated_tail() { + fn boxed(kind: &[u8; 4], payload: &[u8]) -> Vec { + let mut bytes = ((payload.len() + 8) as u32).to_be_bytes().to_vec(); + bytes.extend(kind); + bytes.extend(payload); + bytes + } + fn fragment(samples: u32) -> Vec { + let mut run = 0u32.to_be_bytes().to_vec(); + run.extend(samples.to_be_bytes()); + let mut bytes = boxed(b"moof", &boxed(b"traf", &boxed(b"trun", &run))); + bytes.extend(boxed(b"mdat", &[1, 2, 3, 4])); + bytes + } + let first = fragment(6); + let next = fragment(5); + for end in 0..next.len() { + let mut bytes = first.clone(); + bytes.extend(&next[..end]); + assert_eq!(complete_prefix(&bytes), Some((first.len(), vec![6]))); + } + let mut both = first.clone(); + both.extend(next); + assert_eq!(complete_prefix(&both), Some((both.len(), vec![6, 5]))); + assert!(complete_prefix(&first[..first.len() - 1]).is_none()); + let short_run = boxed(b"moof", &boxed(b"traf", &boxed(b"trun", &[0; 4]))); + assert!(complete_prefix(&short_run).is_none()); +} + +#[test] +fn complete_prefix_rejects_missing_moof_and_invalid_box_sizes() { + assert!(complete_prefix(&[]).is_none()); + assert!(complete_prefix(&[0; 16]).is_none()); + let mut data = 8u32.to_be_bytes().to_vec(); + data.extend(b"mdat"); + assert!(complete_prefix(&data).is_none()); + data[..4].copy_from_slice(&u32::MAX.to_be_bytes()); + assert!(complete_prefix(&data).is_none()); + data[..4].copy_from_slice(&1u32.to_be_bytes()); + data.extend(u64::MAX.to_be_bytes()); + assert!(complete_prefix(&data).is_none()); +}