diff --git a/AGENTS.md b/AGENTS.md index 98e2e58..5e6c45a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -644,3 +644,14 @@ verify its own source before asking the analyzer to explain the capture. - [x] Retry the Tethys browser recording `/start` request to survive transient SSH banner timeouts. - [x] Open the manual review capture directory in Dolphin after summarization so copied Tethys captures are immediately inspectable. - [ ] Re-run the mirrored probe and confirm the preview is visible/audible before trusting any pairing diagnosis. + +## 0.17.35 Right-Eye Capture Diagnostics Checklist + +Context: manual Tethys testing showed the desktop was awake and HDMI was on, but the right-eye feed stayed black. Server logs showed `eye=r` reached `PLAYING` and then hit a V4L2/GStreamer `Invalid argument (22)` poll error before any frame was pushed, while the left eye streamed normally. + +- [x] Confirm Tethys was not asleep: X11 reported HDMI connected at 1920x1080 and `Monitor is On`. +- [x] Remove stale Tethys browser-probe processes after mirrored probe runs so manual Google Meet testing does not compete with old recorder sessions. +- [x] Propagate late eye-capture GStreamer bus errors into the gRPC video stream so the launcher reports a preview stream error instead of a silent black window. +- [x] Add a first-frame watchdog for eye capture streams so opened-but-empty sources surface as explicit diagnostics. +- [ ] Re-run a manual two-eye session and confirm right-eye failures now appear in the session log with the concrete source error. +- [ ] If `eye-r` still reports `poll error ... EINVAL`, recover/reseat the right HDMI capture path or add a dedicated eye-capture soft recovery path separate from UVC/UAC. diff --git a/Cargo.lock b/Cargo.lock index f6fd258..d459319 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.17.34" +version = "0.17.35" dependencies = [ "anyhow", "async-stream", @@ -1686,7 +1686,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.17.34" +version = "0.17.35" dependencies = [ "anyhow", "base64", @@ -1698,7 +1698,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.17.34" +version = "0.17.35" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index 266d8e8..36cbf0a 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.17.34" +version = "0.17.35" edition = "2024" [dependencies] diff --git a/common/Cargo.toml b/common/Cargo.toml index 08473e9..e6925c9 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.17.34" +version = "0.17.35" edition = "2024" build = "build.rs" diff --git a/server/Cargo.toml b/server/Cargo.toml index 1a74cda..950f843 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.17.34" +version = "0.17.35" edition = "2024" autobins = false diff --git a/server/src/video/eye_capture.rs b/server/src/video/eye_capture.rs index 06e395a..8f3a6e1 100644 --- a/server/src/video/eye_capture.rs +++ b/server/src/video/eye_capture.rs @@ -172,9 +172,11 @@ pub async fn eye_ball_with_request( let queue_peak_depth = Arc::new(AtomicU32::new(0)); let last_telemetry_sec = Arc::new(AtomicU64::new(0)); let packet_seq = Arc::new(AtomicU64::new(0)); + let first_sample_seen = Arc::new(AtomicBool::new(false)); let queue_buffers = env_u32("LESAVKA_EYE_QUEUE_BUFFERS", 4).max(1); let appsink_buffers = env_u32("LESAVKA_EYE_APPSINK_BUFFERS", 4).max(1); + let first_frame_timeout_ms = env_u32("LESAVKA_EYE_FIRST_FRAME_TIMEOUT_MS", 5_000).max(500); let keyframe_interval = env_u32("LESAVKA_EYE_KEYFRAME_INTERVAL", request.fps.clamp(1, 5)) .clamp(1, request.fps.max(1)); let use_test_src = @@ -225,6 +227,7 @@ pub async fn eye_ball_with_request( let chan_capacity = env_usize("LESAVKA_EYE_CHAN_CAPACITY", 32).max(8); let (tx, rx) = tokio::sync::mpsc::channel(chan_capacity); + let tx_for_bus = tx.clone(); if let Some(src_pad) = pipeline .by_name(&format!("cam_{eye}")) @@ -252,6 +255,7 @@ pub async fn eye_ball_with_request( let last_telemetry_sec_for_cb = Arc::clone(&last_telemetry_sec); let server_encoder_label_for_cb = server_encoder_label.clone(); let server_process_cpu_tenths_for_cb = Arc::clone(&server_process_cpu_tenths); + let first_sample_seen_for_cb = Arc::clone(&first_sample_seen); sink.set_callbacks( gst_app::AppSinkCallbacks::builder() .new_sample(move |sink| { @@ -259,6 +263,7 @@ pub async fn eye_ball_with_request( let buffer = sample.buffer().ok_or(gst::FlowError::Error)?; let map = buffer.map_readable().map_err(|_| gst::FlowError::Error)?; let is_idr = contains_idr(map.as_slice()); + first_sample_seen_for_cb.store(true, Ordering::Relaxed); static FRAME: AtomicU64 = AtomicU64::new(0); let frame = FRAME.fetch_add(1, Ordering::Relaxed); @@ -406,7 +411,21 @@ pub async fn eye_ball_with_request( let bus = pipeline.bus().expect("bus"); start_eye_pipeline(&pipeline, &bus, eye)?; - let bus_watch = BusWatchHandle::spawn(bus, eye.to_owned()); + let first_sample_seen_for_watchdog = Arc::clone(&first_sample_seen); + let tx_for_first_frame_watchdog = tx_for_bus.clone(); + let first_frame_eye = eye.to_string(); + tokio::spawn(async move { + sleep(Duration::from_millis(first_frame_timeout_ms as u64)).await; + if !first_sample_seen_for_watchdog.load(Ordering::Relaxed) { + let detail = format!( + "eye-{first_frame_eye} capture produced no frames within {first_frame_timeout_ms} ms" + ); + let _ = tx_for_first_frame_watchdog + .send(Err(Status::internal(detail))) + .await; + } + }); + let bus_watch = BusWatchHandle::spawn(bus, eye.to_owned(), tx_for_bus); Ok(VideoStream { _pipeline: pipeline, diff --git a/server/src/video/stream_core.rs b/server/src/video/stream_core.rs index 86c6be2..8df8c93 100644 --- a/server/src/video/stream_core.rs +++ b/server/src/video/stream_core.rs @@ -80,7 +80,11 @@ struct BusWatchHandle { #[cfg(not(coverage))] impl BusWatchHandle { - fn spawn(bus: gst::Bus, eye: String) -> Self { + fn spawn( + bus: gst::Bus, + eye: String, + stream_errors: tokio::sync::mpsc::Sender>, + ) -> Self { let alive = Arc::new(AtomicBool::new(true)); let alive_flag = Arc::clone(&alive); let join = std::thread::spawn(move || { @@ -97,6 +101,12 @@ impl BusWatchHandle { err.error(), err.debug().unwrap_or_default() ); + let detail = format!( + "eye-{eye} capture pipeline error: {} ({})", + err.error(), + err.debug().unwrap_or_default() + ); + let _ = stream_errors.blocking_send(Err(Status::internal(detail))); break; } Warning(warning) => {