From 92b9aeecbd1e3491b69a1956d1705b7f00a7798e Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Fri, 5 Jun 2026 02:02:17 -0300 Subject: [PATCH] ci(lesavka): repair hygiene gate regressions --- Cargo.toml | 4 + .../src/app/uplink_media/webcam_media_loop.rs | 12 +- client/src/app_support.rs | 171 ---------- .../src/launcher/state/launcher_state_impl.rs | 34 +- docs/operational-env.md | 1 + scripts/ci/hygiene_gate_baseline.json | 22 +- scripts/manual/run_synthetic_rct_uvc_probe.py | 318 +++++++++++++++++- server/src/video_sinks/mjpeg_spool.rs | 27 +- server/src/video_sinks/mjpeg_spool/audit.rs | 55 ++- server/src/video_sinks/mjpeg_spool/tests.rs | 52 ++- server/src/video_sinks/webcam_sink.rs | 1 + .../video_sinks/webcam_sink/constructor.rs | 2 + .../video_sinks/webcam_sink/frame_handoff.rs | 12 +- .../client/app/client_app_support_contract.rs | 173 ++++++++++ .../hevc_mjpeg_spool_integration.rs | 86 +++-- ...synthetic_rct_uvc_probe_manual_contract.rs | 19 ++ 16 files changed, 733 insertions(+), 256 deletions(-) create mode 100644 tests/contract/client/app/client_app_support_contract.rs diff --git a/Cargo.toml b/Cargo.toml index 490b420..dbb54ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -187,6 +187,10 @@ path = "tests/component/client/uplink/client_uplink_component_contract.rs" name = "client_app_include_contract" path = "tests/contract/client/app/client_app_include_contract.rs" +[[test]] +name = "client_app_support_contract" +path = "tests/contract/client/app/client_app_support_contract.rs" + [[test]] name = "client_app_process_contract" path = "tests/contract/client/app/client_app_process_contract.rs" diff --git a/client/src/app/uplink_media/webcam_media_loop.rs b/client/src/app/uplink_media/webcam_media_loop.rs index 0ea658b..e1f845b 100644 --- a/client/src/app/uplink_media/webcam_media_loop.rs +++ b/client/src/app/uplink_media/webcam_media_loop.rs @@ -2,8 +2,7 @@ impl LesavkaClientApp { /*──────────────── bundled webcam + mic stream ─────────────────*/ #[cfg(not(coverage))] #[allow(clippy::too_many_arguments)] - /// Keeps `webcam_media_loop` explicit because it sits on the live uplink path, where stale media must be dropped instead of queued into latency. - /// Inputs are the typed parameters; output is the return value or side effect. + /// Run the live bundled uplink loop, dropping stale media instead of queueing latency. async fn webcam_media_loop( ep: Channel, initial_camera_source: Option, @@ -27,7 +26,8 @@ impl LesavkaClientApp { continue; } - let active_camera_source = state.camera_source.resolve(initial_camera_source.as_deref()); + let active_camera_source = + state.camera_source.resolve(initial_camera_source.as_deref()); let active_camera_profile = state.camera_profile.resolve(initial_camera_profile.as_deref()); let active_camera_cfg = camera_config_with_live_codec(camera_cfg, &state.camera_codec); @@ -279,9 +279,9 @@ impl LesavkaClientApp { let desired_source = state .microphone_source .resolve(initial_microphone_source.as_deref()); - let desired_audio_codec = state - .audio_codec - .resolve(lesavka_common::audio_transport::UpstreamAudioCodec::Opus); + let desired_audio_codec = state.audio_codec.resolve( + lesavka_common::audio_transport::UpstreamAudioCodec::Opus, + ); let desired_noise_suppression = state.noise_suppression.resolve(false); if state.camera != active_camera_requested diff --git a/client/src/app_support.rs b/client/src/app_support.rs index 434aa8d..433b40b 100644 --- a/client/src/app_support.rs +++ b/client/src/app_support.rs @@ -74,174 +74,3 @@ fn env_flag_enabled(name: &str) -> bool { .map(|value| value.trim().to_ascii_lowercase()) .is_some_and(|value| matches!(value.as_str(), "1" | "true" | "yes" | "on")) } - -#[cfg(test)] -mod tests { - use super::{ - DEFAULT_SERVER_ADDR, camera_config_from_caps, next_delay, resolve_server_addr, - sanitize_video_queue, - }; - use crate::handshake::PeerCaps; - use crate::input::camera::CameraCodec; - use serial_test::serial; - use std::time::Duration; - - #[test] - fn resolve_server_addr_prefers_cli_then_env_then_default() { - assert_eq!( - resolve_server_addr(&[String::from("http://cli:1")], Some("http://env:2")), - "http://cli:1" - ); - assert_eq!( - resolve_server_addr( - &[ - String::from("--no-launcher"), - String::from("--server"), - String::from("http://cli-flag:3"), - ], - Some("http://env:2"), - ), - "http://cli-flag:3" - ); - assert_eq!( - resolve_server_addr(&[String::from("--launcher")], Some("http://env:2")), - "http://env:2" - ); - assert_eq!( - resolve_server_addr(&[], Some("http://env:2")), - "http://env:2" - ); - assert_eq!(resolve_server_addr(&[], None), DEFAULT_SERVER_ADDR); - } - - #[test] - #[serial] - fn camera_config_from_caps_requires_complete_profile() { - temp_env::with_var("LESAVKA_CAM_CODEC", None::<&str>, || { - let mut caps = PeerCaps { - camera: true, - microphone: false, - bundled_webcam_media: false, - server_version: None, - camera_output: Some(String::from("uvc")), - camera_codec: Some(String::from("mjpeg")), - camera_width: Some(1280), - camera_height: Some(720), - camera_fps: Some(25), - eye_width: None, - eye_height: None, - eye_fps: None, - }; - - let config = camera_config_from_caps(&caps).expect("complete caps should map"); - assert!(matches!(config.codec, CameraCodec::Mjpeg)); - assert_eq!(config.width, 1280); - - caps.camera_codec = Some(String::from("h265")); - let config = camera_config_from_caps(&caps).expect("h265 alias should map"); - assert!(matches!(config.codec, CameraCodec::Hevc)); - - caps.camera_codec = Some(String::from("vp9")); - assert!(camera_config_from_caps(&caps).is_none()); - }); - } - - #[test] - #[serial] - fn camera_config_from_caps_uses_negotiated_codec_over_launcher_default() { - let caps = PeerCaps { - camera: true, - microphone: false, - bundled_webcam_media: false, - server_version: None, - camera_output: Some(String::from("uvc")), - camera_codec: Some(String::from("hevc")), - camera_width: Some(1280), - camera_height: Some(720), - camera_fps: Some(25), - eye_width: None, - eye_height: None, - eye_fps: None, - }; - - temp_env::with_var("LESAVKA_CAM_CODEC", Some("mjpeg"), || { - let config = camera_config_from_caps(&caps).expect("caps codec should map"); - assert!(matches!(config.codec, CameraCodec::Hevc)); - }); - } - - #[test] - #[serial] - fn camera_config_from_caps_allows_explicit_forced_codec_override() { - let caps = PeerCaps { - camera: true, - microphone: false, - bundled_webcam_media: false, - server_version: None, - camera_output: Some(String::from("uvc")), - camera_codec: Some(String::from("hevc")), - camera_width: Some(1280), - camera_height: Some(720), - camera_fps: Some(25), - eye_width: None, - eye_height: None, - eye_fps: None, - }; - - temp_env::with_vars( - [ - ("LESAVKA_CAM_CODEC", Some("mjpeg")), - ("LESAVKA_CAM_CODEC_FORCE", Some("1")), - ], - || { - let config = camera_config_from_caps(&caps).expect("forced override should map"); - assert!(matches!(config.codec, CameraCodec::Mjpeg)); - }, - ); - } - - #[test] - #[serial] - fn camera_config_force_flag_ignores_unknown_override() { - let caps = PeerCaps { - camera: true, - microphone: false, - bundled_webcam_media: false, - server_version: None, - camera_output: Some(String::from("uvc")), - camera_codec: Some(String::from("hevc")), - camera_width: Some(1280), - camera_height: Some(720), - camera_fps: Some(25), - eye_width: None, - eye_height: None, - eye_fps: None, - }; - - temp_env::with_vars( - [ - ("LESAVKA_CAM_CODEC", Some("vp9")), - ("LESAVKA_CAM_CODEC_FORCE", Some("1")), - ], - || { - let config = - camera_config_from_caps(&caps).expect("bad forced override should fall back"); - assert!(matches!(config.codec, CameraCodec::Hevc)); - }, - ); - } - - #[test] - fn sanitize_video_queue_enforces_floor() { - assert_eq!(sanitize_video_queue(None), 8); - assert_eq!(sanitize_video_queue(Some(1)), 4); - assert_eq!(sanitize_video_queue(Some(32)), 32); - } - - #[test] - fn next_delay_doubles_until_capped() { - assert_eq!(next_delay(Duration::from_secs(1)), Duration::from_secs(2)); - assert_eq!(next_delay(Duration::from_secs(15)), Duration::from_secs(30)); - assert_eq!(next_delay(Duration::from_secs(31)), Duration::from_secs(30)); - } -} diff --git a/client/src/launcher/state/launcher_state_impl.rs b/client/src/launcher/state/launcher_state_impl.rs index 7196fda..cd2e33e 100644 --- a/client/src/launcher/state/launcher_state_impl.rs +++ b/client/src/launcher/state/launcher_state_impl.rs @@ -12,14 +12,7 @@ impl LauncherState { } pub fn set_server_version(&mut self, version: Option) { - self.server_version = version.and_then(|value| { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } - }); + self.server_version = normalize_optional_text(version); } pub fn set_server_media_caps( @@ -31,22 +24,8 @@ impl LauncherState { ) { self.server_camera = camera; self.server_microphone = microphone; - self.server_camera_output = camera_output.and_then(|value| { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } - }); - self.server_camera_codec = camera_codec.and_then(|value| { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } - }); + self.server_camera_output = normalize_optional_text(camera_output); + self.server_camera_codec = normalize_optional_text(camera_codec); } pub fn set_view_mode(&mut self, view_mode: ViewMode) { @@ -481,3 +460,10 @@ impl LauncherState { self.upstream_sync = upstream_sync; } } + +fn normalize_optional_text(value: Option) -> Option { + value.and_then(|value| { + let trimmed = value.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + }) +} diff --git a/docs/operational-env.md b/docs/operational-env.md index 12e7af9..ac1bcb2 100644 --- a/docs/operational-env.md +++ b/docs/operational-env.md @@ -340,6 +340,7 @@ 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_AUDIT_CONTROL_PATH` | UVC helper boundary-audit runtime control path; defaults to `/tmp/lesavka-uvc-frame-audit.control`, where a path value enables exact-frame audit capture and `off`/`0` disables it without restarting the server | | `LESAVKA_UVC_FRAME_AUDIT_DIR` | UVC helper boundary-audit directory; when set, the server saves exact MJPEG frames published to the UVC helper plus a JSONL index so recordings can be compared against pre-UVC payloads | | `LESAVKA_UVC_FRAME_AUDIT_EVERY` | UVC helper boundary-audit sampling interval; saves every Nth spooled frame, defaults to `1` for short repros | | `LESAVKA_UVC_FRAME_AUDIT_LOG_PATH` | UVC helper boundary-audit JSONL path override; defaults to `spool-audit.jsonl` inside `LESAVKA_UVC_FRAME_AUDIT_DIR` | diff --git a/scripts/ci/hygiene_gate_baseline.json b/scripts/ci/hygiene_gate_baseline.json index 38674f5..5db629e 100644 --- a/scripts/ci/hygiene_gate_baseline.json +++ b/scripts/ci/hygiene_gate_baseline.json @@ -68,12 +68,12 @@ "client/src/app/uplink_media/webcam_media_loop.rs": { "clippy_warnings": 0, "doc_debt": 3, - "loc": 476 + "loc": 492 }, "client/src/app_support.rs": { "clippy_warnings": 0, - "doc_debt": 3, - "loc": 174 + "doc_debt": 0, + "loc": 76 }, "client/src/bin/lesavka-relayctl.rs": { "clippy_warnings": 0, @@ -362,8 +362,8 @@ }, "client/src/launcher/state/launcher_state_impl.rs": { "clippy_warnings": 0, - "doc_debt": 17, - "loc": 475 + "doc_debt": 16, + "loc": 469 }, "client/src/launcher/state/launcher_status_line.rs": { "clippy_warnings": 0, @@ -1328,32 +1328,32 @@ "server/src/video_sinks/mjpeg_spool.rs": { "clippy_warnings": 0, "doc_debt": 4, - "loc": 443 + "loc": 468 }, "server/src/video_sinks/mjpeg_spool/audit.rs": { "clippy_warnings": 0, "doc_debt": 0, - "loc": 287 + "loc": 338 }, "server/src/video_sinks/mjpeg_spool/tests.rs": { "clippy_warnings": 0, "doc_debt": 2, - "loc": 439 + "loc": 489 }, "server/src/video_sinks/webcam_sink.rs": { "clippy_warnings": 0, "doc_debt": 7, - "loc": 479 + "loc": 480 }, "server/src/video_sinks/webcam_sink/constructor.rs": { "clippy_warnings": 0, "doc_debt": 1, - "loc": 382 + "loc": 384 }, "server/src/video_sinks/webcam_sink/frame_handoff.rs": { "clippy_warnings": 0, "doc_debt": 4, - "loc": 357 + "loc": 364 }, "server/src/video_sinks/webcam_sink/tests.rs": { "clippy_warnings": 0, diff --git a/scripts/manual/run_synthetic_rct_uvc_probe.py b/scripts/manual/run_synthetic_rct_uvc_probe.py index f945c5d..866f67f 100755 --- a/scripts/manual/run_synthetic_rct_uvc_probe.py +++ b/scripts/manual/run_synthetic_rct_uvc_probe.py @@ -22,6 +22,7 @@ HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC = 8000 DEFAULT_ISOCHRONOUS_LIMIT_PCT = 85 DEFAULT_UVC_MAX_PACKET = 1024 DEFAULT_MEDIA_CONTROL_PATH = "/tmp/lesavka-media.control" +DEFAULT_SERVER_UVC_AUDIT_CONTROL_PATH = "/tmp/lesavka-uvc-frame-audit.control" MARKER_BITS = 32 MARKER_COLUMNS = 16 CADENCE_REASONS = {"frame_repeat", "frame_gap", "frame_backwards"} @@ -235,6 +236,32 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--max-reference-artifacts", type=int, default=12) parser.add_argument("--reference-every", type=int, default=900) parser.add_argument("--progress-every", type=int, default=150) + parser.add_argument( + "--server-uvc-audit", + action="store_true", + help="enable exact server-side UVC-bound MJPEG audit evidence for this run", + ) + parser.add_argument( + "--server-uvc-audit-host", + default="", + help="SSH host running the Lesavka server; defaults to --inject-host when set", + ) + parser.add_argument( + "--server-uvc-audit-control-path", + default=DEFAULT_SERVER_UVC_AUDIT_CONTROL_PATH, + help="runtime control file read by the server to enable UVC-bound frame auditing", + ) + parser.add_argument( + "--server-uvc-audit-dir", + default="", + help="remote audit directory; default uses a unique /tmp path on the server host", + ) + parser.add_argument( + "--server-uvc-audit-sample-frames", + type=int, + default=30, + help="number of audited MJPEG frames to copy/decode for boundary classification", + ) parser.add_argument( "--stream-analyze", action="store_true", @@ -417,6 +444,263 @@ def restore_remote_live_upstream(host: str, state: dict[str, Any]) -> None: ) +def resolve_server_uvc_audit_host(args: argparse.Namespace) -> str: + if args.server_uvc_audit_host: + return args.server_uvc_audit_host + if args.inject_host: + return args.inject_host + return "" + + +def setup_server_uvc_audit(args: argparse.Namespace, artifact_stamp: str) -> tuple[str, str] | None: + if not args.server_uvc_audit: + return None + host = resolve_server_uvc_audit_host(args) + if not host: + raise SystemExit("--server-uvc-audit requires --server-uvc-audit-host when --local-inject is used") + remote_dir = args.server_uvc_audit_dir or f"/tmp/lesavka-synthetic-rct-uvc-audit-{artifact_stamp}" + command = ( + f"rm -rf {shlex.quote(remote_dir)} && " + f"mkdir -p {shlex.quote(remote_dir)} && " + f"printf '%s\\n' {shlex.quote(remote_dir)} > {shlex.quote(args.server_uvc_audit_control_path)}" + ) + subprocess.run(["ssh", host, command], check=True) + print( + f"enabled server UVC-bound frame audit on {host}: {remote_dir}", + file=sys.stderr, + ) + return host, remote_dir + + +def cleanup_server_uvc_audit(args: argparse.Namespace, state: tuple[str, str] | None) -> None: + if state is None: + return + host, _remote_dir = state + command = f"rm -f {shlex.quote(args.server_uvc_audit_control_path)}" + subprocess.run(["ssh", host, command], check=False) + print( + f"disabled server UVC-bound frame audit on {host}", + file=sys.stderr, + ) + + +def read_jsonl(path: pathlib.Path) -> list[dict[str, Any]]: + records: list[dict[str, Any]] = [] + if not path.exists(): + return records + for line in path.read_text(errors="replace").splitlines(): + try: + value = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(value, dict): + records.append(value) + return records + + +def sample_records(records: list[dict[str, Any]], limit: int) -> list[dict[str, Any]]: + if limit <= 0 or len(records) <= limit: + return records + if limit == 1: + return [records[-1]] + indexes = { + round(idx * (len(records) - 1) / (limit - 1)) + for idx in range(limit) + } + return [records[idx] for idx in sorted(indexes)] + + +def copy_server_uvc_audit( + args: argparse.Namespace, + state: tuple[str, str] | None, + local_dir: pathlib.Path, +) -> pathlib.Path | None: + if state is None: + return None + host, remote_dir = state + local_dir.mkdir(parents=True, exist_ok=True) + remote_log = f"{remote_dir.rstrip('/')}/spool-audit.jsonl" + local_log = local_dir / "spool-audit.jsonl" + subprocess.run(["scp", f"{host}:{remote_log}", str(local_log)], check=False) + records = read_jsonl(local_log) + for record in sample_records(records, args.server_uvc_audit_sample_frames): + frame_file = str(record.get("file") or "") + if not frame_file or "/" in frame_file: + continue + subprocess.run( + [ + "scp", + f"{host}:{remote_dir.rstrip('/')}/{frame_file}", + str(local_dir / frame_file), + ], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return local_dir + + +def decode_mjpeg_to_gray(path: pathlib.Path, width: int, height: int) -> bytes | None: + if width <= 0 or height <= 0 or not path.exists(): + return None + proc = subprocess.run( + [ + "ffmpeg", + "-hide_banner", + "-loglevel", + "error", + "-i", + str(path), + "-an", + "-pix_fmt", + "gray", + "-f", + "rawvideo", + "-", + ], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + check=False, + ) + expected = width * height + if proc.returncode != 0 or len(proc.stdout) < expected: + return None + return proc.stdout[:expected] + + +def summarize_server_uvc_audit( + local_dir: pathlib.Path | None, + mode_width: int, + mode_height: int, + mode_fps: int, + capture_data: dict[str, Any] | None, + args: argparse.Namespace, +) -> dict[str, Any] | None: + if local_dir is None: + return None + log_path = local_dir / "spool-audit.jsonl" + records = read_jsonl(log_path) + frame_size_counts: collections.Counter[str] = collections.Counter() + uvc_mode_counts: collections.Counter[str] = collections.Counter() + complete_count = 0 + rejected_count = 0 + decoded_sample_count = 0 + marker_sample_count = 0 + visual_sample_count = 0 + sample_reason_counts: collections.Counter[str] = collections.Counter() + previous_seq: int | None = None + + for record in records: + width = record.get("frame_width") + height = record.get("frame_height") + frame_size_counts[f"{width}x{height}"] += 1 + uvc_width = record.get("uvc_width") + uvc_height = record.get("uvc_height") + uvc_fps = record.get("uvc_fps") + uvc_mode_counts[f"{uvc_width}x{uvc_height}@{uvc_fps}"] += 1 + complete_count += int(bool(record.get("jpeg_complete"))) + rejected_count += int(bool(record.get("rejected"))) + + for record in sample_records(records, args.server_uvc_audit_sample_frames): + frame_file = str(record.get("file") or "") + width = int(record.get("frame_width") or 0) + height = int(record.get("frame_height") or 0) + if not frame_file or width <= 0 or height <= 0: + continue + raw = decode_mjpeg_to_gray(local_dir / frame_file, width, height) + if raw is None: + continue + decoded_sample_count += 1 + result = analyze_frame(raw, width, height, args, previous_seq) + comparison_seq = result.get("comparison_sequence") + if comparison_seq is not None: + previous_seq = int(comparison_seq) + if result.get("decoded_sequence") is not None: + marker_sample_count += 1 + if result.get("visual_suspicious"): + visual_sample_count += 1 + sample_reason_counts.update(result.get("visual_reasons") or []) + + matching_records = [ + record + for record in records + if int(record.get("frame_width") or 0) == mode_width + and int(record.get("frame_height") or 0) == mode_height + ] + matching_uvc_records = [ + record + for record in records + if int(record.get("uvc_width") or 0) == mode_width + and int(record.get("uvc_height") or 0) == mode_height + and int(record.get("uvc_fps") or 0) == mode_fps + ] + capture_visual_frames = int((capture_data or {}).get("visual_suspicious_frames") or 0) + capture_frames = int((capture_data or {}).get("frames") or 0) + status = "inconclusive" + diagnosis: list[str] = [] + if not records: + status = "server_boundary_missing" + diagnosis.append( + "server UVC-bound audit recorded no frames; the software output path did not prove it produced fresh webcam frames during the probe" + ) + elif rejected_count and rejected_count == len(records): + status = "server_boundary_rejected" + diagnosis.append( + "every audited UVC-bound frame was rejected before handoff; corruption or profile trouble is before the browser-facing UVC path" + ) + elif complete_count < len(records): + status = "server_boundary_incomplete_jpeg" + diagnosis.append( + "the server UVC-bound audit contains incomplete JPEG payloads, so corruption exists before or at the server handoff" + ) + elif not matching_records: + status = "server_boundary_frame_mode_mismatch" + diagnosis.append( + f"server UVC-bound frames did not match requested {mode_width}x{mode_height}; observed frame sizes {dict(frame_size_counts)}" + ) + elif not matching_uvc_records: + status = "server_boundary_uvc_mode_mismatch" + diagnosis.append( + f"server UVC-bound records did not advertise requested {mode_width}x{mode_height}@{mode_fps}; observed UVC modes {dict(uvc_mode_counts)}" + ) + elif visual_sample_count: + status = "server_boundary_visual_corruption" + diagnosis.append( + "decoded server UVC-bound audit samples were already visually suspicious before reaching the host/browser" + ) + elif capture_visual_frames: + status = "downstream_uvc_or_browser_corruption" + diagnosis.append( + "server UVC-bound samples were clean and mode-matched, but receiver capture showed visual corruption; the software UVC gadget/browser leg is implicated" + ) + elif capture_frames: + status = "no_visual_corruption_observed" + diagnosis.append( + "server UVC-bound samples and receiver capture had no visual corruption in this run" + ) + + summary = { + "schema": "lesavka.server-uvc-boundary-summary.v1", + "status": status, + "diagnosis": diagnosis, + "record_count": len(records), + "complete_count": complete_count, + "rejected_count": rejected_count, + "frame_size_counts": dict(frame_size_counts), + "uvc_mode_counts": dict(uvc_mode_counts), + "matching_frame_records": len(matching_records), + "matching_uvc_mode_records": len(matching_uvc_records), + "decoded_sample_count": decoded_sample_count, + "marker_sample_count": marker_sample_count, + "visual_sample_count": visual_sample_count, + "sample_visual_reason_counts": dict(sample_reason_counts), + "artifact_dir": str(local_dir), + "log_path": str(log_path), + } + (local_dir / "boundary-summary.json").write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n") + return summary + + def run_remote_orchestrated(args: argparse.Namespace) -> int: if (not args.inject_host and not args.local_inject) or not args.rct_host: raise SystemExit( @@ -425,11 +709,16 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int: if not shutil.which("ssh") or not shutil.which("scp"): raise SystemExit("ssh and scp are required for the remote synthetic probe") width, height, fps = mode_dimensions(args) + run_stamp = timestamp() inject_max_frame_bytes = args.inject_max_frame_bytes or default_inject_max_frame_bytes(fps) - artifact_dir = pathlib.Path(args.artifact_dir) if args.artifact_dir else default_artifact_dir(args.mode) + artifact_dir = ( + pathlib.Path(args.artifact_dir) + if args.artifact_dir + else pathlib.Path("artifacts/synthetic-rct") / f"{args.mode.replace('@', '-').replace('x', 'x')}-{run_stamp}" + ) artifact_dir.mkdir(parents=True, exist_ok=True) - remote_rct_dir = args.remote_rct_dir or f"/tmp/lesavka-synthetic-rct-capture-{timestamp()}" - remote_inject_dir = args.remote_inject_dir or f"/tmp/lesavka-synthetic-uplink-{timestamp()}" + remote_rct_dir = args.remote_rct_dir or f"/tmp/lesavka-synthetic-rct-capture-{run_stamp}" + remote_inject_dir = args.remote_inject_dir or f"/tmp/lesavka-synthetic-uplink-{run_stamp}" remote_script = f"/tmp/lesavka-synthetic-rct-probe-{os.getpid()}.py" script_text = pathlib.Path(__file__).read_text() subprocess.run( @@ -537,6 +826,10 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int: "rct_host": args.rct_host, "pause_local_live_upstream": args.pause_local_live_upstream, "media_control_path": args.media_control_path, + "server_uvc_audit": args.server_uvc_audit, + "server_uvc_audit_host": resolve_server_uvc_audit_host(args), + "server_uvc_audit_control_path": args.server_uvc_audit_control_path, + "server_uvc_audit_sample_frames": args.server_uvc_audit_sample_frames, }, indent=2, sort_keys=True, @@ -598,7 +891,9 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int: diagnosis: list[str] = [] paused_control: tuple[pathlib.Path, bytes | None] | None = None paused_remote_control: tuple[str, dict[str, Any]] | None = None + server_audit_state: tuple[str, str] | None = None try: + server_audit_state = setup_server_uvc_audit(args, run_stamp) if args.pause_local_live_upstream: if args.local_inject: paused_control = pause_local_live_upstream(args) @@ -624,12 +919,14 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int: capture = start_capture() capture_rc, inject_rc = wait_capture_or_inject_exit(capture, inject) finally: + cleanup_server_uvc_audit(args, server_audit_state) if paused_remote_control is not None: restore_remote_live_upstream(*paused_remote_control) if paused_control is not None: restore_local_live_upstream(*paused_control) local_capture = artifact_dir / "capture" local_inject = artifact_dir / "inject" + local_server_audit = artifact_dir / "server-uvc-audit" if capture is not None: subprocess.run(["scp", "-r", f"{args.rct_host}:{remote_rct_dir}", str(local_capture)], check=False) if args.local_inject: @@ -639,7 +936,9 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int: shutil.copytree(remote_inject_dir, local_inject) else: subprocess.run(["scp", "-r", f"{args.inject_host}:{remote_inject_dir}", str(local_inject)], check=False) + copied_server_audit = copy_server_uvc_audit(args, server_audit_state, local_server_audit) capture_summary = local_capture / "summary.json" + capture_data: dict[str, Any] | None = None if capture_summary.exists(): try: capture_data = json.loads(capture_summary.read_text()) @@ -695,6 +994,17 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int: ) except Exception: pass + server_boundary_summary = summarize_server_uvc_audit( + copied_server_audit, + width, + height, + fps, + capture_data, + args, + ) + if server_boundary_summary: + for item in server_boundary_summary.get("diagnosis") or []: + diagnosis.append(str(item)) summary = { "schema": "lesavka.synthetic-rct-probe.orchestrator.v1", "mode": args.mode, @@ -704,6 +1014,8 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int: "artifact_dir": str(artifact_dir), "capture_artifacts": str(local_capture), "inject_artifacts": str(local_inject), + "server_uvc_boundary": server_boundary_summary, + "server_uvc_audit_artifacts": str(local_server_audit) if copied_server_audit else None, } (artifact_dir / "run-summary.json").write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n") print(json.dumps(summary, indent=2, sort_keys=True)) diff --git a/server/src/video_sinks/mjpeg_spool.rs b/server/src/video_sinks/mjpeg_spool.rs index 79a67b6..aba1d7c 100644 --- a/server/src/video_sinks/mjpeg_spool.rs +++ b/server/src/video_sinks/mjpeg_spool.rs @@ -23,6 +23,9 @@ pub(super) struct MjpegSpoolTiming { pub profile: &'static str, pub source_pts_us: Option, pub decoded_pts_us: Option, + pub uvc_width: Option, + pub uvc_height: Option, + pub uvc_fps: Option, } impl MjpegSpoolTiming { @@ -36,6 +39,9 @@ impl MjpegSpoolTiming { profile: "mjpeg-passthrough", source_pts_us: Some(source_pts_us), decoded_pts_us: None, + uvc_width: None, + uvc_height: None, + uvc_fps: None, } } @@ -50,6 +56,9 @@ impl MjpegSpoolTiming { profile: "mjpeg-normalized", source_pts_us: Some(source_pts_us), decoded_pts_us: None, + uvc_width: None, + uvc_height: None, + uvc_fps: None, } } @@ -64,6 +73,9 @@ impl MjpegSpoolTiming { profile: "hevc-decoded-mjpeg", source_pts_us: Some(source_pts_us), decoded_pts_us, + uvc_width: None, + uvc_height: None, + uvc_fps: None, } } @@ -81,8 +93,18 @@ impl MjpegSpoolTiming { profile: "hevc-decoded-mjpeg-rejected", source_pts_us: Some(source_pts_us), decoded_pts_us, + uvc_width: None, + uvc_height: None, + uvc_fps: None, } } + + pub(super) fn with_uvc_mode(mut self, width: u16, height: u16, fps: u32) -> Self { + self.uvc_width = Some(width); + self.uvc_height = Some(height); + self.uvc_fps = Some(fps); + self + } } /// Decide whether the UVC helper file-spool path should own MJPEG emission. @@ -345,12 +367,15 @@ pub(super) fn format_mjpeg_spool_metadata( timing: MjpegSpoolTiming, ) -> String { format!( - "{{\"schema\":\"lesavka.uvc-mjpeg-spool-meta.v1\",\"sequence\":{},\"profile\":\"{}\",\"bytes\":{},\"source_pts_us\":{},\"decoded_pts_us\":{},\"spool_unix_ns\":{}}}\n", + "{{\"schema\":\"lesavka.uvc-mjpeg-spool-meta.v1\",\"sequence\":{},\"profile\":\"{}\",\"bytes\":{},\"source_pts_us\":{},\"decoded_pts_us\":{},\"uvc_width\":{},\"uvc_height\":{},\"uvc_fps\":{},\"spool_unix_ns\":{}}}\n", sequence, timing.profile, bytes, json_number_or_null(timing.source_pts_us), json_number_or_null(timing.decoded_pts_us), + json_number_or_null(timing.uvc_width.map(u64::from)), + json_number_or_null(timing.uvc_height.map(u64::from)), + json_number_or_null(timing.uvc_fps.map(u64::from)), unix_now_ns() ) } diff --git a/server/src/video_sinks/mjpeg_spool/audit.rs b/server/src/video_sinks/mjpeg_spool/audit.rs index 5f9c103..5801859 100644 --- a/server/src/video_sinks/mjpeg_spool/audit.rs +++ b/server/src/video_sinks/mjpeg_spool/audit.rs @@ -4,6 +4,7 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; +use super::super::hevc_mjpeg_guard; use super::MjpegSpoolTiming; static AUDIT_TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(1); @@ -11,6 +12,7 @@ static AUDIT_SAVED_FRAMES: AtomicU64 = AtomicU64::new(0); const DEFAULT_UVC_FRAME_AUDIT_EVERY: u32 = 1; const DEFAULT_UVC_FRAME_AUDIT_MAX_FRAMES: u32 = 1800; const DEFAULT_UVC_FRAME_AUDIT_ROLLING: bool = true; +const DEFAULT_UVC_FRAME_AUDIT_CONTROL_PATH: &str = "/tmp/lesavka-uvc-frame-audit.control"; const FNV1A64_OFFSET: u64 = 0xcbf29ce484222325; const FNV1A64_PRIME: u64 = 0x100000001b3; @@ -71,6 +73,30 @@ pub(super) fn mjpeg_spool_audit_dir() -> Option { .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .map(PathBuf::from) + .or_else(mjpeg_spool_runtime_audit_dir) +} + +/// Resolve the runtime control-file audit directory. +/// +/// Inputs: `LESAVKA_UVC_FRAME_AUDIT_CONTROL_PATH` or the default control path. +/// Output: an audit directory, unless the file is absent or contains an off +/// value. Why: lab probes need to enable exact UVC-bound captures around one +/// run without restarting or making all installed calls write MJPEG dumps. +fn mjpeg_spool_runtime_audit_dir() -> Option { + let control_path = std::env::var("LESAVKA_UVC_FRAME_AUDIT_CONTROL_PATH") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(DEFAULT_UVC_FRAME_AUDIT_CONTROL_PATH)); + let value = fs::read_to_string(control_path).ok()?; + let trimmed = value.trim(); + if trimmed.is_empty() + || trimmed.eq_ignore_ascii_case("0") + || trimmed.eq_ignore_ascii_case("false") + || trimmed.eq_ignore_ascii_case("no") + || trimmed.eq_ignore_ascii_case("off") + { + return None; + } + Some(PathBuf::from(trimmed)) } /// Resolve the UVC-bound audit sampling interval. @@ -189,14 +215,28 @@ fn format_audit_record( let profile = timing.map(|value| value.profile).unwrap_or("unknown"); let source_pts_us = timing.and_then(|value| value.source_pts_us); let decoded_pts_us = timing.and_then(|value| value.decoded_pts_us); + let uvc_width = timing.and_then(|value| value.uvc_width.map(u64::from)); + let uvc_height = timing.and_then(|value| value.uvc_height.map(u64::from)); + let uvc_fps = timing.and_then(|value| value.uvc_fps.map(u64::from)); + let inspection = hevc_mjpeg_guard::inspect_mjpeg_frame(data); format!( - "{{\"schema\":\"lesavka.uvc-mjpeg-spool-audit.v1\",\"sequence\":{},\"slot\":{},\"profile\":\"{}\",\"bytes\":{},\"source_pts_us\":{},\"decoded_pts_us\":{},\"spool_unix_ns\":{},\"fnv1a64\":\"{}\",\"file\":\"{}\"}}\n", + "{{\"schema\":\"lesavka.uvc-mjpeg-spool-audit.v1\",\"sequence\":{},\"slot\":{},\"profile\":\"{}\",\"bytes\":{},\"jpeg_complete\":{},\"frame_width\":{},\"frame_height\":{},\"entropy_bytes\":{},\"entropy_distinct_bytes\":{},\"entropy_dominant_pct\":{},\"entropy_max_run\":{},\"source_pts_us\":{},\"decoded_pts_us\":{},\"uvc_width\":{},\"uvc_height\":{},\"uvc_fps\":{},\"spool_unix_ns\":{},\"fnv1a64\":\"{}\",\"file\":\"{}\"}}\n", sequence, slot, json_escape(profile), data.len(), + inspection.complete, + json_number_or_null(inspection.width.map(u64::from)), + json_number_or_null(inspection.height.map(u64::from)), + inspection.entropy_bytes, + inspection.entropy_distinct_bytes, + inspection.entropy_dominant_pct, + inspection.entropy_max_run, json_number_or_null(source_pts_us), json_number_or_null(decoded_pts_us), + json_number_or_null(uvc_width), + json_number_or_null(uvc_height), + json_number_or_null(uvc_fps), unix_now_ns(), fnv1a64_hex(data), json_escape(frame_file) @@ -218,15 +258,26 @@ fn format_rejected_audit_record( ) -> String { let source_pts_us = timing.source_pts_us; let decoded_pts_us = timing.decoded_pts_us; + let inspection = hevc_mjpeg_guard::inspect_mjpeg_frame(data); format!( - "{{\"schema\":\"lesavka.uvc-mjpeg-spool-audit.v1\",\"sequence\":{},\"slot\":{},\"profile\":\"{}\",\"rejected\":true,\"reason\":\"{}\",\"bytes\":{},\"source_pts_us\":{},\"decoded_pts_us\":{},\"spool_unix_ns\":{},\"fnv1a64\":\"{}\",\"file\":\"{}\"}}\n", + "{{\"schema\":\"lesavka.uvc-mjpeg-spool-audit.v1\",\"sequence\":{},\"slot\":{},\"profile\":\"{}\",\"rejected\":true,\"reason\":\"{}\",\"bytes\":{},\"jpeg_complete\":{},\"frame_width\":{},\"frame_height\":{},\"entropy_bytes\":{},\"entropy_distinct_bytes\":{},\"entropy_dominant_pct\":{},\"entropy_max_run\":{},\"source_pts_us\":{},\"decoded_pts_us\":{},\"uvc_width\":{},\"uvc_height\":{},\"uvc_fps\":{},\"spool_unix_ns\":{},\"fnv1a64\":\"{}\",\"file\":\"{}\"}}\n", sequence, slot, json_escape(timing.profile), json_escape(reason), data.len(), + inspection.complete, + json_number_or_null(inspection.width.map(u64::from)), + json_number_or_null(inspection.height.map(u64::from)), + inspection.entropy_bytes, + inspection.entropy_distinct_bytes, + inspection.entropy_dominant_pct, + inspection.entropy_max_run, json_number_or_null(source_pts_us), json_number_or_null(decoded_pts_us), + json_number_or_null(timing.uvc_width.map(u64::from)), + json_number_or_null(timing.uvc_height.map(u64::from)), + json_number_or_null(timing.uvc_fps.map(u64::from)), unix_now_ns(), fnv1a64_hex(data), json_escape(frame_file) diff --git a/server/src/video_sinks/mjpeg_spool/tests.rs b/server/src/video_sinks/mjpeg_spool/tests.rs index 1a4a9c5..b00213d 100644 --- a/server/src/video_sinks/mjpeg_spool/tests.rs +++ b/server/src/video_sinks/mjpeg_spool/tests.rs @@ -184,6 +184,48 @@ fn mjpeg_spool_audit_knobs_are_opt_in_and_bounded() { assert!(super::audit::should_sample_spool_audit_frame(4, 3)); } +/// Verifies runtime audit control can enable boundary evidence without a +/// server restart. +/// +/// Input: a temporary control file containing an audit directory. Output: +/// audit dir resolution follows the control file when the env dir is unset. +/// Why: hardware/lab probes need to turn on exact UVC-bound frame evidence +/// around one run without making every installed call write MJPEG dumps. +#[test] +fn mjpeg_spool_audit_can_be_enabled_by_runtime_control_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let control = dir.path().join("uvc-audit.control"); + let audit = dir.path().join("audit"); + std::fs::write(&control, format!("{}\n", audit.display())).expect("write control"); + + temp_env::with_vars( + [ + ("LESAVKA_UVC_FRAME_AUDIT_DIR", None::<&str>), + ( + "LESAVKA_UVC_FRAME_AUDIT_CONTROL_PATH", + Some(control.to_str().expect("utf8 path")), + ), + ], + || { + assert_eq!(super::audit::mjpeg_spool_audit_dir(), Some(audit)); + }, + ); + + std::fs::write(&control, "off\n").expect("disable control"); + temp_env::with_vars( + [ + ("LESAVKA_UVC_FRAME_AUDIT_DIR", None::<&str>), + ( + "LESAVKA_UVC_FRAME_AUDIT_CONTROL_PATH", + Some(control.to_str().expect("utf8 path")), + ), + ], + || { + assert_eq!(super::audit::mjpeg_spool_audit_dir(), None); + }, + ); +} + /// Verifies metadata records carry enough timing evidence for RCT analysis. /// /// Input: HEVC-decoded spool timing. Output: JSON fields for source and @@ -350,7 +392,10 @@ fn spool_mjpeg_frame_writes_enabled_boundary_audit() { super::spool_mjpeg_frame_with_timing( &frame, b"audit-jpeg", - Some(super::MjpegSpoolTiming::mjpeg_passthrough(222)), + Some( + super::MjpegSpoolTiming::mjpeg_passthrough(222) + .with_uvc_mode(1280, 720, 30), + ), ) .expect("spool audited frame"); super::spool_mjpeg_frame_with_timing( @@ -377,6 +422,11 @@ fn spool_mjpeg_frame_writes_enabled_boundary_audit() { assert!(index.contains("\"schema\":\"lesavka.uvc-mjpeg-spool-audit.v1\"")); assert!(index.contains("\"profile\":\"mjpeg-passthrough\"")); assert!(index.contains("\"source_pts_us\":222")); + assert!(index.contains("\"jpeg_complete\":false")); + assert!(index.contains("\"frame_width\":null")); + assert!(index.contains("\"uvc_width\":1280")); + assert!(index.contains("\"uvc_height\":720")); + assert!(index.contains("\"uvc_fps\":30")); assert!(index.contains("\"file\":\"frame-")); } diff --git a/server/src/video_sinks/webcam_sink.rs b/server/src/video_sinks/webcam_sink.rs index 540decf..c24a283 100644 --- a/server/src/video_sinks/webcam_sink.rs +++ b/server/src/video_sinks/webcam_sink.rs @@ -46,6 +46,7 @@ pub struct WebcamSink { direct_mjpeg_max_bytes: usize, uvc_width: u16, uvc_height: u16, + uvc_fps: u32, direct_mjpeg_profile_mismatch_seen: AtomicBool, unexpected_mjpeg_in_hevc_seen: AtomicBool, last_decoded_mjpeg_bytes: AtomicU64, diff --git a/server/src/video_sinks/webcam_sink/constructor.rs b/server/src/video_sinks/webcam_sink/constructor.rs index 774241d..db4c96a 100644 --- a/server/src/video_sinks/webcam_sink/constructor.rs +++ b/server/src/video_sinks/webcam_sink/constructor.rs @@ -61,6 +61,7 @@ impl WebcamSink { direct_mjpeg_max_bytes: mjpeg_spool_frame_max_bytes(cfg.fps), uvc_width: cfg.width.min(u32::from(u16::MAX)) as u16, uvc_height: cfg.height.min(u32::from(u16::MAX)) as u16, + uvc_fps: cfg.fps.max(1), direct_mjpeg_profile_mismatch_seen: AtomicBool::new(false), unexpected_mjpeg_in_hevc_seen: AtomicBool::new(false), last_decoded_mjpeg_bytes: AtomicU64::new(0), @@ -368,6 +369,7 @@ impl WebcamSink { direct_mjpeg_max_bytes: mjpeg_spool_frame_max_bytes(cfg.fps), uvc_width: cfg.width.min(u32::from(u16::MAX)) as u16, uvc_height: cfg.height.min(u32::from(u16::MAX)) as u16, + uvc_fps: cfg.fps.max(1), direct_mjpeg_profile_mismatch_seen: AtomicBool::new(false), unexpected_mjpeg_in_hevc_seen: AtomicBool::new(false), last_decoded_mjpeg_bytes: AtomicU64::new(0), diff --git a/server/src/video_sinks/webcam_sink/frame_handoff.rs b/server/src/video_sinks/webcam_sink/frame_handoff.rs index c15c85a..94a122c 100644 --- a/server/src/video_sinks/webcam_sink/frame_handoff.rs +++ b/server/src/video_sinks/webcam_sink/frame_handoff.rs @@ -107,14 +107,16 @@ impl WebcamSink { { self.decoded_mjpeg_miss_count.store(0, Ordering::Relaxed); let decoded_pts_us = buffer.pts().map(|pts| pts.nseconds() / 1_000); - let timing = MjpegSpoolTiming::hevc_decoded_mjpeg(pkt.pts, decoded_pts_us); + let timing = MjpegSpoolTiming::hevc_decoded_mjpeg(pkt.pts, decoded_pts_us) + .with_uvc_mode(self.uvc_width, self.uvc_height, self.uvc_fps); let previous_bytes = self.last_decoded_mjpeg_bytes.load(Ordering::Relaxed); let decoded_bytes = map.as_slice().len(); if let Some(reason) = hevc_mjpeg_guard::decoded_mjpeg_reject_reason(previous_bytes, map.as_slice()) { let rejected_timing = - MjpegSpoolTiming::rejected_hevc_decoded_mjpeg(pkt.pts, decoded_pts_us); + MjpegSpoolTiming::rejected_hevc_decoded_mjpeg(pkt.pts, decoded_pts_us) + .with_uvc_mode(self.uvc_width, self.uvc_height, self.uvc_fps); let reason_text = format!("{reason:?}"); super::mjpeg_spool::audit_rejected_mjpeg_frame( map.as_slice(), @@ -218,7 +220,8 @@ impl WebcamSink { #[cfg(not(coverage))] fn spool_passthrough_direct_mjpeg_frame(&self, path: &Path, pkt: &VideoPacket) { - let timing = MjpegSpoolTiming::mjpeg_passthrough(pkt.pts); + let timing = MjpegSpoolTiming::mjpeg_passthrough(pkt.pts) + .with_uvc_mode(self.uvc_width, self.uvc_height, self.uvc_fps); if let Err(err) = spool_mjpeg_frame_with_timing(path, &pkt.data, Some(timing)) { warn!(target:"lesavka_server::video", %err, "failed to spool MJPEG frame for UVC helper"); } else { @@ -347,7 +350,8 @@ impl WebcamSink { return; } - let timing = MjpegSpoolTiming::mjpeg_normalized(pkt.pts); + let timing = MjpegSpoolTiming::mjpeg_normalized(pkt.pts) + .with_uvc_mode(self.uvc_width, self.uvc_height, self.uvc_fps); if let Err(err) = spool_mjpeg_frame_with_timing(path, normalized, Some(timing)) { warn!(target:"lesavka_server::video", %err, "failed to spool normalized direct MJPEG frame for UVC helper"); } else { diff --git a/tests/contract/client/app/client_app_support_contract.rs b/tests/contract/client/app/client_app_support_contract.rs new file mode 100644 index 0000000..06cb0be --- /dev/null +++ b/tests/contract/client/app/client_app_support_contract.rs @@ -0,0 +1,173 @@ +// Integration coverage for client app support helpers. +// +// Scope: exercise server-address, camera-capability, and retry-delay helpers +// against deterministic local types. +// Targets: `client/src/app_support.rs`. +// Why: these startup decisions shape live camera codec negotiation and should +// stay covered without adding source-file LOC to the hygiene ratchet. + +mod handshake { + #[allow(dead_code)] + #[derive(Clone, Debug)] + pub struct PeerCaps { + pub camera: bool, + pub microphone: bool, + pub bundled_webcam_media: bool, + pub server_version: Option, + pub camera_output: Option, + pub camera_codec: Option, + pub camera_width: Option, + pub camera_height: Option, + pub camera_fps: Option, + pub eye_width: Option, + pub eye_height: Option, + pub eye_fps: Option, + } +} + +mod input { + pub mod camera { + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + pub enum CameraCodec { + Mjpeg, + H264, + Hevc, + } + + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + pub struct CameraConfig { + pub codec: CameraCodec, + pub width: u32, + pub height: u32, + pub fps: u32, + } + } +} + +#[path = "../../../../client/src/app_support.rs"] +mod app_support; + +use app_support::{ + DEFAULT_SERVER_ADDR, camera_config_from_caps, next_delay, resolve_server_addr, + sanitize_video_queue, +}; +use handshake::PeerCaps; +use input::camera::CameraCodec; +use serial_test::serial; +use std::time::Duration; + +fn peer_caps(codec: &str) -> PeerCaps { + PeerCaps { + camera: true, + microphone: false, + bundled_webcam_media: false, + server_version: None, + camera_output: Some(String::from("uvc")), + camera_codec: Some(codec.to_string()), + camera_width: Some(1280), + camera_height: Some(720), + camera_fps: Some(25), + eye_width: None, + eye_height: None, + eye_fps: None, + } +} + +#[test] +fn resolve_server_addr_prefers_cli_then_env_then_default() { + assert_eq!( + resolve_server_addr(&[String::from("http://cli:1")], Some("http://env:2")), + "http://cli:1" + ); + assert_eq!( + resolve_server_addr( + &[ + String::from("--no-launcher"), + String::from("--server"), + String::from("http://cli-flag:3"), + ], + Some("http://env:2"), + ), + "http://cli-flag:3" + ); + assert_eq!( + resolve_server_addr(&[String::from("--launcher")], Some("http://env:2")), + "http://env:2" + ); + assert_eq!( + resolve_server_addr(&[], Some("http://env:2")), + "http://env:2" + ); + assert_eq!(resolve_server_addr(&[], None), DEFAULT_SERVER_ADDR); +} + +#[test] +#[serial] +fn camera_config_from_caps_requires_complete_profile() { + temp_env::with_var("LESAVKA_CAM_CODEC", None::<&str>, || { + let mut caps = peer_caps("mjpeg"); + let config = camera_config_from_caps(&caps).expect("complete caps should map"); + assert!(matches!(config.codec, CameraCodec::Mjpeg)); + assert_eq!(config.width, 1280); + + caps.camera_codec = Some(String::from("h265")); + let config = camera_config_from_caps(&caps).expect("h265 alias should map"); + assert!(matches!(config.codec, CameraCodec::Hevc)); + + caps.camera_codec = Some(String::from("vp9")); + assert!(camera_config_from_caps(&caps).is_none()); + }); +} + +#[test] +#[serial] +fn camera_config_from_caps_uses_negotiated_codec_over_launcher_default() { + temp_env::with_var("LESAVKA_CAM_CODEC", Some("mjpeg"), || { + let config = camera_config_from_caps(&peer_caps("hevc")).expect("caps codec should map"); + assert!(matches!(config.codec, CameraCodec::Hevc)); + }); +} + +#[test] +#[serial] +fn camera_config_from_caps_allows_explicit_forced_codec_override() { + temp_env::with_vars( + [ + ("LESAVKA_CAM_CODEC", Some("mjpeg")), + ("LESAVKA_CAM_CODEC_FORCE", Some("1")), + ], + || { + let config = camera_config_from_caps(&peer_caps("hevc")).expect("forced override"); + assert!(matches!(config.codec, CameraCodec::Mjpeg)); + }, + ); +} + +#[test] +#[serial] +fn camera_config_force_flag_ignores_unknown_override() { + temp_env::with_vars( + [ + ("LESAVKA_CAM_CODEC", Some("vp9")), + ("LESAVKA_CAM_CODEC_FORCE", Some("1")), + ], + || { + let config = camera_config_from_caps(&peer_caps("hevc")).expect("fallback codec"); + assert!(matches!(config.codec, CameraCodec::Hevc)); + }, + ); +} + +#[test] +fn sanitize_video_queue_enforces_floor() { + assert_eq!(sanitize_video_queue(None), 8); + assert_eq!(sanitize_video_queue(Some(1)), 4); + assert_eq!(sanitize_video_queue(Some(32)), 32); +} + +#[test] +fn next_delay_doubles_until_capped() { + assert_eq!(next_delay(Duration::from_secs(1)), Duration::from_secs(2)); + assert_eq!(next_delay(Duration::from_secs(15)), Duration::from_secs(30)); + assert_eq!(next_delay(Duration::from_secs(31)), Duration::from_secs(30)); +} diff --git a/tests/integration/server/video_sinks/hevc_mjpeg_spool_integration.rs b/tests/integration/server/video_sinks/hevc_mjpeg_spool_integration.rs index 97b440d..ab0f852 100644 --- a/tests/integration/server/video_sinks/hevc_mjpeg_spool_integration.rs +++ b/tests/integration/server/video_sinks/hevc_mjpeg_spool_integration.rs @@ -10,42 +10,62 @@ use std::fs; use serial_test::serial; -#[allow(dead_code, clippy::items_after_test_module)] -mod spool { - include!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/server/src/video_sinks/mjpeg_spool.rs" - )); - - pub fn write_hevc( - path: &std::path::Path, - data: &[u8], - source_pts_us: u64, - decoded_pts_us: Option, - ) -> anyhow::Result<()> { - spool_mjpeg_frame_with_timing( - path, - data, - Some(MjpegSpoolTiming::hevc_decoded_mjpeg( - source_pts_us, - decoded_pts_us, - )), - ) - } - - pub fn write_mjpeg( - path: &std::path::Path, - data: &[u8], - source_pts_us: u64, - ) -> anyhow::Result<()> { - spool_mjpeg_frame_with_timing( - path, - data, - Some(MjpegSpoolTiming::mjpeg_passthrough(source_pts_us)), - ) +mod video_support { + pub fn env_u32(name: &str, default: u32) -> u32 { + std::env::var(name) + .ok() + .and_then(|value| value.trim().parse().ok()) + .unwrap_or(default) } } +mod video_sinks { + #[allow(clippy::items_after_test_module)] + pub mod hevc_mjpeg_guard { + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/video_sinks/hevc_mjpeg_guard.rs" + )); + } + + #[allow(dead_code, clippy::items_after_test_module)] + pub mod spool { + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/video_sinks/mjpeg_spool.rs" + )); + + pub fn write_hevc( + path: &std::path::Path, + data: &[u8], + source_pts_us: u64, + decoded_pts_us: Option, + ) -> anyhow::Result<()> { + spool_mjpeg_frame_with_timing( + path, + data, + Some(MjpegSpoolTiming::hevc_decoded_mjpeg( + source_pts_us, + decoded_pts_us, + )), + ) + } + + pub fn write_mjpeg( + path: &std::path::Path, + data: &[u8], + source_pts_us: u64, + ) -> anyhow::Result<()> { + spool_mjpeg_frame_with_timing( + path, + data, + Some(MjpegSpoolTiming::mjpeg_passthrough(source_pts_us)), + ) + } + } +} +use video_sinks::spool; + #[test] #[serial] fn hevc_decoded_mjpeg_spool_writes_frame_and_timing_metadata() { diff --git a/tests/manual/server/rct/synthetic_rct_uvc_probe_manual_contract.rs b/tests/manual/server/rct/synthetic_rct_uvc_probe_manual_contract.rs index 74639c5..abac4a5 100644 --- a/tests/manual/server/rct/synthetic_rct_uvc_probe_manual_contract.rs +++ b/tests/manual/server/rct/synthetic_rct_uvc_probe_manual_contract.rs @@ -52,6 +52,11 @@ fn synthetic_probe_keeps_bundled_network_ingress_and_rct_comparison_markers() { "--media-control-path", "--jpeg-quality", "--inject-max-frame-bytes", + "--server-uvc-audit", + "--server-uvc-audit-host", + "--server-uvc-audit-control-path", + "--server-uvc-audit-dir", + "--server-uvc-audit-sample-frames", "--stream-analyze", "--sequence-window", "--mix-mae-threshold", @@ -94,6 +99,20 @@ fn synthetic_probe_keeps_bundled_network_ingress_and_rct_comparison_markers() { "lower_dominant_sequence", "max_mixed_band_count", "max_sequence_boundary_count", + "lesavka.server-uvc-boundary-summary.v1", + "server_uvc_boundary", + "server_uvc_audit_artifacts", + "server_boundary_missing", + "server_boundary_frame_mode_mismatch", + "server_boundary_uvc_mode_mismatch", + "server_boundary_visual_corruption", + "downstream_uvc_or_browser_corruption", + "no_visual_corruption_observed", + "spool-audit.jsonl", + "matching_frame_records", + "matching_uvc_mode_records", + "decoded_sample_count", + "marker_sample_count", "diagnosis", "encoded_oversize_frames", "sent_frames",