diff --git a/AGENTS.md b/AGENTS.md index 80f20ea..783cf22 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,6 +99,10 @@ path. defaults, preventing browsers from receiving frames outside negotiated caps. - [x] Make the UVC control helper answer probe/commit requests from the same live descriptor so Firefox/Chrome negotiation matches server frame output. +- [x] Bound UVC helper buffering and stale MJPEG replay so server-to-host video + freshness can be tightened without changing the bundled sync architecture. +- [x] Make direct UVC/UAC output-delay application absolute by default so stale + legacy calibration does not keep a hidden multi-second video delay alive. - [x] Continue reporting client timing and sink handoff diagnostics from bundled packets. - [ ] Add bundled-mode counters for first bundle, first audio push, first video feed, dropped stale bundles, and bundle queue age. diff --git a/Cargo.lock b/Cargo.lock index cb4eaf9..009a3bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.19.4" +version = "0.19.5" dependencies = [ "anyhow", "async-stream", @@ -1686,7 +1686,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.19.4" +version = "0.19.5" dependencies = [ "anyhow", "base64", @@ -1698,7 +1698,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.19.4" +version = "0.19.5" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index aca65fa..1ef687b 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.19.4" +version = "0.19.5" edition = "2024" [dependencies] diff --git a/common/Cargo.toml b/common/Cargo.toml index 16fa8e2..7b6b4fd 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.19.4" +version = "0.19.5" edition = "2024" build = "build.rs" diff --git a/docs/operational-env.md b/docs/operational-env.md index 64063ad..6f7ecbb 100644 --- a/docs/operational-env.md +++ b/docs/operational-env.md @@ -176,6 +176,7 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta | `LESAVKA_MIC_TEST_SOURCE_DESC` | client media capture/playback override | | `LESAVKA_MOUSE_DEVICE` | input routing/clipboard override | | `LESAVKA_OUTPUT_DELAY_APPLY` | manual direct UVC/UAC probe override; apply the measured server output-delay correction through the calibration API when the probe gates pass | +| `LESAVKA_OUTPUT_DELAY_APPLY_MODE` | manual direct UVC/UAC probe override; `absolute` sets the active output-path baseline to the measured device delay, while `relative` preserves legacy nudge behavior | | `LESAVKA_OUTPUT_DELAY_CALIBRATION` | manual direct UVC/UAC probe override; emit `output-delay-calibration.json` from a lab-attached USB host capture of server-generated signatures, defaults to enabled | | `LESAVKA_OUTPUT_DELAY_GAIN` | manual direct UVC/UAC probe override; scales measured output-delay correction before applying, defaults to `1.0` | | `LESAVKA_OUTPUT_DELAY_MAX_ABS_SKEW_MS` | manual direct UVC/UAC probe safety limit; refuses to apply/save implausibly large measured device skew, defaults to `5000` | @@ -278,6 +279,7 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta | `LESAVKA_UVC_APP_BLOCK` | server hardware/device override | | `LESAVKA_UVC_BLOCKING` | server hardware/device override | | `LESAVKA_UVC_BULK` | server hardware/device override | +| `LESAVKA_UVC_BUFFER_COUNT` | UVC helper freshness override; number of queued gadget output buffers, defaults to `2` for live-call freshness | | `LESAVKA_UVC_BY_PATH_ROOT` | server hardware/device override | | `LESAVKA_UVC_CODEC` | server hardware/device override | | `LESAVKA_UVC_CTRL_BIN` | server hardware/device override | @@ -289,8 +291,10 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta | `LESAVKA_UVC_EXTERNAL` | server hardware/device override | | `LESAVKA_UVC_FALLBACK` | server hardware/device override | | `LESAVKA_UVC_FPS` | server hardware/device override | +| `LESAVKA_UVC_FRAME_MAX_AGE_MS` | UVC helper freshness override; stale spooled MJPEG frames older than this are not replayed, defaults to `1000`; `0` disables TTL | | `LESAVKA_UVC_FRAME_SIZE` | server hardware/device override | | `LESAVKA_UVC_HEIGHT` | server hardware/device override | +| `LESAVKA_UVC_IDLE_PUMP_MS` | UVC helper freshness override; idle poll sleep while pumping host-returned buffers, defaults to `2` | | `LESAVKA_UVC_INTERVAL` | server hardware/device override | | `LESAVKA_UVC_LIMIT_PCT` | server hardware/device override | | `LESAVKA_UVC_MAXBURST` | server hardware/device override | diff --git a/scripts/manual/run_upstream_av_sync.sh b/scripts/manual/run_upstream_av_sync.sh index 144b0b4..9a7efc7 100755 --- a/scripts/manual/run_upstream_av_sync.sh +++ b/scripts/manual/run_upstream_av_sync.sh @@ -55,6 +55,7 @@ REMOTE_EXPECT_CAM_OUTPUT=${REMOTE_EXPECT_CAM_OUTPUT:-uvc} REMOTE_EXPECT_UVC_CODEC=${REMOTE_EXPECT_UVC_CODEC:-mjpeg} LESAVKA_OUTPUT_DELAY_CALIBRATION=${LESAVKA_OUTPUT_DELAY_CALIBRATION:-1} LESAVKA_OUTPUT_DELAY_APPLY=${LESAVKA_OUTPUT_DELAY_APPLY:-0} +LESAVKA_OUTPUT_DELAY_APPLY_MODE=${LESAVKA_OUTPUT_DELAY_APPLY_MODE:-absolute} LESAVKA_OUTPUT_DELAY_SAVE=${LESAVKA_OUTPUT_DELAY_SAVE:-0} LESAVKA_OUTPUT_DELAY_TARGET=${LESAVKA_OUTPUT_DELAY_TARGET:-video} LESAVKA_OUTPUT_DELAY_MIN_PAIRS=${LESAVKA_OUTPUT_DELAY_MIN_PAIRS:-8} @@ -273,6 +274,7 @@ write_output_delay_calibration() { "${LESAVKA_OUTPUT_DELAY_GAIN}" \ "${LESAVKA_OUTPUT_DELAY_MAX_STEP_US}" \ "${LESAVKA_OUTPUT_DELAY_APPLY}" \ + "${LESAVKA_OUTPUT_DELAY_APPLY_MODE}" \ "${LESAVKA_OUTPUT_DELAY_SAVE}" import json import math @@ -292,6 +294,7 @@ import sys gain_raw, max_step_raw, apply_raw, + apply_mode_raw, save_raw, ) = sys.argv[1:] @@ -323,6 +326,7 @@ report = json.loads(pathlib.Path(report_path).read_text()) verdict = report.get("verdict") or {} target = target.strip().lower() +apply_mode = apply_mode_raw.strip().lower() min_pairs = max(1, as_int(min_pairs_raw, 8)) max_abs_skew_ms = max(1.0, as_float(max_abs_skew_raw, 5000.0)) max_drift_ms = max(0.0, as_float(max_drift_raw, 80.0)) @@ -353,6 +357,11 @@ elif target == "audio": else: refusal_reasons.append(f"unsupported target {target!r}; use video or audio") +if apply_mode not in {"absolute", "relative"}: + refusal_reasons.append( + f"unsupported apply mode {apply_mode!r}; use absolute or relative" + ) + if paired < min_pairs: refusal_reasons.append(f"paired_event_count {paired} < {min_pairs}") if max_abs_observed_ms > max_abs_skew_ms: @@ -387,6 +396,7 @@ artifact = { "ready": ready, "decision": decision, "apply_enabled": as_bool(apply_raw), + "apply_mode": apply_mode, "save_enabled": as_bool(save_raw), "paired_event_count": paired, "min_pairs": min_pairs, @@ -402,6 +412,8 @@ artifact = { "bounded_device_delta_us": bounded_delta_us, "audio_offset_adjust_us": audio_delta_us, "video_offset_adjust_us": video_delta_us, + "audio_target_offset_us": audio_delta_us, + "video_target_offset_us": video_delta_us, "refusal_reasons": refusal_reasons, "note": note, } @@ -413,9 +425,12 @@ env_values = { "output_delay_target": target, "output_delay_audio_delta_us": audio_delta_us, "output_delay_video_delta_us": video_delta_us, + "output_delay_audio_target_offset_us": audio_delta_us, + "output_delay_video_target_offset_us": video_delta_us, "output_delay_measured_skew_ms": f"{median_skew_ms:.3f}", "output_delay_paired_event_count": paired, "output_delay_drift_ms": f"{drift_ms:.3f}", + "output_delay_apply_mode": apply_mode, "output_delay_note": note, } with pathlib.Path(output_env_path).open("w") as handle: @@ -668,6 +683,9 @@ maybe_apply_output_delay_calibration() { echo " ↪ output_delay_drift_ms=${output_delay_drift_ms:-0.0}" echo " ↪ output_delay_audio_delta_us=${output_delay_audio_delta_us:-0}" echo " ↪ output_delay_video_delta_us=${output_delay_video_delta_us:-0}" + echo " ↪ output_delay_audio_target_offset_us=${output_delay_audio_target_offset_us:-0}" + echo " ↪ output_delay_video_target_offset_us=${output_delay_video_target_offset_us:-0}" + echo " ↪ output_delay_apply_mode=${output_delay_apply_mode:-${LESAVKA_OUTPUT_DELAY_APPLY_MODE}}" echo " ↪ output_delay_note=${output_delay_note:-}" if [[ "${output_delay_ready:-false}" != "true" ]]; then @@ -680,13 +698,49 @@ maybe_apply_output_delay_calibration() { return 0 fi + local apply_mode="${output_delay_apply_mode:-${LESAVKA_OUTPUT_DELAY_APPLY_MODE}}" + local apply_audio_delta="${output_delay_audio_delta_us:-0}" + local apply_video_delta="${output_delay_video_delta_us:-0}" + + if [[ "${apply_mode}" == "absolute" ]]; then + local calibration_output current_audio_offset_us current_video_offset_us + if ! calibration_output="$( + LESAVKA_TLS_DOMAIN="${LESAVKA_TLS_DOMAIN}" \ + "${REPO_ROOT}/target/debug/lesavka-relayctl" \ + --server "${RESOLVED_LESAVKA_SERVER_ADDR}" \ + calibration 2>&1 + )"; then + echo " ↪ output delay calibration apply refused: current calibration query failed" + echo "${calibration_output}" >&2 + return 0 + fi + current_audio_offset_us="$(awk -F= '/^calibration_active_audio_offset_us=/{print $2; exit}' <<<"${calibration_output}")" + current_video_offset_us="$(awk -F= '/^calibration_active_video_offset_us=/{print $2; exit}' <<<"${calibration_output}")" + if [[ ! "${current_audio_offset_us}" =~ ^-?[0-9]+$ || ! "${current_video_offset_us}" =~ ^-?[0-9]+$ ]]; then + echo " ↪ output delay calibration apply refused: could not parse active calibration offsets" + echo "${calibration_output}" >&2 + return 0 + fi + apply_audio_delta=$(( ${output_delay_audio_target_offset_us:-0} - current_audio_offset_us )) + apply_video_delta=$(( ${output_delay_video_target_offset_us:-0} - current_video_offset_us )) + echo " ↪ current_active_audio_offset_us=${current_audio_offset_us}" + echo " ↪ current_active_video_offset_us=${current_video_offset_us}" + echo " ↪ absolute_target_audio_offset_us=${output_delay_audio_target_offset_us:-0}" + echo " ↪ absolute_target_video_offset_us=${output_delay_video_target_offset_us:-0}" + elif [[ "${apply_mode}" != "relative" ]]; then + echo " ↪ output delay calibration apply refused: unsupported apply mode ${apply_mode}" + return 0 + fi + + echo " ↪ calibration_apply_audio_delta_us=${apply_audio_delta}" + echo " ↪ calibration_apply_video_delta_us=${apply_video_delta}" echo "==> applying measured UVC/UAC output-delay calibration" LESAVKA_TLS_DOMAIN="${LESAVKA_TLS_DOMAIN}" \ "${REPO_ROOT}/target/debug/lesavka-relayctl" \ --server "${RESOLVED_LESAVKA_SERVER_ADDR}" \ calibrate \ - "${output_delay_audio_delta_us:-0}" \ - "${output_delay_video_delta_us:-0}" \ + "${apply_audio_delta}" \ + "${apply_video_delta}" \ "${output_delay_note:-direct UVC/UAC output-delay calibration}" if [[ "${LESAVKA_OUTPUT_DELAY_SAVE}" != "0" ]]; then diff --git a/server/Cargo.toml b/server/Cargo.toml index 53d107d..377a8ac 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.19.4" +version = "0.19.5" edition = "2024" autobins = false diff --git a/server/src/bin/lesavka-uvc.real.inc b/server/src/bin/lesavka-uvc.real.inc index 1daa246..a0e94b0 100644 --- a/server/src/bin/lesavka-uvc.real.inc +++ b/server/src/bin/lesavka-uvc.real.inc @@ -6,7 +6,7 @@ use std::fs::{File, OpenOptions}; use std::os::unix::fs::OpenOptionsExt; use std::os::unix::io::{AsRawFd, RawFd}; use std::thread; -use std::time::Duration; +use std::time::{Duration, SystemTime}; const STREAM_CTRL_SIZE_11: usize = 26; const STREAM_CTRL_SIZE_15: usize = 34; @@ -47,6 +47,9 @@ const V4L2_FIELD_NONE: u32 = 1; const V4L2_PIX_FMT_MJPEG: u32 = u32::from_le_bytes(*b"MJPG"); const MAX_MJPEG_FRAME_BYTES: usize = 1024 * 1024; const EMPTY_MJPEG_FRAME: &[u8] = &[0xff, 0xd8, 0xff, 0xd9]; +const DEFAULT_UVC_BUFFER_COUNT: u32 = 2; +const DEFAULT_UVC_IDLE_PUMP_MS: u64 = 2; +const DEFAULT_UVC_FRAME_MAX_AGE_MS: u64 = 1_000; #[repr(C)] struct V4l2EventSubscription { @@ -253,7 +256,7 @@ impl UvcVideoStream { fn start(&mut self, cfg: UvcConfig) -> Result<()> { self.stop(); self.set_format(cfg)?; - self.request_buffers(4)?; + self.request_buffers(uvc_buffer_count())?; for index in 0..self.buffers.len() { self.queue_buffer(index as u32)?; } @@ -421,6 +424,10 @@ impl UvcVideoStream { } fn refresh_latest_frame(&mut self) { + if frame_spool_is_stale(&self.frame_path, frame_spool_max_age()) { + self.latest_frame = EMPTY_MJPEG_FRAME.to_vec(); + return; + } if let Ok(frame) = std::fs::read(&self.frame_path) && !frame.is_empty() && frame.len() <= MAX_MJPEG_FRAME_BYTES @@ -471,6 +478,43 @@ fn frame_spool_path() -> std::path::PathBuf { .unwrap_or_else(|_| std::path::PathBuf::from("/run/lesavka-uvc-frame.mjpg")) } +fn uvc_buffer_count() -> u32 { + env_u32("LESAVKA_UVC_BUFFER_COUNT", DEFAULT_UVC_BUFFER_COUNT).clamp(1, 8) +} + +fn uvc_idle_pump_sleep() -> Duration { + Duration::from_millis(env_u64( + "LESAVKA_UVC_IDLE_PUMP_MS", + DEFAULT_UVC_IDLE_PUMP_MS, + )) +} + +fn frame_spool_max_age() -> Option { + match env_u64( + "LESAVKA_UVC_FRAME_MAX_AGE_MS", + DEFAULT_UVC_FRAME_MAX_AGE_MS, + ) { + 0 => None, + value => Some(Duration::from_millis(value)), + } +} + +fn frame_spool_is_stale(path: &std::path::Path, max_age: Option) -> bool { + let Some(max_age) = max_age else { + return false; + }; + let Ok(metadata) = std::fs::metadata(path) else { + return true; + }; + let Ok(modified) = metadata.modified() else { + return false; + }; + SystemTime::now() + .duration_since(modified) + .map(|age| age > max_age) + .unwrap_or(false) +} + fn main() -> Result<()> { let (dev, cfg) = parse_args()?; let _singleton = acquire_singleton_lock()?; @@ -482,7 +526,16 @@ fn main() -> Result<()> { ); let debug = env::var("LESAVKA_UVC_DEBUG").is_ok(); + let idle_sleep = uvc_idle_pump_sleep(); eprintln!("[lesavka-uvc] nonblock=1"); + eprintln!( + "[lesavka-uvc] freshness buffers={} idle_pump_ms={} frame_max_age_ms={}", + uvc_buffer_count(), + idle_sleep.as_millis(), + frame_spool_max_age() + .map(|duration| duration.as_millis().to_string()) + .unwrap_or_else(|| "disabled".to_string()) + ); let mut setup_seen: u64 = 0; let mut data_seen: u64 = 0; let mut dq_err_seen: u64 = 0; @@ -548,7 +601,7 @@ fn main() -> Result<()> { dq_err_last = code; } } - thread::sleep(Duration::from_millis(10)); + thread::sleep(idle_sleep); continue; } Some(libc::ENODEV) | Some(libc::EBADF) | Some(libc::EIO) => { @@ -1258,6 +1311,13 @@ fn env_u32(name: &str, default: u32) -> u32 { .unwrap_or(default) } +fn env_u64(name: &str, default: u64) -> u64 { + env::var(name) + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(default) +} + fn env_u8(name: &str) -> Option { env::var(name).ok().and_then(|v| v.parse::().ok()) } diff --git a/testing/tests/client_manual_sync_script_contract.rs b/testing/tests/client_manual_sync_script_contract.rs index 6a06628..53506aa 100644 --- a/testing/tests/client_manual_sync_script_contract.rs +++ b/testing/tests/client_manual_sync_script_contract.rs @@ -41,6 +41,7 @@ fn upstream_sync_script_tunnels_auto_server_addr_through_ssh() { "LOCAL_OUTPUT_DELAY_ENV=\"${LOCAL_REPORT_DIR}/output-delay-calibration.env\"", "LESAVKA_OUTPUT_DELAY_CALIBRATION=${LESAVKA_OUTPUT_DELAY_CALIBRATION:-1}", "LESAVKA_OUTPUT_DELAY_APPLY=${LESAVKA_OUTPUT_DELAY_APPLY:-0}", + "LESAVKA_OUTPUT_DELAY_APPLY_MODE=${LESAVKA_OUTPUT_DELAY_APPLY_MODE:-absolute}", "LESAVKA_OUTPUT_DELAY_SAVE=${LESAVKA_OUTPUT_DELAY_SAVE:-0}", "LESAVKA_OUTPUT_DELAY_TARGET=${LESAVKA_OUTPUT_DELAY_TARGET:-video}", "LESAVKA_OUTPUT_DELAY_MIN_PAIRS=${LESAVKA_OUTPUT_DELAY_MIN_PAIRS:-8}", @@ -65,6 +66,13 @@ fn upstream_sync_script_tunnels_auto_server_addr_through_ssh() { "probe_media_origin\": \"server-generated\"", "probe_media_path\": \"server generated signatures -> UVC/UAC sinks -> lab host capture\"", "audio_after_video_positive", + "audio_target_offset_us", + "video_target_offset_us", + "output_delay_audio_target_offset_us", + "output_delay_video_target_offset_us", + "calibration_active_video_offset_us", + "absolute_target_video_offset_us", + "calibration_apply_video_delta_us", "PROBE_EVENT_WIDTH_CODES=${PROBE_EVENT_WIDTH_CODES:-1,2,1,3", "\"${LESAVKA_OUTPUT_DELAY_PROBE_AUDIO_DELAY_US}\"", "\"${LESAVKA_OUTPUT_DELAY_PROBE_VIDEO_DELAY_US}\"", diff --git a/testing/tests/server_uvc_binary_extra_contract.rs b/testing/tests/server_uvc_binary_extra_contract.rs index db4b9ad..8399e87 100644 --- a/testing/tests/server_uvc_binary_extra_contract.rs +++ b/testing/tests/server_uvc_binary_extra_contract.rs @@ -351,6 +351,55 @@ mod uvc_binary_extra { assert_eq!(read_fifo_min(missing.to_str().expect("missing")), None); } + #[test] + #[serial] + fn uvc_freshness_defaults_bound_live_video_backlog() { + with_var("LESAVKA_UVC_BUFFER_COUNT", None::<&str>, || { + with_var("LESAVKA_UVC_IDLE_PUMP_MS", None::<&str>, || { + with_var("LESAVKA_UVC_FRAME_MAX_AGE_MS", None::<&str>, || { + assert_eq!(uvc_buffer_count(), 2); + assert_eq!(uvc_idle_pump_sleep(), std::time::Duration::from_millis(2)); + assert_eq!( + frame_spool_max_age(), + Some(std::time::Duration::from_millis(1_000)) + ); + }); + }); + }); + } + + #[test] + #[serial] + fn uvc_freshness_env_clamps_buffers_and_allows_disabling_frame_ttl() { + with_var("LESAVKA_UVC_BUFFER_COUNT", Some("99"), || { + with_var("LESAVKA_UVC_IDLE_PUMP_MS", Some("11"), || { + with_var("LESAVKA_UVC_FRAME_MAX_AGE_MS", Some("0"), || { + assert_eq!(uvc_buffer_count(), 8); + assert_eq!(uvc_idle_pump_sleep(), std::time::Duration::from_millis(11)); + assert_eq!(frame_spool_max_age(), None); + }); + }); + }); + } + + #[test] + fn frame_spool_staleness_uses_mtime_and_respects_disabled_ttl() { + let frame = NamedTempFile::new().expect("tmp frame"); + fs::write(frame.path(), [0xff, 0xd8, 0xff, 0xd9]).expect("write frame"); + + assert!(!frame_spool_is_stale(frame.path(), None)); + let missing = PathBuf::from("/tmp/lesavka-definitely-missing-frame.mjpg"); + assert!(frame_spool_is_stale( + missing.as_path(), + Some(std::time::Duration::from_millis(1)) + )); + std::thread::sleep(std::time::Duration::from_millis(2)); + assert!(frame_spool_is_stale( + frame.path(), + Some(std::time::Duration::from_millis(1)) + )); + } + #[test] fn compute_payload_cap_clamps_limit_pct_bounds() { with_var("LESAVKA_UVC_MAXPAYLOAD_LIMIT", None::<&str>, || {