From 75df8c94e116c2ea243229b51c9934c47f15acf7 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Wed, 12 Aug 2026 07:53:40 -0300 Subject: [PATCH] fix(uvc): enforce negotiated payload integrity --- Cargo.lock | 6 +- Cargo.toml | 4 + README.md | 3 + client/Cargo.toml | 2 +- .../upstream_sync_formatting.rs | 55 ++ client/src/bin/tests/lesavka_relayctl.rs | 24 + .../selection_models/sync_and_state_status.rs | 21 + client/src/launcher/tests/ui_runtime.rs | 44 ++ .../src/launcher/ui_runtime/status_details.rs | 15 + .../src/launcher/ui_runtime/status_refresh.rs | 14 +- common/Cargo.toml | 2 +- common/proto/lesavka.proto | 24 + docs/architecture-and-media-readiness.md | 445 ++++++++++++++ docs/operational-env.md | 11 +- scripts/daemon/lesavka-core.sh | 48 +- scripts/daemon/lesavka-uvc-kernel-watch.py | 162 +++++ scripts/install/server.sh | 36 +- scripts/manual/analyze_marked_capture.py | 8 + scripts/manual/analyze_uvc_mjpeg_frame.py | 10 +- scripts/manual/analyze_uvc_mjpeg_integrity.py | 402 +++++++++++++ scripts/manual/run_synthetic_rct_uvc_probe.py | 198 +++++- server/Cargo.toml | 2 +- server/src/bin/lesavka-synthetic-uplink.rs | 5 +- server/src/bin/lesavka-uvc.real.inc | 401 +++++++++++-- .../bin/lesavka_synthetic_uplink/support.rs | 33 + .../bin/lesavka_synthetic_uplink/tests/mod.rs | 21 + .../src/bin/lesavka_uvc/control_requests.rs | 1 + server/src/bin/lesavka_uvc/coverage_config.rs | 20 +- .../bin/lesavka_uvc/coverage_frame_budget.rs | 54 +- server/src/bin/lesavka_uvc/coverage_model.rs | 38 +- server/src/bin/lesavka_uvc/coverage_stats.rs | 47 +- server/src/bin/tests/lesavka_uvc.rs | 52 +- server/src/lib.rs | 1 + server/src/main.rs | 2 +- server/src/main/rpc_helpers.rs | 202 +++++++ server/src/uvc_contract.rs | 566 ++++++++++++++++++ server/src/video_sinks/mjpeg_spool.rs | 104 ++-- server/src/video_sinks/mjpeg_spool/tests.rs | 11 +- .../video_sinks/webcam_sink/frame_handoff.rs | 6 +- .../daemon/server_core_script_contract.rs | 5 +- .../install/server_install_script_contract.rs | 22 +- .../server/uvc/server_uvc_binary_contract.rs | 37 +- .../server_video_sinks_include_contract.rs | 4 + .../hevc_mjpeg_spool_integration.rs | 4 + ...synthetic_rct_uvc_probe_manual_contract.rs | 8 + .../uvc_mjpeg_integrity_manual_contract.rs | 82 +++ 46 files changed, 3049 insertions(+), 213 deletions(-) create mode 100644 docs/architecture-and-media-readiness.md create mode 100755 scripts/daemon/lesavka-uvc-kernel-watch.py create mode 100755 scripts/manual/analyze_marked_capture.py create mode 100755 scripts/manual/analyze_uvc_mjpeg_integrity.py create mode 100644 server/src/uvc_contract.rs create mode 100644 tests/manual/server/rct/uvc_mjpeg_integrity_manual_contract.rs diff --git a/Cargo.lock b/Cargo.lock index 92e349f..9460fdc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1658,7 +1658,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.27.6" +version = "0.27.7" dependencies = [ "anyhow", "async-stream", @@ -1692,7 +1692,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.27.6" +version = "0.27.7" dependencies = [ "anyhow", "base64", @@ -1704,7 +1704,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.27.6" +version = "0.27.7" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index dbb54ac..7a8690e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -485,6 +485,10 @@ path = "tests/manual/server/rct/rct_uvc_artifact_probe_manual_contract.rs" name = "synthetic_rct_uvc_probe_manual_contract" path = "tests/manual/server/rct/synthetic_rct_uvc_probe_manual_contract.rs" +[[test]] +name = "uvc_mjpeg_integrity_manual_contract" +path = "tests/manual/server/rct/uvc_mjpeg_integrity_manual_contract.rs" + [[test]] name = "google_meet_observer_manual_contract" path = "tests/manual/google_meet/google_meet_observer_manual_contract.rs" diff --git a/README.md b/README.md index 32e6627..9b53f77 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,9 @@ Useful entry points: ## Operational Notes +- The full topology, media paths, current incident evidence, and remaining + completion gates are documented in + [`docs/architecture-and-media-readiness.md`](docs/architecture-and-media-readiness.md). - Runtime and test environment variables are indexed in `docs/operational-env.md`. - Gate criteria and evidence paths are documented in `docs/quality-gate.md`. - Manual hardware checks belong in clearly marked manual scripts, not hidden in CI-only assumptions. diff --git a/client/Cargo.toml b/client/Cargo.toml index 216aec3..6b425d8 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.27.6" +version = "0.27.7" edition = "2024" [dependencies] diff --git a/client/src/bin/lesavka_relayctl/upstream_sync_formatting.rs b/client/src/bin/lesavka_relayctl/upstream_sync_formatting.rs index b448c46..584fd06 100644 --- a/client/src/bin/lesavka_relayctl/upstream_sync_formatting.rs +++ b/client/src/bin/lesavka_relayctl/upstream_sync_formatting.rs @@ -185,5 +185,60 @@ fn print_upstream_sync(state: lesavka_common::lesavka::UpstreamSyncState) { "planner_sink_handoff_window_samples={}", state.sink_handoff_window_samples ); + println!("uvc_contract_status={}", state.uvc_contract_status); + println!("uvc_transport={}", state.uvc_transport); + println!( + "uvc_enforced_frame_cap_bytes={}", + state + .uvc_enforced_frame_cap_bytes + .map(|value| value.to_string()) + .unwrap_or_else(|| "unavailable".to_string()) + ); + println!( + "uvc_advertised_frame_bytes={}", + state + .uvc_advertised_frame_bytes + .map(|value| value.to_string()) + .unwrap_or_else(|| "unavailable".to_string()) + ); + println!( + "uvc_committed_frame_bytes={}", + state + .uvc_committed_frame_bytes + .map(|value| value.to_string()) + .unwrap_or_else(|| "unavailable".to_string()) + ); + println!( + "uvc_committed_payload_bytes={}", + state + .uvc_committed_payload_bytes + .map(|value| value.to_string()) + .unwrap_or_else(|| "unavailable".to_string()) + ); + println!("uvc_contract_divergent={}", state.uvc_contract_divergent); + println!( + "uvc_contract_missing_reads={}", + state.uvc_contract_missing_reads + ); + println!( + "uvc_contract_divergent_reads={}", + state.uvc_contract_divergent_reads + ); + println!("uvc_contract_detail={}", state.uvc_contract_detail); + println!("uvc_integrity_status={}", state.uvc_integrity_status); + println!("uvc_integrity_detail={}", state.uvc_integrity_detail); + println!("uvc_rejected_oversize={}", state.uvc_rejected_oversize); + println!("uvc_rejected_invalid={}", state.uvc_rejected_invalid); + println!("uvc_fallback_idle={}", state.uvc_fallback_idle); + println!("uvc_held_last_good={}", state.uvc_held_last_good); + println!("uvc_stale_replay={}", state.uvc_stale_replay); + println!("uvc_read_errors={}", state.uvc_read_errors); + println!( + "uvc_strict_validation_failures={}", + state.uvc_strict_validation_failures + ); + println!("uvc_dqbuf_errors={}", state.uvc_dqbuf_errors); + println!("uvc_qbuf_errors={}", state.uvc_qbuf_errors); + println!("uvc_kernel_errors={}", state.uvc_kernel_errors); println!("planner_detail={}", state.last_reason); } diff --git a/client/src/bin/tests/lesavka_relayctl.rs b/client/src/bin/tests/lesavka_relayctl.rs index 8b4cdf7..e7df82c 100644 --- a/client/src/bin/tests/lesavka_relayctl.rs +++ b/client/src/bin/tests/lesavka_relayctl.rs @@ -321,6 +321,30 @@ fn print_upstream_sync_accepts_complete_and_pending_payloads() { microphone_sink_late_p95_ms: Some(18.5), client_timing_window_samples: 19, sink_handoff_window_samples: 20, + uvc_contract_status: "ready".to_string(), + uvc_transport: "isochronous".to_string(), + uvc_enforced_frame_cap_bytes: Some(150_000), + uvc_advertised_frame_bytes: Some(150_000), + uvc_committed_frame_bytes: Some(150_000), + uvc_committed_payload_bytes: Some(1024), + uvc_contract_divergent: false, + uvc_contract_detail: "live contract agrees".to_string(), + uvc_contract_generated_unix_ms: Some(1_777_777_777_000), + uvc_contract_missing_reads: 0, + uvc_contract_divergent_reads: 0, + uvc_integrity_status: "ready".to_string(), + uvc_integrity_detail: "no helper or kernel faults".to_string(), + uvc_rejected_oversize: 0, + uvc_rejected_invalid: 0, + uvc_fallback_idle: 0, + uvc_held_last_good: 0, + uvc_strict_validation_failures: 0, + uvc_dqbuf_errors: 0, + uvc_qbuf_errors: 0, + uvc_kernel_errors: 0, + uvc_integrity_generated_unix_ms: Some(1_777_777_777_000), + uvc_stale_replay: 0, + uvc_read_errors: 0, }); super::print_upstream_sync(UpstreamSyncState { diff --git a/client/src/launcher/state/selection_models/sync_and_state_status.rs b/client/src/launcher/state/selection_models/sync_and_state_status.rs index a7b619f..95c54c7 100644 --- a/client/src/launcher/state/selection_models/sync_and_state_status.rs +++ b/client/src/launcher/state/selection_models/sync_and_state_status.rs @@ -85,6 +85,13 @@ pub struct UpstreamSyncStatus { pub freshness_reanchors: u64, pub startup_timeouts: u64, pub video_freezes: u64, + pub uvc_contract_status: String, + pub uvc_transport: String, + pub uvc_enforced_frame_cap_bytes: Option, + pub uvc_contract_divergent: bool, + pub uvc_contract_detail: String, + pub uvc_integrity_status: String, + pub uvc_integrity_detail: String, pub detail: String, } @@ -109,6 +116,13 @@ impl UpstreamSyncStatus { freshness_reanchors: reply.freshness_reanchors, startup_timeouts: reply.startup_timeouts, video_freezes: reply.video_freezes, + uvc_contract_status: reply.uvc_contract_status, + uvc_transport: reply.uvc_transport, + uvc_enforced_frame_cap_bytes: reply.uvc_enforced_frame_cap_bytes, + uvc_contract_divergent: reply.uvc_contract_divergent, + uvc_contract_detail: reply.uvc_contract_detail, + uvc_integrity_status: reply.uvc_integrity_status, + uvc_integrity_detail: reply.uvc_integrity_detail, detail: reply.last_reason, } } @@ -146,6 +160,13 @@ impl Default for UpstreamSyncStatus { freshness_reanchors: 0, startup_timeouts: 0, video_freezes: 0, + uvc_contract_status: "unavailable".to_string(), + uvc_transport: "unknown".to_string(), + uvc_enforced_frame_cap_bytes: None, + uvc_contract_divergent: true, + uvc_contract_detail: "UVC payload contract unavailable".to_string(), + uvc_integrity_status: "unavailable".to_string(), + uvc_integrity_detail: "UVC helper integrity telemetry unavailable".to_string(), detail: "upstream sync planner unavailable".to_string(), } } diff --git a/client/src/launcher/tests/ui_runtime.rs b/client/src/launcher/tests/ui_runtime.rs index 37754df..c666f2b 100644 --- a/client/src/launcher/tests/ui_runtime.rs +++ b/client/src/launcher/tests/ui_runtime.rs @@ -906,6 +906,50 @@ fn uvc_chip_degrades_when_live_camera_frames_are_not_flowing() { ); } +#[test] +fn uvc_chip_uses_helper_integrity_and_payload_contract_evidence() { + let mut state = LauncherState::new(); + state.set_server_available(true); + state.set_server_media_caps( + Some(true), + Some(true), + Some("uvc".to_string()), + Some("mjpeg".to_string()), + ); + state.upstream_sync.available = true; + state.upstream_sync.uvc_contract_status = "ready".to_string(); + state.upstream_sync.uvc_contract_divergent = false; + state.upstream_sync.uvc_integrity_status = "ready".to_string(); + assert_eq!( + recovery_uvc_health(&state, false, None), + (StatusLightState::Live, "MJPEG".to_string()) + ); + + state.upstream_sync.uvc_integrity_status = "holding".to_string(); + assert_eq!( + recovery_uvc_health(&state, false, None), + (StatusLightState::Caution, "Holding".to_string()) + ); + state.upstream_sync.uvc_integrity_status = "fault".to_string(); + assert_eq!( + recovery_uvc_health(&state, false, None), + (StatusLightState::Warning, "Integrity fault".to_string()) + ); + + state.upstream_sync.uvc_integrity_status = "ready".to_string(); + state.upstream_sync.uvc_contract_status = "unverified".to_string(); + assert_eq!( + recovery_uvc_health(&state, false, None), + (StatusLightState::Caution, "Unverified".to_string()) + ); + state.upstream_sync.uvc_contract_status = "divergent".to_string(); + state.upstream_sync.uvc_contract_divergent = true; + assert_eq!( + recovery_uvc_health(&state, false, None), + (StatusLightState::Warning, "Contract fault".to_string()) + ); +} + #[test] fn capture_power_detail_mentions_detected_eyes_when_powered() { let power = CapturePowerStatus { diff --git a/client/src/launcher/ui_runtime/status_details.rs b/client/src/launcher/ui_runtime/status_details.rs index 9ed5747..5e78fc3 100644 --- a/client/src/launcher/ui_runtime/status_details.rs +++ b/client/src/launcher/ui_runtime/status_details.rs @@ -216,6 +216,21 @@ fn recovery_uvc_health( .unwrap_or(codec); return (StatusLightState::Caution, value); } + if state.upstream_sync.available { + match state.upstream_sync.uvc_integrity_status.as_str() { + "ready" => {} + "holding" => return (StatusLightState::Caution, "Holding".to_string()), + "fault" => return (StatusLightState::Warning, "Integrity fault".to_string()), + _ => return (StatusLightState::Caution, "Unverified".to_string()), + } + match state.upstream_sync.uvc_contract_status.as_str() { + "ready" if !state.upstream_sync.uvc_contract_divergent => {} + "" | "unavailable" | "unverified" => { + return (StatusLightState::Caution, "Unverified".to_string()); + } + _ => return (StatusLightState::Warning, "Contract fault".to_string()), + } + } if !relay_live { return (StatusLightState::Live, codec); } diff --git a/client/src/launcher/ui_runtime/status_refresh.rs b/client/src/launcher/ui_runtime/status_refresh.rs index a145692..ff01afa 100644 --- a/client/src/launcher/ui_runtime/status_refresh.rs +++ b/client/src/launcher/ui_runtime/status_refresh.rs @@ -139,8 +139,18 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi set_status_light(&widgets.summary.uvc_light, uvc_state); widgets.summary.uvc_value.set_text(&uvc_value); widgets.summary.uvc_value.set_tooltip_text(Some(&format!( - "Upstream webcam transport: {}. Server calibration is profile-specific.", - state.effective_webcam_transport().label() + "Upstream webcam transport: {}. UVC payload contract: {} via {}, cap {} bytes. {} Integrity: {}. {}", + state.effective_webcam_transport().label(), + state.upstream_sync.uvc_contract_status, + state.upstream_sync.uvc_transport, + state + .upstream_sync + .uvc_enforced_frame_cap_bytes + .map(|value| value.to_string()) + .unwrap_or_else(|| "unavailable".to_string()), + state.upstream_sync.uvc_contract_detail, + state.upstream_sync.uvc_integrity_status, + state.upstream_sync.uvc_integrity_detail, ))); let (lag_state, lag_value, lag_tooltip) = upstream_lag_health(&state.upstream_sync, relay_live); diff --git a/common/Cargo.toml b/common/Cargo.toml index 5d9ea5f..14d9539 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.27.6" +version = "0.27.7" edition = "2024" build = "build.rs" diff --git a/common/proto/lesavka.proto b/common/proto/lesavka.proto index c2aa24f..6c8e888 100644 --- a/common/proto/lesavka.proto +++ b/common/proto/lesavka.proto @@ -195,6 +195,30 @@ message UpstreamSyncState { optional float microphone_sink_late_p95_ms = 33; uint64 client_timing_window_samples = 34; uint64 sink_handoff_window_samples = 35; + string uvc_contract_status = 36; + string uvc_transport = 37; + optional uint32 uvc_enforced_frame_cap_bytes = 38; + optional uint32 uvc_advertised_frame_bytes = 39; + optional uint32 uvc_committed_frame_bytes = 40; + optional uint32 uvc_committed_payload_bytes = 41; + bool uvc_contract_divergent = 42; + string uvc_contract_detail = 43; + optional uint64 uvc_contract_generated_unix_ms = 44; + uint64 uvc_contract_missing_reads = 45; + uint64 uvc_contract_divergent_reads = 46; + string uvc_integrity_status = 47; + string uvc_integrity_detail = 48; + uint64 uvc_rejected_oversize = 49; + uint64 uvc_rejected_invalid = 50; + uint64 uvc_fallback_idle = 51; + uint64 uvc_held_last_good = 52; + uint64 uvc_strict_validation_failures = 53; + uint64 uvc_dqbuf_errors = 54; + uint64 uvc_qbuf_errors = 55; + uint64 uvc_kernel_errors = 56; + optional uint64 uvc_integrity_generated_unix_ms = 57; + uint64 uvc_stale_replay = 58; + uint64 uvc_read_errors = 59; } message HandshakeSet { diff --git a/docs/architecture-and-media-readiness.md b/docs/architecture-and-media-readiness.md new file mode 100644 index 0000000..d18c3d9 --- /dev/null +++ b/docs/architecture-and-media-readiness.md @@ -0,0 +1,445 @@ +# Lesavka Architecture And Media Readiness + +Status snapshot: 2026-08-12 + +This document explains what Lesavka is, how traffic moves through it, why the +webcam uplink has been unreliable, why the downstream eye feeds are currently +blank, and what evidence is still required before the system can be considered +finished. + +## 1. What Lesavka Is Supposed To Do + +Lesavka makes a physically attached lab computer usable from an operator +workstation as though its important peripherals were local. It is not merely a +screen-sharing application. It joins network transport to real USB, HDMI, +audio, camera, and input hardware. + +There are three roles: + +1. **Operator workstation (client)** - runs the Lesavka launcher and relay, + displays the two remote eye feeds, plays remote audio, captures the selected + webcam and microphone, and captures keyboard and mouse input. +2. **Theia / `titan-jh` (server)** - terminates the authenticated gRPC session, + reads the two HDMI capture cards and remote audio device, and presents the + operator's camera, microphone, keyboard, and mouse to the controlled host as + physical devices. +3. **Remote controlled host (RCT)** - sees Theia as USB HID, UVC webcam, UAC + microphone/speaker, and display-related hardware. Applications such as a + browser or Google Meet should be able to use those devices without knowing + that the operator is elsewhere. + +The intended result is: + +- two live, low-latency remote display feeds at the operator workstation; +- remote speaker audio at the operator workstation; +- keyboard and mouse control of the RCT; +- the operator's selected webcam and microphone appearing on the RCT; +- device/profile controls in the launcher remaining authoritative; and +- bounded latency, explicit health information, and recoverable failures. + +## 2. Direction Names + +The direction names are from the operator workstation's point of view. + +### Downstream: Theia To The Operator + +Downstream carries the RCT's outputs back to the operator: + +```text +RCT display outputs + -> GC311 capture cards on Theia + -> /dev/lesavka_l_eye and /dev/lesavka_r_eye + -> CaptureVideo gRPC streams + -> client H.264 decoder + -> launcher previews / breakout windows + +RCT USB speaker output + -> Theia UAC capture + -> CaptureAudio gRPC stream + -> client audio sink +``` + +### Upstream: The Operator To Theia And The RCT + +Upstream carries the operator's inputs toward the RCT: + +```text +operator webcam + microphone + -> one client capture timeline + -> StreamWebcamMedia bundled gRPC stream + -> Theia freshness/sync planner + -> UVC webcam + UAC microphone presented to the RCT + +operator keyboard + mouse + -> streaming gRPC input RPCs + -> Theia HID gadget + -> RCT input +``` + +The upstream and downstream video systems are separate. A healthy webcam +uplink does not prove the two downstream eye feeds work, and healthy eye feeds +do not prove that webcam video reaches the RCT. + +## 3. Connection, Security, And Session Shape + +The client connects to Theia over TLS with a private CA and a client +certificate. The server exposes a handshake service plus the relay service. +The handshake reports versions and physical capabilities; the relay service +owns the live streams and control RPCs. + +Important RPCs are: + +- `Handshake/GetCapabilities` for version, profile, and feature discovery; +- `Relay/CaptureVideo` for each downstream eye; +- `Relay/CaptureAudio` for downstream speaker audio; +- `Relay/StreamKeyboard` and `Relay/StreamMouse` for input; +- `Relay/StreamWebcamMedia` for bundled upstream camera and microphone; +- `Relay/StreamMicrophone` for the explicit microphone-only path; +- capture-power, calibration, recovery, and diagnostics RPCs. + +The client starts long-running tasks for the requested media and input paths. +Queues are deliberately bounded. Live interaction is more important than +draining every packet that was once captured. + +## 4. How Downstream Video Works + +Theia expects two stable udev-created device names: + +- `/dev/lesavka_l_eye` +- `/dev/lesavka_r_eye` + +The server installer discovers the two GC311 capture devices, identifies them +by their physical udev path tags, and writes `/etc/udev/rules.d/85-gc311.rules` +so left and right do not change when `/dev/videoN` numbering changes. + +When a client opens `CaptureVideo`, the server: + +1. validates the requested eye and source; +2. acquires a capture-power lease; +3. waits a bounded time for the expected V4L2 device; +4. opens the capture card with GStreamer; +5. preserves the card's H.264 elementary stream instead of decoding and + re-encoding it on Theia; +6. publishes packets through a shared per-source hub; and +7. drops packets under pressure rather than building interactive latency. + +The client opens one RPC for each eye. It sends packets to a bounded display +queue. If that queue overflows, it drops predicted frames and waits for an IDR +frame before resuming, which prevents a corrupt decoder recovery sequence. +The installed workstation currently exposes `vah264dec`, backed by Intel's iHD +VA-API driver, and also has `avdec_h264` for explicit software diagnostics. + +## 5. How Bundled Upstream Webcam Media Works + +Webcam sessions use the v2 bundled path by default. The camera and microphone +are captured by one client-owned media pipeline and share the client's capture +clock. A bundle contains an optional video frame and the audio packets captured +beside it. + +The important rules are: + +- camera-enabled sessions imply microphone capture when UAC is available; +- camera audio is not flushed as an unrelated standalone stream; +- source and handoff queues keep fresh media rather than old media; +- client capture timestamps are the A/V sync truth; +- Theia maps that timeline to one new local playout epoch; +- only explicit UVC/UAC device-path offsets may move one output relative to the + other; and +- stale or internally inconsistent mixed bundles are dropped coherently. + +Theia's planner schedules audio and video from the shared timeline, then hands +approved packets to UAC and UVC sinks. The sinks should be simple consumers: +they must not accumulate private multi-second backlogs or create a second sync +model. + +Microphone-only operation remains a separate supported mode. The old split +camera/microphone webcam uplink exists only as a compatibility escape hatch. + +## 6. Why The Upstream Webcam Feed Has Been A Long-Running Problem + +The original upstream implementation sent camera and microphone through +independent streams. Each side could establish or retain its own timing anchor, +queue, recovery state, and output delay. That made several failure modes +possible: + +- a new session could inherit an old stream's playout epoch; +- one side could start while the other waited; +- stale audio or video could be drained to "catch up"; +- capture timestamps could be replaced by late enqueue or network time; +- independent freshness decisions could preserve smooth playback while losing + lip sync; and +- large static calibration values could hide architectural backlog. + +Those failures produced seconds-scale delay and A/V skew, including observed +browser-visible failures near eight to ten seconds. The bundled v2 redesign +removed the independent-stream timing contract, added latest/freshness-bounded +queues, reset timing state on session replacement, made audio-master/video- +follower behavior explicit, and added device-level sync probes. + +That redesign fixed the class of problem, but a newer sink handoff regression +reintroduced a different kind of video backlog. + +### The Latest Confirmed Upstream Failure + +Before the current patch, live diagnostics showed: + +- client camera and microphone capture was connected and streaming; +- client delivery age was roughly `40-79 ms`; +- Theia's microphone sink lateness p95 was about `1.4 ms`; but +- Theia's camera sink lateness p95 was about `7661.5 ms`. + +This proves the webcam, bundled network transport, and shared capture clock were +not the bottleneck. Video was becoming late inside Theia at the sink handoff. + +The immediate cause was the newly auto-enabled optional HDMI camera mirror. +The camera relay synchronously performed the equivalent of: + +```text +push frame to UVC +push the same frame to HDMI/KMS +``` + +If the KMS/HDMI mirror blocked, the task could not return to feed the next UVC +frame. A 32-frame FIFO handoff and a direct-MJPEG normalization pull that could +wait up to 50 ms amplified the stall into a deep video backlog. Audio continued +through its own sink handoff, so the result was missing or many-seconds-late +video rather than a transport disconnect. + +### The `0.27.6` Correction + +The correction is pushed to Gitea `master` at revision `d5b1eca`: + +- UVC remains the synchronous primary camera output; +- HDMI mirroring runs in an isolated worker with only one pending latest frame; +- a blocked mirror can replace/drop its own stale frame but cannot stall UVC; +- the v2 scheduled-video handoff is a latest-value watch channel rather than a + 32-frame FIFO; and +- direct MJPEG normalization is nonblocking by default (`0 ms` pull timeout). + +The client installer also now accepts a kernel module that is already loaded in +`/sys/module` even when `modinfo` cannot find metadata for the running kernel. +That installer fix is why the complete patch release is `0.27.6` rather than +`0.27.5`. + +At the start of the 0.27.7 integrity work, the operator workstation was running +`lesavka-client 0.27.6` and Theia was still running `lesavka-server 0.27.4` at +revision `7011b5c`. Release artifacts and live `lesavka-relayctl version` +output, rather than this historical incident snapshot, are authoritative for +the current deployment state. + +### The Post-Spool UVC Integrity Failure + +The seconds-late sink handoff was not the only upstream video defect. A +simultaneous boundary capture on 2026-07-19 proved that +`/run/lesavka-uvc-frame.mjpg` was clean while the RCT-side UVC capture contained +black frames, grey lower bands, multi-frame splices, and a 184-second freeze. +The corruption was therefore introduced after the spool, in the helper, +gadget, USB, or host-consumption leg. The preserved evidence is under +`artifacts/webm-tear-analysis/20260719-014411/boundary-spool-vs-uvc/`. + +The 0.27.6 code had five concrete contract defects that could create or hide +that failure: + +- bulk sizing was selected from the existence of configfs `streaming_bulk`, + even when its value was `0`; +- the gadget script swallowed a failed `streaming_bulk=1` write; +- explicit frame-size overrides could exceed the descriptor/wire cap; +- invalid, oversized, stale, or unreadable frames immediately replaced the + last good frame with black idle video; and +- core, spool, and helper sizing calculations could silently drift apart. + +### The `0.27.7` Integrity Contract + +Release 0.27.7 makes the helper-published live contract authoritative. The UVC +helper reads configfs values and the browser's actual UVC probe/commit, then +atomically publishes `/run/lesavka-uvc-contract.json`. Both helper and server +spool enforce the smallest of the requested cap, descriptor cap, committed cap, +transport budget, and hard safety maximum. Missing configfs is isochronous and +unverified, never implicitly bulk. `lesavka-core.sh` now verifies bulk writeback +and restores isochronous packet sizing if the requested mode does not stick. + +The output behavior is also explicit: + +- a transient invalid, oversized, stale, or unreadable spool update freezes the + last verified frame; +- black idle appears only after `LESAVKA_UVC_IDLE_AFTER_MS`, default `2000`; +- every thirtieth accepted frame receives a complete JPEG structure and APP4 + CRC check by default; and +- helper DQBUF/QBUF failures, `V4L2_BUF_FLAG_ERROR`, substitutions, strict + validation failures, and kernel UVC/UDC events are counted. + +Operational evidence is available without an RCT: + +- `/run/lesavka-uvc-contract.json` is the live negotiated payload contract; +- `/run/lesavka-uvc-video-stats.json` contains helper and kernel counters; +- `relayctl upstream-sync` prints the contract and integrity status; +- the launcher's UVC chip reports `Holding`, `Integrity fault`, `Contract + fault`, or `Unverified` from real server evidence; and +- `LESAVKA_UVC_HANDOFF_AUDIT=1` enables a bounded frame-by-frame JSONL ring at + `/run/lesavka-uvc-handoff.jsonl` for lab diagnosis. + +Marked synthetic frames now carry sequence/band blocks every 64 rows and an +APP4 sequence plus payload CRC. `scripts/manual/analyze_marked_capture.py` +classifies native MJPEG frames as intact, truncated, spliced, stale repeat, or +idle black and reports the first bad row or splice seam when recoverable. The +synthetic RCT probe's `--deep-capture` mode preserves native MJPEG, uvcvideo +debug logs, usbmon traffic, USB power state, and before/after device snapshots. + +These changes close the known software defects and observability gaps. They do +not by themselves prove the RCT-side USB path is clean. The 0.27.7 hardware A/B, +mode-matrix soak, browser probe, and Google Meet validation remain release +gates. A host-controller drop or host decoder bug can still occur after the +gadget has successfully queued a verified frame. The honest long-term answer is +to retain a small Linux verification host in the lab when possible; otherwise +the calibrated server telemetry is the available integrity contract. + +## 7. The Current Downstream Video Failure + +The current blank downstream feeds fail before transport or decoding. + +On 2026-08-12, direct mutually authenticated `CaptureVideo` probes returned: + +```text +eye-l device /dev/lesavka_l_eye was not ready within 5000 ms: +No such file or directory + +eye-r device /dev/lesavka_r_eye was not ready within 5000 ms: +No such file or directory +``` + +Both RPCs reached the live server and produced a precise host-side error. The +server acquired the normal capture path but neither expected V4L2 device became +available. No H.264 packets were sent to the workstation. The workstation's +hardware decoder is present and working, so changing the local renderer cannot +fix this incident. + +The capture-power control provides a second independent confirmation. Theia +reports the configured GPIO unit as `relay.service`, with state +`failed/failed`. A `ForceOn` control request momentarily changed the unit to +`active/running`, but it returned to `failed/failed` within twelve seconds. A +second attempt followed immediately by a `CaptureVideo` request still produced +no left-eye device during the five-second readiness window, and the power unit +again ended in `failed/failed`. This is not simply the normal auto-power idle +state: the unit cannot remain healthy long enough for either card to enumerate. + +Theia's SSH listener is also unhealthy. TCP reaches the real Theia SSH port +`2208` over the configured route, but every connection is reset before the SSH +banner/authentication exchange. At the same time: + +- Lesavka gRPC on `50051` remains available; +- node exporter on `9100` remains available; +- the host reports ample free memory, negligible load, and no socket/resource + exhaustion; and +- the NVMe filesystem and normal network interface remain visible. + +This combination points to stuck host service/GPIO/device state, not a dead +host or a client connectivity problem. A controlled Theia reboot is justified +because it can restore the SSH daemon, capture-power unit, and capture-card +enumeration in one bounded operation. It is not yet proof that the capture +hardware itself is healthy. If the devices remain absent after reboot, the next +checks are the `relay.service` failure reason, capture-card power, USB +enumeration, V4L2 nodes, udev path tags/rules, and physical card/cable state. + +## 8. Recovery And Deployment Sequence + +The safe completion sequence for this incident is: + +1. Perform a controlled reboot of Theia. Do not hard-reset the UVC/UAC gadget + separately unless later evidence specifically requires it. +2. Wait for SSH, gRPC, and node-exporter health. +3. Over SSH, inspect `lsusb`, `v4l2-ctl --list-devices`, `/dev/video*`, and the + two `/dev/lesavka_*_eye` symlinks. +4. Use `ssh theia`, whose alias targets `38.28.125.112:2208`. Port `2277` is a + jump route for other titan hosts and is not a Theia health probe. +5. Pull Gitea `master` into Theia's existing checkout without overwriting + unrelated local work. +6. Run `scripts/install/server.sh` as the trusted deployment path. Preserve the + already-attached USB gadget unless a controlled rebuild is explicitly + required. +7. Confirm Theia reports server version `0.27.7`, the pushed release revision, + direct MJPEG normalizer timeout `0`, and a coherent UVC contract. +8. Open both downstream RPCs and prove that each emits changing, decodable H.264 + frames. +9. Run a real bundled webcam session and confirm that camera sink lateness no + longer grows into seconds. +10. Run marked native-UVC A/B capture and the four-mode soak with zero + truncated, spliced, or unexpected-black frames. +11. Verify the RCT receives live UVC video and UAC audio, then run the mirrored + browser probe and a manual Google Meet check. + +## 9. What Is Still Missing Before Lesavka Is Done + +Lesavka is feature-rich and heavily tested, but it is not done until the live +hardware contract is repeatable. The remaining work falls into five groups. + +### A. Install And Version Parity + +- Push and deploy `0.27.7` through the client/server install scripts. +- Confirm client/server version and revision in every hardware probe artifact. +- Eliminate the current state where a fixed client talks to an unfixed server. + +### B. End-To-End Media Acceptance + +- Both downstream eye feeds must open, keep changing, decode without a black + screen, and recover from an interrupted capture source. +- The webcam must appear on the RCT as nonblack, supported UVC video. +- Microphone audio must remain intelligible and synchronized with camera video. +- The mirrored browser probe must meet the project thresholds: preferred median + near `35 ms`, acceptable p95 absolute skew within `80 ms`, and no sustained + split near `1000 ms`. +- Manual Google Meet testing must agree with the device-level probes. + +### C. Remaining Runtime/Diagnostics Work + +- Add first-bundle, first-video-feed, first-audio-push, stale-drop, and queue-age + counters for bundled sessions. +- Add concise per-session first camera/microphone packet and first UVC/UAC sink + handoff evidence. +- Surface `Starting`, `Healing`, `Flowing`, `Lagging`, `Dropping`, and `Stale` + from real path evidence rather than inferred UI state. +- Flush and stop UAC cleanly on close, replacement, and recovery. +- Prove disconnect closes all media tasks owned by that session. + +### D. Compatibility And Profile Closure + +- Prove normal bundled-capable webcam sessions never use the legacy split RPCs. +- Complete focused server bundled-stream acceptance tests. +- Run and record the Theia/Tethys mode matrix for `1280x720@20/30` and + `1920x1080@20/30` before changing advertised profiles. +- Preserve the small operator calibration nudges after the static hardware + baselines are locked. + +### E. Operational Reliability + +- Restore and verify Theia SSH, since the required installer and incident + diagnostics depend on it in this lab. +- Add a bounded, documented way to recover a wedged SSH daemon without turning + an application outage into an improvised host power operation. +- Make capture-card absence a first-class health state visible before the user + opens a blank preview. +- Keep device-level probe artifacts as release evidence; internal queue and + planner telemetry alone cannot declare the product fixed. +- Calibrate the helper/kernel integrity counters against simultaneous RCT + fault-injection captures so every visible corruption class has a server-side + signal and documented residual blind spot. + +## 10. Definition Of Done + +Lesavka can be called done when a freshly installed client and server can pass +this sequence without source edits, secret manual timing adjustments, or hidden +host repair: + +1. handshake and versions match; +2. both eye feeds show changing remote video; +3. downstream audio is audible; +4. keyboard and mouse control the RCT; +5. selected webcam/microphone settings remain authoritative; +6. RCT applications receive live UVC/UAC media; +7. mirrored-probe sync/freshness/smoothness gates pass; +8. Google Meet passes manual audio, video, and lip-sync validation; +9. disconnect/reconnect and device changes recover without stale media; and +10. diagnostics identify the failed physical stage when any item breaks. + +Until that sequence passes on the installed `0.27.7` client/server pair, the +current release should be described as a validated code correction awaiting +hardware deployment and end-to-end acceptance, not as a completed product fix. diff --git a/docs/operational-env.md b/docs/operational-env.md index d2b4df4..94389cd 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_CONTRACT_PATH` | Atomic helper-published UVC payload contract consumed by the server spool and diagnostics; defaults to `/run/lesavka-uvc-contract.json` | | `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 | @@ -350,13 +351,17 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta | `LESAVKA_UVC_FRAME_META_LOG_PATH` | UVC helper diagnostic override; when set with `LESAVKA_UVC_FRAME_META=1`, append every MJPEG spool timing record as JSONL for full-probe HEVC/RCT correlation; summarize with `scripts/manual/summarize_uvc_frame_meta_log.py` | | `LESAVKA_UVC_FRAME_META_PATH` | UVC helper diagnostic override; explicit path for the optional MJPEG spool metadata sidecar | | `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_MAX_BYTES` | UVC helper MJPEG frame-size guard; explicit maximum accepted frame bytes. Unset or `0` uses the live-call byte budget so oversized frames freeze instead of tearing on the host | +| `LESAVKA_UVC_FRAME_MAX_BYTES` | Requested UVC MJPEG frame cap. The effective value is always clamped to the live descriptor, browser commit, and transport budget; unset or `0` uses that physical contract directly | | `LESAVKA_UVC_FRAME_SIZE` | UVC advertised maximum MJPEG frame bytes; defaults to the live-call byte budget instead of raw uncompressed frame size so the gadget/host do not schedule oversized phantom frames | -| `LESAVKA_UVC_FRAME_SIZE_GUARD` | UVC helper MJPEG frame-size guard toggle; defaults to `1`; set `0` only for diagnostics when oversized MJPEG frames must be allowed through | +| `LESAVKA_UVC_FRAME_SIZE_GUARD` | Legacy diagnostic compatibility knob. It no longer permits frames to exceed the live physical UVC payload contract | +| `LESAVKA_UVC_HANDOFF_AUDIT` | Enables the bounded helper-to-gadget JSONL handoff ring when set to `1`; defaults off | +| `LESAVKA_UVC_HANDOFF_AUDIT_MAX_RECORDS` | Maximum retained helper handoff records; defaults to `1024` and is capped at `16384` | +| `LESAVKA_UVC_HANDOFF_AUDIT_PATH` | Helper handoff audit path; defaults to `/run/lesavka-uvc-handoff.jsonl` | | `LESAVKA_UVC_HEIGHT` | server hardware/device override | | `LESAVKA_UVC_HEVC_SPOOL_PULL_TIMEOUT_MS` | server HEVC decode-to-MJPEG freshness override; appsink pull wait for decoded MJPEG handoff before publishing newest frame to the UVC helper, defaults to `20` and is capped at `50` | | `LESAVKA_UVC_HEVC_FRESHNESS_QUEUE_BUFFERS` | server HEVC decode-to-MJPEG branch queue depth; defaults to `2` and is capped at `4` so decode/JPEG scheduling jitter does not starve the UVC helper while stale frames still get dropped | | `LESAVKA_UVC_IDLE_PUMP_MS` | UVC helper freshness override; idle poll sleep while pumping host-returned buffers, defaults to `2` | +| `LESAVKA_UVC_IDLE_AFTER_MS` | Sustained failure/staleness window before the helper replaces its last verified good frame with idle black; defaults to `2000` | | `LESAVKA_UVC_INTERVAL` | server hardware/device override | | `LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT` | UVC MJPEG isochronous safety cap; defaults to `85`, limiting advertised/runtime frame bytes to a safe fraction of high-speed isochronous payload capacity when bulk UVC is unavailable | | `LESAVKA_UVC_LIMIT_PCT` | server hardware/device override | @@ -378,11 +383,13 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta | `LESAVKA_UVC_MJPEG_PIXEL_GUARD_SAMPLE_WIDTH` | server MJPEG pixel-artifact guard sampling width; defaults to `160` pixels and is clamped to `64..320` | | `LESAVKA_UVC_MJPEG_PIXEL_GUARD_SEAM_COVERAGE_PCT` | server MJPEG pixel-artifact guard threshold; minimum sampled-row coverage for hard horizontal tile seams, default `42` | | `LESAVKA_UVC_MJPEG_PIXEL_GUARD_SEAM_DELTA` | server MJPEG pixel-artifact guard threshold; sampled luma jump that marks a row as suspicious, default `34` | +| `LESAVKA_UVC_KERNEL_STATS_PATH` | Kernel UVC/UDC watcher JSON path embedded in helper stats; defaults to `/run/lesavka-uvc-kernel-stats.json` | | `LESAVKA_UVC_QUEUE_PACING` | UVC helper queue pacing override; defaults to `0` because the RCT host already paces UVC consumption, and delaying returned buffer requeueing can starve isochronous gadget transfers | | `LESAVKA_UVC_RESTART_DELAY_MS` | UVC control helper supervisor restart delay after helper exit or failure; defaults to `1000` | | `LESAVKA_UVC_SKIP_UDEV` | server hardware/device override | | `LESAVKA_UVC_STATS_INTERVAL_MS` | UVC helper telemetry interval for queued/reloaded/rejected MJPEG frame counters; defaults to `5000`, `0` disables | | `LESAVKA_UVC_STATS_PATH` | UVC helper JSON stats snapshot path for queued/reloaded/rejected MJPEG frame counters; defaults to `/run/lesavka-uvc-video-stats.json`, set `0` or empty to disable file snapshots | +| `LESAVKA_UVC_STRICT_VALIDATE_EVERY` | Sample interval for complete JPEG structure and Lesavka APP4 CRC validation before QBUF; defaults to every `30` accepted frames, `0` disables sampling | | `LESAVKA_UVC_STREAMING_INTERVAL` | server hardware/device override | | `LESAVKA_UVC_STREAM_INTF` | server hardware/device override | | `LESAVKA_UVC_WIDTH` | server hardware/device override | diff --git a/scripts/daemon/lesavka-core.sh b/scripts/daemon/lesavka-core.sh index abac51e..9a03d21 100755 --- a/scripts/daemon/lesavka-core.sh +++ b/scripts/daemon/lesavka-core.sh @@ -319,7 +319,7 @@ MAX_SPEED=${LESAVKA_MAX_SPEED:-high-speed} if [[ -z $UVC_INTERVAL ]]; then UVC_INTERVAL=$((10000000 / UVC_FPS)) fi -UVC_FRAME_SIZE=${LESAVKA_UVC_FRAME_SIZE:-$((UVC_WIDTH * UVC_HEIGHT * 2))} +UVC_FRAME_SIZE= UVC_INTERVAL_30=${LESAVKA_UVC_INTERVAL_30:-333333} UVC_INTERVAL_20=${LESAVKA_UVC_INTERVAL_20:-500000} UVC_MJPEG_BUDGET_BYTES_PER_SEC=${LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC:-4500000} @@ -350,8 +350,8 @@ uvc_mjpeg_frame_size_for_fps() { fi fi local per_frame=$((budget / fps)) - if ((per_frame < 65536)); then - per_frame=65536 + if ((per_frame < 1)); then + per_frame=1 elif ((per_frame > 8388608)); then per_frame=8388608 fi @@ -367,8 +367,15 @@ uvc_fps_for_interval() { fi } -if [[ -z ${LESAVKA_UVC_FRAME_SIZE:-} ]]; then - UVC_FRAME_SIZE="$(uvc_mjpeg_frame_size_for_fps "$UVC_FPS")" +UVC_DERIVED_FRAME_SIZE="$(uvc_mjpeg_frame_size_for_fps "$UVC_FPS")" +if [[ -n ${LESAVKA_UVC_FRAME_SIZE:-} ]]; then + UVC_FRAME_SIZE=$LESAVKA_UVC_FRAME_SIZE + if ((UVC_FRAME_SIZE > UVC_DERIVED_FRAME_SIZE)); then + log "clamping requested UVC frame size $UVC_FRAME_SIZE -> $UVC_DERIVED_FRAME_SIZE (wire budget)" + UVC_FRAME_SIZE=$UVC_DERIVED_FRAME_SIZE + fi +else + UVC_FRAME_SIZE=$UVC_DERIVED_FRAME_SIZE fi uvc_selected_frame_index() { @@ -400,10 +407,16 @@ write_active_mjpeg_frame_descriptor() { uvc_frame_size_for() { local width=$1 height=$2 + local derived + derived="$(uvc_mjpeg_frame_size_for_fps "$(uvc_fps_for_interval "$(uvc_default_interval_for "$width" "$height")")")" if [[ $width == "$UVC_WIDTH" && $height == "$UVC_HEIGHT" && -n ${LESAVKA_UVC_FRAME_SIZE:-} ]]; then - echo "$LESAVKA_UVC_FRAME_SIZE" + if ((LESAVKA_UVC_FRAME_SIZE < derived)); then + echo "$LESAVKA_UVC_FRAME_SIZE" + else + echo "$derived" + fi else - uvc_mjpeg_frame_size_for_fps "$(uvc_fps_for_interval "$(uvc_default_interval_for "$width" "$height")")" + echo "$derived" fi } @@ -640,7 +653,13 @@ if [[ -z $DISABLE_UVC ]]; then F="$G/functions/uvc.usb0" if [[ -n $UVC_BULK_REQUESTED ]]; then if [[ -e "$F/streaming_bulk" ]]; then - UVC_BULK=1 + if echo 1 >"$F/streaming_bulk" 2>/dev/null && [[ $(cat "$F/streaming_bulk" 2>/dev/null) == 1 ]]; then + UVC_BULK=1 + else + log "UVC bulk request did not stick; using isochronous descriptors" + UVC_BULK= + UVC_MAXPACKET=${LESAVKA_UVC_MAXPACKET:-1024} + fi else # Some kernels do not expose the patched bulk-transfer knob. Falling # back to isochronous must also avoid the 512-byte bulk packet clamp; @@ -649,13 +668,22 @@ if [[ -z $DISABLE_UVC ]]; then UVC_BULK= UVC_MAXPACKET=${LESAVKA_UVC_MAXPACKET:-1024} fi + elif [[ -e "$F/streaming_bulk" ]]; then + if ! echo 0 >"$F/streaming_bulk" 2>/dev/null || [[ $(cat "$F/streaming_bulk" 2>/dev/null) != 0 ]]; then + log "UVC streaming_bulk could not be disabled; refusing to infer transport from attribute existence" + UVC_BULK= + fi fi apply_uvc_payload_limits echo "$UVC_STREAMING_INTERVAL" >"$F/streaming_interval" echo "$UVC_MAXPACKET" >"$F/streaming_maxpacket" echo "$UVC_MAXBURST" >"$F/streaming_maxburst" - if [[ -n $UVC_BULK ]]; then - echo 1 >"$F/streaming_bulk" 2>/dev/null || true + if [[ -n $UVC_BULK && $(cat "$F/streaming_bulk" 2>/dev/null) != 1 ]]; then + log "UVC bulk readback diverged after descriptor setup; falling back to isochronous sizing" + UVC_BULK= + UVC_MAXPACKET=${LESAVKA_UVC_MAXPACKET:-1024} + apply_uvc_payload_limits + echo "$UVC_MAXPACKET" >"$F/streaming_maxpacket" fi # ── 1. FORMAT DESCRIPTOR ────────────────────────────────────────── diff --git a/scripts/daemon/lesavka-uvc-kernel-watch.py b/scripts/daemon/lesavka-uvc-kernel-watch.py new file mode 100755 index 0000000..e9c4037 --- /dev/null +++ b/scripts/daemon/lesavka-uvc-kernel-watch.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Publish bounded kernel-side UVC/UDC error counters for Lesavka diagnostics.""" + +from __future__ import annotations + +import argparse +import json +import os +import pathlib +import re +import signal +import subprocess +import time +from collections import Counter, deque +from typing import Any + +PATTERNS = { + "dwc2_errors": re.compile(r"\bdwc2\b.*\b(error|failed|timeout|incomplete|shutdown|disconnect)", re.I), + "uvc_errors": re.compile( + r"\buvc(video)?\b.*\b(error|failed|timeout|corrupt|invalid|overflow|incomplete|non-zero status)", + re.I, + ), + "udc_errors": re.compile(r"\b(udc|gadget)\b.*\b(error|failed|timeout|shutdown|disconnect|overflow)", re.I), + "urb_errors": re.compile(r"\burb\b.*\b(error|failed|timeout|resubmit|non-zero|overflow)", re.I), + "usb_resets": re.compile(r"\busb\b.*\b(reset|disconnect|re-enumerat)", re.I), +} + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--stats-path", default=os.environ.get("LESAVKA_UVC_KERNEL_STATS_PATH", "/run/lesavka-uvc-kernel-stats.json")) + parser.add_argument("--recent-lines", type=int, default=500) + parser.add_argument("--flush-seconds", type=float, default=5.0) + parser.add_argument("--self-test", action="store_true") + return parser.parse_args() + + +def write_atomic(path: pathlib.Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(f".tmp.{os.getpid()}") + temporary.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n") + temporary.replace(path) + + +def journal_process(lines: int, follow: bool) -> subprocess.Popen[str]: + command = ["journalctl", "-k", "--no-pager", "-o", "json", "-n", str(max(0, lines))] + if follow: + command.insert(2, "-f") + return subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + + +def classify_message(message: str) -> list[str]: + return [name for name, pattern in PATTERNS.items() if pattern.search(message)] + + +def self_test() -> int: + cases = { + "dwc2 error: incomplete request": {"dwc2_errors"}, + "uvcvideo error: Non-zero status (-75) in video completion handler": {"uvc_errors"}, + "usb 1-1: reset high-speed USB device": {"usb_resets"}, + "ordinary unrelated kernel line": set(), + } + for message, expected in cases.items(): + actual = set(classify_message(message)) + if actual != expected: + raise AssertionError(f"{message!r}: expected {expected}, got {actual}") + print("self-test: pass") + return 0 + + +def main() -> int: + args = parse_args() + if args.self_test: + return self_test() + path = pathlib.Path(args.stats_path) + counters: Counter[str] = Counter() + recent: deque[dict[str, Any]] = deque(maxlen=40) + baseline_counters: dict[str, int] = {} + baseline_recent: list[dict[str, Any]] = [] + started = int(time.time() * 1000) + running = True + + def stop(_signum: int, _frame: Any) -> None: + nonlocal running + running = False + + signal.signal(signal.SIGTERM, stop) + signal.signal(signal.SIGINT, stop) + + def consume(raw: str) -> None: + try: + entry = json.loads(raw) + except json.JSONDecodeError: + return + message = str(entry.get("MESSAGE") or "") + matched = classify_message(message) + if not matched: + return + counters["matched_lines"] += 1 + counters.update(matched) + recent.append( + { + "unix_us": int(entry.get("__REALTIME_TIMESTAMP") or 0), + "categories": matched, + "message": message[:800], + } + ) + + def snapshot() -> dict[str, Any]: + return { + "schema": "lesavka.uvc-kernel-watch.v1", + "pid": os.getpid(), + "started_unix_ms": started, + "updated_unix_ms": int(time.time() * 1000), + "counters": { + name: counters[name] + for name in ["matched_lines", "watcher_restarts", *PATTERNS] + }, + "recent": list(recent), + "startup_baseline_counters": baseline_counters, + "startup_baseline_recent": baseline_recent, + } + + initial = journal_process(args.recent_lines, False) + stdout, _stderr = initial.communicate(timeout=15) + for line in stdout.splitlines(): + consume(line) + baseline_counters = { + name: counters[name] for name in ["matched_lines", *PATTERNS] + } + baseline_recent = list(recent) + counters.clear() + recent.clear() + write_atomic(path, snapshot()) + + follow = journal_process(0, True) + assert follow.stdout is not None + try: + while running: + line = follow.stdout.readline() + if line: + consume(line) + write_atomic(path, snapshot()) + elif follow.poll() is not None: + counters["watcher_restarts"] += 1 + time.sleep(max(0.1, args.flush_seconds)) + follow = journal_process(0, True) + assert follow.stdout is not None + else: + time.sleep(0.1) + finally: + follow.terminate() + try: + follow.wait(timeout=3) + except subprocess.TimeoutExpired: + follow.kill() + write_atomic(path, snapshot()) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/install/server.sh b/scripts/install/server.sh index ad81666..c2afcbf 100755 --- a/scripts/install/server.sh +++ b/scripts/install/server.sh @@ -428,6 +428,12 @@ LESAVKA_UVC_FRAME_MAX_BYTES=$(uvc_env_value LESAVKA_UVC_FRAME_MAX_BYTES 0) LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC=${LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC:-4500000} LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT=$(uvc_env_value LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT 85) LESAVKA_UVC_STATS_PATH=$(uvc_env_value LESAVKA_UVC_STATS_PATH /run/lesavka-uvc-video-stats.json) +LESAVKA_UVC_CONTRACT_PATH=$(uvc_env_value LESAVKA_UVC_CONTRACT_PATH /run/lesavka-uvc-contract.json) +LESAVKA_UVC_IDLE_AFTER_MS=$(uvc_env_value LESAVKA_UVC_IDLE_AFTER_MS 2000) +LESAVKA_UVC_STRICT_VALIDATE_EVERY=$(uvc_env_value LESAVKA_UVC_STRICT_VALIDATE_EVERY 30) +LESAVKA_UVC_HANDOFF_AUDIT=$(uvc_env_value LESAVKA_UVC_HANDOFF_AUDIT 0) +LESAVKA_UVC_HANDOFF_AUDIT_PATH=$(uvc_env_value LESAVKA_UVC_HANDOFF_AUDIT_PATH /run/lesavka-uvc-handoff.jsonl) +LESAVKA_UVC_HANDOFF_AUDIT_MAX_RECORDS=$(uvc_env_value LESAVKA_UVC_HANDOFF_AUDIT_MAX_RECORDS 1024) EOF } @@ -1040,6 +1046,26 @@ WantedBy=multi-user.target UNIT } +install_uvc_kernel_watch_unit() { + cat <<'UNIT' | sudo tee /etc/systemd/system/lesavka-uvc-kernel-watch.service >/dev/null +[Unit] +Description=lesavka UVC/UDC kernel error watcher +After=systemd-journald.service + +[Service] +ExecStart=/usr/local/lib/lesavka/lesavka-uvc-kernel-watch.py +Restart=always +RestartSec=2 +User=root +Environment=LESAVKA_UVC_KERNEL_STATS_PATH=/run/lesavka-uvc-kernel-stats.json + +[Install] +WantedBy=multi-user.target +UNIT + sudo systemctl daemon-reload + sudo systemctl enable --now lesavka-uvc-kernel-watch +} + restart_lesavka_server_only() { sudo truncate -s 0 /var/log/lesavka/server.stderr sudo systemctl stop lesavka-server >/dev/null 2>&1 || true @@ -1599,6 +1625,8 @@ install_verified_executable "$SRC_DIR/target/release/lesavka-uvc" /usr/local/bin install_verified_executable "$SRC_DIR/target/release/lesavka-synthetic-uplink" /usr/local/bin/lesavka-synthetic-uplink "lesavka-synthetic-uplink" install_verified_executable "$SRC_DIR/scripts/daemon/lesavka-core.sh" /usr/local/bin/lesavka-core.sh "lesavka-core.sh" install_verified_executable "$SRC_DIR/scripts/daemon/lesavka-uvc.sh" /usr/local/bin/lesavka-uvc.sh "lesavka-uvc.sh" +install_verified_executable "$SRC_DIR/scripts/daemon/lesavka-uvc-kernel-watch.py" /usr/local/lib/lesavka/lesavka-uvc-kernel-watch.py "lesavka-uvc-kernel-watch.py" +install_uvc_kernel_watch_unit install_verified_executable "$SRC_DIR/scripts/daemon/lesavka-recovery-ladder.sh" /usr/local/bin/lesavka-recovery-ladder "lesavka-recovery-ladder" install_verified_executable "$SRC_DIR/scripts/manual/run_uac_output_sanity.sh" /usr/local/bin/lesavka-uac-sanity "lesavka-uac-sanity" install_recovery_ladder_units @@ -1820,12 +1848,18 @@ if [[ "$ATTACHED_UVC_RESTART_DEFERRED" == "1" ]]; then fi echo "✅ No active gRPC clients detected; updated UVC env matches the live descriptor, restarting lesavka-server only." >&2 - echo " Preserving lesavka-core and lesavka-uvc so the attached USB gadget is not cycled." >&2 + echo " Preserving lesavka-core so the attached USB gadget is not cycled." >&2 sudo install -d -m 0755 /var/log/lesavka sudo truncate -s 0 /var/log/lesavka/server.log install_lesavka_server_unit_file sudo systemctl daemon-reload sudo systemctl enable lesavka-server >/dev/null 2>&1 || true + if [[ "${LESAVKA_INSTALL_RESTART_UVC_HELPER:-0}" == "1" ]]; then + restart_lesavka_uvc_helper_only + echo "✅ lesavka-uvc helper restarted in-place by explicit request; lesavka-core and the attached USB gadget were preserved." + else + echo " Preserving lesavka-uvc; set LESAVKA_INSTALL_RESTART_UVC_HELPER=1 when a helper-only refresh is required." >&2 + fi restart_lesavka_server_only sudo /usr/local/bin/lesavka-recovery-ladder snapshot || true INSTALLED_VERSION=$(manifest_package_version "$SRC_DIR/server/Cargo.toml" 2>/dev/null || true) diff --git a/scripts/manual/analyze_marked_capture.py b/scripts/manual/analyze_marked_capture.py new file mode 100755 index 0000000..03a9578 --- /dev/null +++ b/scripts/manual/analyze_marked_capture.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 +"""Compatibility entry point for the marked UVC capture integrity analyzer.""" + +from analyze_uvc_mjpeg_integrity import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/manual/analyze_uvc_mjpeg_frame.py b/scripts/manual/analyze_uvc_mjpeg_frame.py index af4785a..f3de8b7 100755 --- a/scripts/manual/analyze_uvc_mjpeg_frame.py +++ b/scripts/manual/analyze_uvc_mjpeg_frame.py @@ -90,13 +90,11 @@ def inspect_jpeg(data: bytes) -> dict[str, object]: } -def derived_cap(fps: int, budget: int, explicit: Optional[int], guard: bool) -> int: - if not guard: - return MAX_MJPEG_FRAME_BYTES +def derived_cap(fps: int, budget: int, explicit: Optional[int], _guard: bool) -> int: + physical_cap = min(max(1, budget // max(fps, 1)), MAX_MJPEG_FRAME_BYTES) if explicit and explicit > 0: - return min(explicit, MAX_MJPEG_FRAME_BYTES) - per_frame = max(budget // max(fps, 1), 64 * 1024) - return min(per_frame, MAX_MJPEG_FRAME_BYTES) + return min(explicit, physical_cap) + return physical_cap def main() -> int: diff --git a/scripts/manual/analyze_uvc_mjpeg_integrity.py b/scripts/manual/analyze_uvc_mjpeg_integrity.py new file mode 100755 index 0000000..45fd4e9 --- /dev/null +++ b/scripts/manual/analyze_uvc_mjpeg_integrity.py @@ -0,0 +1,402 @@ +#!/usr/bin/env python3 +"""Classify raw UVC MJPEG frames without relying on an RCT browser.""" + +from __future__ import annotations + +import argparse +import hashlib +import io +import json +import pathlib +import sys +import time +import warnings +import zlib +from collections import Counter +from typing import Any + +APP4_MAGIC = b"LSVK" +BAND_ROWS = 64 +BAND_BITS = 32 +BAND_CELL = 4 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("inputs", nargs="*", help="MJPEG files or directories containing .jpg/.mjpg frames") + parser.add_argument("--output-dir", default="", help="write report.json, report.txt, and frames.jsonl") + parser.add_argument("--idle-mean-max", type=float, default=8.0) + parser.add_argument("--idle-stddev-max", type=float, default=4.0) + parser.add_argument("--self-test", action="store_true") + return parser.parse_args() + + +def input_files(values: list[str]) -> list[pathlib.Path]: + files: list[pathlib.Path] = [] + for value in values: + path = pathlib.Path(value) + if path.is_dir(): + files.extend( + candidate + for candidate in sorted(path.rglob("*")) + if candidate.suffix.lower() in {".jpg", ".jpeg", ".mjpg", ".mjpeg"} + ) + elif path.is_file(): + files.append(path) + return files + + +def split_mjpeg(data: bytes) -> list[bytes]: + starts: list[int] = [] + cursor = 0 + while True: + start = data.find(b"\xff\xd8", cursor) + if start < 0: + break + starts.append(start) + cursor = start + 2 + if not starts: + return [data] + frames: list[bytes] = [] + for index, start in enumerate(starts): + next_start = starts[index + 1] if index + 1 < len(starts) else len(data) + end = data.find(b"\xff\xd9", start + 2, next_start) + frames.append(data[start : end + 2] if end >= 0 else data[start:next_start]) + return frames + + +def inspect_jpeg(frame: bytes) -> dict[str, Any]: + if len(frame) < 4 or not frame.startswith(b"\xff\xd8"): + return {"valid": False, "reason": "missing_soi", "app4": None} + pos = 2 + saw_scan = False + app4: dict[str, Any] | None = None + app4_range: tuple[int, int] | None = None + while pos < len(frame): + if frame[pos] != 0xFF: + return {"valid": False, "reason": "marker_alignment", "app4": app4} + marker_start = pos + while pos < len(frame) and frame[pos] == 0xFF: + pos += 1 + if pos >= len(frame): + return {"valid": False, "reason": "truncated_marker", "app4": app4} + marker = frame[pos] + pos += 1 + if marker == 0xD9: + valid = saw_scan and pos == len(frame) + result = {"valid": valid, "reason": "ok" if valid else "trailing_or_no_scan", "app4": app4} + if app4 is not None and app4_range is not None: + start, end = app4_range + original = frame[:start] + frame[end:] + app4["actual_crc32"] = zlib.crc32(original) & 0xFFFFFFFF + app4["crc_ok"] = app4["actual_crc32"] == app4["expected_crc32"] + return result + if marker in {0xD8, 0x00}: + return {"valid": False, "reason": "nested_soi_or_stuffing", "app4": app4} + if marker == 0x01 or 0xD0 <= marker <= 0xD7: + continue + if pos + 2 > len(frame): + return {"valid": False, "reason": "truncated_length", "app4": app4} + segment_len = int.from_bytes(frame[pos : pos + 2], "big") + segment_end = pos + segment_len + if segment_len < 2 or segment_end > len(frame): + return {"valid": False, "reason": "truncated_segment", "app4": app4} + payload = frame[pos + 2 : segment_end] + if marker == 0xE4 and payload.startswith(APP4_MAGIC) and len(payload) >= 17: + app4 = { + "version": payload[4], + "sequence": int.from_bytes(payload[5:13], "big"), + "expected_crc32": int.from_bytes(payload[13:17], "big"), + } + app4_range = (marker_start, segment_end) + pos = segment_end + if marker != 0xDA: + continue + saw_scan = True + while pos < len(frame): + if frame[pos] != 0xFF: + pos += 1 + continue + entropy_marker = pos + while pos < len(frame) and frame[pos] == 0xFF: + pos += 1 + if pos >= len(frame): + return {"valid": False, "reason": "truncated_entropy", "app4": app4} + if frame[pos] == 0x00 or 0xD0 <= frame[pos] <= 0xD7: + pos += 1 + continue + pos = entropy_marker + break + return {"valid": False, "reason": "missing_eoi", "app4": app4} + + +def decode_image(frame: bytes, allow_truncated: bool = False) -> Any | None: + try: + from PIL import Image, ImageFile + + previous = ImageFile.LOAD_TRUNCATED_IMAGES + ImageFile.LOAD_TRUNCATED_IMAGES = allow_truncated + try: + image = Image.open(io.BytesIO(frame)).convert("L") + image.load() + return image + finally: + ImageFile.LOAD_TRUNCATED_IMAGES = previous + except Exception: + return None + + +def image_integrity_metrics(frame: bytes, allow_truncated: bool = False) -> dict[str, Any]: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + image = decode_image(frame, allow_truncated=allow_truncated) + decode_warnings = [str(item.message) for item in caught] + if image is None: + return { + "width": None, + "height": None, + "luma_mean": None, + "luma_stddev": None, + "bands": [], + "first_bad_row": None, + "decode_warnings": decode_warnings, + } + try: + width, height = image.size + histogram = image.resize((64, 36)).histogram() + count = sum(histogram) + mean = sum(value * occurrences for value, occurrences in enumerate(histogram)) / max(1, count) + variance = ( + sum(((value - mean) ** 2) * occurrences for value, occurrences in enumerate(histogram)) + / max(1, count) + ) + marker_width = BAND_BITS * BAND_CELL + bands: list[dict[str, Any]] = [] + first_bad_row: int | None = None + if width >= marker_width + 8 and height >= BAND_ROWS: + x0 = width - marker_width - 4 + pixels = image.load() + expected_bands = (height + BAND_ROWS - 1) // BAND_ROWS + for band in range(expected_bands): + y0 = band * BAND_ROWS + 4 + if y0 + BAND_CELL * 2 > height: + if first_bad_row is None: + first_bad_row = band * BAND_ROWS + break + marker = 0 + confidence_sum = 0.0 + for bit in range(BAND_BITS): + values = [ + pixels[x, y] + for y in range(y0, y0 + BAND_CELL * 2) + for x in range(x0 + bit * BAND_CELL, x0 + (bit + 1) * BAND_CELL) + ] + average = sum(values) / len(values) + if average >= 128.0: + marker |= 1 << bit + confidence_sum += abs(average - 128.0) / 127.0 + decoded_band = (marker >> 24) & 0xFF + valid_band = decoded_band == (band & 0xFF) + if not valid_band and first_bad_row is None: + first_bad_row = band * BAND_ROWS + bands.append( + { + "band": band, + "row": band * BAND_ROWS, + "marker": marker, + "sequence": marker & 0x00FFFFFF, + "decoded_band": decoded_band, + "valid_band": valid_band, + "confidence": round(confidence_sum / BAND_BITS, 4), + } + ) + return { + "width": width, + "height": height, + "luma_mean": mean, + "luma_stddev": variance**0.5, + "bands": bands, + "first_bad_row": first_bad_row, + "decode_warnings": decode_warnings, + } + except Exception: + return { + "width": None, + "height": None, + "luma_mean": None, + "luma_stddev": None, + "bands": [], + "first_bad_row": None, + "decode_warnings": decode_warnings, + } + + +def classify_frame(frame: bytes, previous_sequence: int | None, args: argparse.Namespace) -> dict[str, Any]: + inspection = inspect_jpeg(frame) + app4 = inspection.get("app4") + sequence = app4.get("sequence") if isinstance(app4, dict) else None + metrics = image_integrity_metrics(frame, allow_truncated=not inspection["valid"]) + mean = metrics["luma_mean"] + stddev = metrics["luma_stddev"] + bands = metrics["bands"] + valid_band_sequences = [band["sequence"] for band in bands if band["valid_band"]] + distinct_band_sequences = sorted(set(valid_band_sequences)) + expected_sequence = sequence & 0x00FFFFFF if sequence is not None else None + mismatched_band = next( + ( + band + for band in bands + if not band["valid_band"] + or (expected_sequence is not None and band["sequence"] != expected_sequence) + ), + None, + ) + band_splice = isinstance(app4, dict) and ( + len(distinct_band_sequences) > 1 or mismatched_band is not None + ) + idle_black = mean is not None and stddev is not None and mean <= args.idle_mean_max and stddev <= args.idle_stddev_max + if not inspection["valid"]: + classification = "truncated" + elif (isinstance(app4, dict) and not app4.get("crc_ok", False)) or band_splice: + classification = "spliced" + elif sequence is not None and previous_sequence is not None and sequence <= previous_sequence: + classification = "stale_repeat" + elif idle_black: + classification = "idle_black" + else: + classification = "intact" + first_bad_row = metrics["first_bad_row"] if classification == "truncated" else None + seam_row = mismatched_band["row"] if classification == "spliced" and mismatched_band else None + if classification == "truncated": + verdict = f"truncated(first_bad_row={first_bad_row})" + elif classification == "spliced": + verdict = f"spliced(seqs={distinct_band_sequences}, seam_row={seam_row})" + else: + verdict = classification + return { + "classification": classification, + "verdict": verdict, + "bytes": len(frame), + "sha256": hashlib.sha256(frame).hexdigest(), + "jpeg_valid": inspection["valid"], + "jpeg_reason": inspection["reason"], + "sequence": sequence, + "app4_crc_ok": app4.get("crc_ok") if isinstance(app4, dict) else None, + "width": metrics["width"], + "height": metrics["height"], + "band_markers": bands, + "band_sequences": distinct_band_sequences, + "first_bad_row": first_bad_row, + "seam_row": seam_row, + "decode_warnings": metrics["decode_warnings"], + "luma_mean": round(mean, 3) if mean is not None else None, + "luma_stddev": round(stddev, 3) if stddev is not None else None, + } + + +def add_app4(frame: bytes, sequence: int) -> bytes: + payload = APP4_MAGIC + bytes([1]) + sequence.to_bytes(8, "big") + (zlib.crc32(frame) & 0xFFFFFFFF).to_bytes(4, "big") + return frame[:2] + b"\xff\xe4" + (len(payload) + 2).to_bytes(2, "big") + payload + frame[2:] + + +def self_test(args: argparse.Namespace) -> int: + from PIL import Image + + def marked_image(top_sequence: int, bottom_sequence: int | None = None) -> Any: + image = Image.new("RGB", (256, 192)) + pixels = image.load() + x0 = image.width - BAND_BITS * BAND_CELL - 4 + for y in range(image.height): + for x in range(image.width): + pixels[x, y] = ((x * 3) & 0xFF, (y * 5) & 0xFF, ((x + y) * 2) & 0xFF) + for band in range((image.height + BAND_ROWS - 1) // BAND_ROWS): + sequence = top_sequence if bottom_sequence is None or band < 2 else bottom_sequence + marker = (sequence & 0x00FFFFFF) | ((band & 0xFF) << 24) + for bit in range(BAND_BITS): + value = 245 if marker & (1 << bit) else 12 + for y in range(band * BAND_ROWS + 4, band * BAND_ROWS + 12): + for x in range(x0 + bit * BAND_CELL, x0 + (bit + 1) * BAND_CELL): + pixels[x, y] = (value, value, value) + return image + + image = marked_image(7) + encoded = io.BytesIO() + image.save(encoded, format="JPEG", quality=90) + intact = add_app4(encoded.getvalue(), 7) + spliced = bytearray(intact) + entropy_index = max(24, len(spliced) - 12) + while entropy_index > 24 and spliced[entropy_index] == 0xFF: + entropy_index -= 1 + spliced[entropy_index] ^= 0x01 + visibly_spliced = io.BytesIO() + marked_image(7, 8).save(visibly_spliced, format="JPEG", quality=95) + visibly_spliced_frame = add_app4(visibly_spliced.getvalue(), 7) + black = io.BytesIO() + Image.new("RGB", (256, 192), (0, 0, 0)).save(black, format="JPEG", quality=90) + + cases = [ + (intact, None, "intact"), + (intact, 7, "stale_repeat"), + (bytes(spliced), 6, "spliced"), + (visibly_spliced_frame, 6, "spliced"), + (intact[:-9], 6, "truncated"), + (black.getvalue(), None, "idle_black"), + ] + for frame, previous, expected in cases: + actual = classify_frame(frame, previous, args)["classification"] + if actual != expected: + raise AssertionError(f"expected {expected}, got {actual}") + localized = classify_frame(visibly_spliced_frame, 6, args) + if localized["seam_row"] != 128 or localized["band_sequences"] != [7, 8]: + raise AssertionError(f"splice localization failed: {localized}") + print("self-test: pass") + return 0 + + +def main() -> int: + args = parse_args() + if args.self_test: + return self_test(args) + files = input_files(args.inputs) + if not files: + raise SystemExit("no MJPEG inputs found") + records: list[dict[str, Any]] = [] + previous_sequence: int | None = None + for path in files: + for index, frame in enumerate(split_mjpeg(path.read_bytes())): + record = classify_frame(frame, previous_sequence, args) + record.update({"source": str(path), "source_frame_index": index}) + records.append(record) + if record["sequence"] is not None: + previous_sequence = int(record["sequence"]) + counts = Counter(record["classification"] for record in records) + report = { + "schema": "lesavka.uvc-mjpeg-integrity.v1", + "generated_unix_ms": int(time.time() * 1000), + "frames": len(records), + "classification_counts": dict(sorted(counts.items())), + "intact": counts["intact"], + "truncated": counts["truncated"], + "spliced": counts["spliced"], + "stale_repeat": counts["stale_repeat"], + "idle_black": counts["idle_black"], + "verdict": "pass" if len(records) > 0 and counts["intact"] == len(records) else "fail", + } + text = ( + f"verdict: {report['verdict']}\nframes: {report['frames']}\n" + f"intact: {report['intact']}\ntruncated: {report['truncated']}\n" + f"spliced: {report['spliced']}\nstale_repeat: {report['stale_repeat']}\n" + f"idle_black: {report['idle_black']}\n" + ) + if args.output_dir: + output = pathlib.Path(args.output_dir) + output.mkdir(parents=True, exist_ok=True) + (output / "report.json").write_text(json.dumps(report, indent=2) + "\n") + (output / "report.txt").write_text(text) + (output / "frames.jsonl").write_text("".join(json.dumps(record) + "\n" for record in records)) + sys.stdout.write(text) + return 0 if report["verdict"] == "pass" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/manual/run_synthetic_rct_uvc_probe.py b/scripts/manual/run_synthetic_rct_uvc_probe.py index 866f67f..6e942a0 100755 --- a/scripts/manual/run_synthetic_rct_uvc_probe.py +++ b/scripts/manual/run_synthetic_rct_uvc_probe.py @@ -25,6 +25,9 @@ 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 +BAND_MARKER_ROWS = 64 +BAND_MARKER_BITS = 32 +BAND_MARKER_CELL = 4 CADENCE_REASONS = {"frame_repeat", "frame_gap", "frame_backwards"} NON_VISUAL_REASONS = CADENCE_REASONS | {"sequence_marker_mismatch"} @@ -267,6 +270,11 @@ def parse_args() -> argparse.Namespace: action="store_true", help="debug path: analyze ffmpeg stdout directly instead of spooling raw frames first", ) + parser.add_argument( + "--deep-capture", + action="store_true", + help="preserve native MJPEG plus UVC/kernel/USB power evidence; UVC source only", + ) parser.add_argument("--capture-only", action="store_true", help=argparse.SUPPRESS) parser.add_argument("--self-test", action="store_true") return parser.parse_args() @@ -305,7 +313,7 @@ def default_inject_max_frame_bytes(fps: int) -> int: * DEFAULT_ISOCHRONOUS_LIMIT_PCT // 100 ) - return max(64 * 1024, bytes_per_second // max(1, fps)) + return max(1, bytes_per_second // max(1, fps)) def default_artifact_dir(mode: str) -> pathlib.Path: @@ -791,6 +799,8 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int: ] if args.stream_analyze: capture_cmd.append("--stream-analyze") + if args.deep_capture: + capture_cmd.append("--deep-capture") inject_cmd = [ args.inject_binary, "--server", @@ -929,6 +939,20 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int: 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.deep_capture: + integrity_script = pathlib.Path(__file__).with_name("analyze_marked_capture.py") + native_capture = local_capture / "capture.mjpg" + if integrity_script.exists() and native_capture.exists(): + subprocess.run( + [ + sys.executable, + str(integrity_script), + str(native_capture), + "--output-dir", + str(local_capture / "mjpeg-integrity"), + ], + check=False, + ) if args.local_inject: if pathlib.Path(remote_inject_dir).exists(): if local_inject.exists(): @@ -1175,9 +1199,28 @@ def synthetic_luma(width: int, height: int, sequence: int, x: int, y: int) -> in marker = synthetic_marker_luma(width, height, sequence, x, y) if marker is not None: return marker + band_marker = synthetic_band_marker_luma(width, height, sequence, x, y) + if band_marker is not None: + return band_marker return synthetic_base_luma(width, height, sequence, x, y) +def synthetic_band_marker_luma(width: int, height: int, sequence: int, x: int, y: int) -> int | None: + marker_width = BAND_MARKER_BITS * BAND_MARKER_CELL + if width < marker_width + 8 or height < BAND_MARKER_ROWS: + return None + band = y // BAND_MARKER_ROWS + band_y = band * BAND_MARKER_ROWS + 4 + if not band_y <= y < band_y + BAND_MARKER_CELL * 2: + return None + x0 = width - marker_width - 4 + if not x0 <= x < x0 + marker_width: + return None + bit = (x - x0) // BAND_MARKER_CELL + marker = (sequence & 0x00FFFFFF) | ((band & 0xFF) << 24) + return 245 if marker & (1 << bit) else 12 + + def synthetic_gray(width: int, height: int, sequence: int) -> bytes: data = bytearray(width * height) for y in range(height): @@ -1524,11 +1567,119 @@ def write_pgm(path: pathlib.Path, frame: bytes, width: int, height: int) -> None path.write_bytes(f"P5\n{width} {height}\n255\n".encode() + frame) +def command_text(command: list[str]) -> str: + try: + return subprocess.run(command, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False).stdout + except (FileNotFoundError, PermissionError, OSError) as error: + return f"unavailable: {error}\n" + + +def write_deep_capture_snapshot(artifact_dir: pathlib.Path, label: str, device: str) -> None: + snapshot = artifact_dir / "deep-capture" / label + snapshot.mkdir(parents=True, exist_ok=True) + commands = { + "uname.txt": ["uname", "-a"], + "v4l2-all.txt": ["v4l2-ctl", "--device", device, "--all"], + "v4l2-formats.txt": ["v4l2-ctl", "--device", device, "--list-formats-ext"], + "udev.txt": ["udevadm", "info", "--query=all", "--name", device], + "uvcvideo-parameters.txt": ["bash", "-lc", "for f in /sys/module/uvcvideo/parameters/*; do printf '%s=' \"$f\"; cat \"$f\" 2>&1; done"], + "usb-power.txt": [ + "bash", + "-lc", + ( + f"p=$(udevadm info -q path -n {shlex.quote(device)} 2>/dev/null); " + "d=/sys$p; while [[ $d != /sys && ! -e $d/idVendor ]]; do d=${d%/*}; done; " + "echo sysfs=$d; for f in power/control power/autosuspend power/autosuspend_delay_ms " + "power/runtime_status busnum devnum idVendor idProduct product serial; do " + "printf '%s=' \"$f\"; cat \"$d/$f\" 2>&1; done" + ), + ], + "kernel.txt": ["journalctl", "-k", "--no-pager", "-n", "1200"], + } + for name, command in commands.items(): + (snapshot / name).write_text(command_text(command)) + + +def usbmon_interface(device: str) -> str: + try: + sysfs = pathlib.Path("/sys" + subprocess.check_output(["udevadm", "info", "-q", "path", "-n", device], text=True).strip()) + for parent in [sysfs, *sysfs.parents]: + busnum = parent / "busnum" + if busnum.exists(): + return f"usbmon{int(busnum.read_text().strip())}" + except (FileNotFoundError, OSError, ValueError, subprocess.CalledProcessError): + pass + return "usbmon0" + + +def set_uvc_dynamic_debug(artifact_dir: pathlib.Path, enabled: bool) -> bool: + action = "+p" if enabled else "-p" + command = [ + "bash", + "-lc", + ( + "c=/sys/kernel/debug/dynamic_debug/control; " + f"line='module uvcvideo {action}'; " + "if [[ -w $c ]]; then echo \"$line\" >\"$c\"; " + "elif sudo -n test -w \"$c\" 2>/dev/null; then echo \"$line\" | sudo -n tee \"$c\" >/dev/null; " + "else echo 'dynamic debug control unavailable' >&2; exit 1; fi" + ), + ] + result = subprocess.run(command, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False) + (artifact_dir / f"uvcvideo-dynamic-debug-{'enable' if enabled else 'disable'}.txt").write_text( + f"rc={result.returncode}\n{result.stdout}" + ) + return result.returncode == 0 + + +def start_usbmon_capture(artifact_dir: pathlib.Path, device: str) -> subprocess.Popen[Any] | None: + if not shutil.which("tshark"): + (artifact_dir / "usbmon-status.txt").write_text("tshark unavailable\n") + return None + interface = usbmon_interface(device) + output = artifact_dir / "usbmon.pcapng" + stderr = (artifact_dir / "usbmon.stderr").open("wb") + try: + process = subprocess.Popen( + ["tshark", "-i", interface, "-w", str(output)], + stdout=subprocess.DEVNULL, + stderr=stderr, + ) + except (OSError, PermissionError) as error: + stderr.close() + (artifact_dir / "usbmon-status.txt").write_text(f"usbmon unavailable: {error}\n") + return None + setattr(process, "_lesavka_stderr", stderr) + return process + + +def stop_usbmon_capture(process: subprocess.Popen[Any] | None) -> None: + if process is None: + return + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + stderr = getattr(process, "_lesavka_stderr", None) + if stderr is not None: + stderr.close() + + def run_capture(args: argparse.Namespace) -> int: width, height, fps = mode_dimensions(args) command, capture_width, capture_height, device = ffmpeg_cmd(args, width, height) artifact_dir = pathlib.Path(args.artifact_dir) if args.artifact_dir else pathlib.Path("/tmp") / f"lesavka-synthetic-rct-capture-{timestamp()}" artifact_dir.mkdir(parents=True, exist_ok=True) + if args.deep_capture and args.source != "uvc": + raise SystemExit("--deep-capture requires --source uvc") + if args.deep_capture: + write_deep_capture_snapshot(artifact_dir, "before", device) + dynamic_debug_enabled = ( + set_uvc_dynamic_debug(artifact_dir / "deep-capture", True) if args.deep_capture else False + ) + usbmon = start_usbmon_capture(artifact_dir / "deep-capture", device) if args.deep_capture else None frame_size = capture_width * capture_height stderr_path = artifact_dir / "ffmpeg.stderr" metrics_path = artifact_dir / "frame-metrics.jsonl" @@ -1610,8 +1761,9 @@ def run_capture(args: argparse.Namespace) -> int: if frame_index % args.progress_every == 0: print(f"frames={frame_index} suspicious={suspicious_count} latest={result}", file=sys.stderr) - with stderr_path.open("wb") as err, metrics_path.open("w") as metrics: - if args.stream_analyze: + try: + with stderr_path.open("wb") as err, metrics_path.open("w") as metrics: + if args.stream_analyze and not args.deep_capture: (artifact_dir / "command.txt").write_text(" ".join(shlex.quote(part) for part in command) + "\n") proc = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=err) assert proc.stdout is not None @@ -1633,18 +1785,40 @@ def run_capture(args: argparse.Namespace) -> int: analysis_elapsed = capture_elapsed else: raw_path = artifact_dir / "capture.raw" - capture_command = command[:] - if "-an" in capture_command: - capture_command[capture_command.index("-an") : capture_command.index("-an")] = ["-t", str(args.duration)] + if args.deep_capture: + native_mjpeg = artifact_dir / "capture.mjpg" + input_end = command.index("-an") + capture_command = command[:input_end] + [ + "-t", + str(args.duration), + "-an", + "-c:v", + "copy", + "-f", + "mjpeg", + str(native_mjpeg), + ] else: - capture_command[-1:-1] = ["-t", str(args.duration)] - capture_command[-1] = str(raw_path) + native_mjpeg = None + capture_command = command[:] + if "-an" in capture_command: + capture_command[capture_command.index("-an") : capture_command.index("-an")] = ["-t", str(args.duration)] + else: + capture_command[-1:-1] = ["-t", str(args.duration)] + capture_command[-1] = str(raw_path) (artifact_dir / "command.txt").write_text(" ".join(shlex.quote(part) for part in capture_command) + "\n") print(f"capturing raw RCT frames before analysis: {raw_path}", file=sys.stderr) capture_started = time.monotonic() proc = subprocess.run(capture_command, stdout=subprocess.DEVNULL, stderr=err, check=False) capture_elapsed = time.monotonic() - capture_started ffmpeg_rc = proc.returncode + if args.deep_capture and native_mjpeg is not None and native_mjpeg.exists(): + decode_command = [ + "ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "warning", + "-i", str(native_mjpeg), "-an", "-pix_fmt", "gray", "-f", "rawvideo", str(raw_path), + ] + (artifact_dir / "decode-command.txt").write_text(" ".join(shlex.quote(part) for part in decode_command) + "\n") + subprocess.run(decode_command, stdout=subprocess.DEVNULL, stderr=err, check=False) raw_capture_bytes = raw_path.stat().st_size if raw_path.exists() else 0 print( f"analyzing captured raw RCT frames bytes={raw_capture_bytes} capture_s={capture_elapsed:.3f}", @@ -1661,13 +1835,19 @@ def run_capture(args: argparse.Namespace) -> int: finally: raw_path.unlink(missing_ok=True) analysis_elapsed = time.monotonic() - analysis_started + finally: + stop_usbmon_capture(usbmon) + if args.deep_capture: + write_deep_capture_snapshot(artifact_dir, "after", device) + if dynamic_debug_enabled: + set_uvc_dynamic_debug(artifact_dir / "deep-capture", False) elapsed = max(0.001, capture_elapsed) summary = { "schema": "lesavka.synthetic-rct-capture.v1", "source": args.source, "device": device, "mode": args.mode, - "capture_mode": "stream" if args.stream_analyze else "rawfile", + "capture_mode": "deep-mjpeg" if args.deep_capture else ("stream" if args.stream_analyze else "rawfile"), "width": capture_width, "height": capture_height, "fps_requested": fps, diff --git a/server/Cargo.toml b/server/Cargo.toml index 54beb59..4a0d254 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -16,7 +16,7 @@ bench = false [package] name = "lesavka_server" -version = "0.27.6" +version = "0.27.7" edition = "2024" autobins = false diff --git a/server/src/bin/lesavka-synthetic-uplink.rs b/server/src/bin/lesavka-synthetic-uplink.rs index a1a1675..b53f6c3 100755 --- a/server/src/bin/lesavka-synthetic-uplink.rs +++ b/server/src/bin/lesavka-synthetic-uplink.rs @@ -20,6 +20,9 @@ const DEFAULT_CHANNELS: u32 = 2; const DEFAULT_JPEG_QUALITY: i32 = 82; const MARKER_BITS: usize = 32; const MARKER_COLUMNS: usize = 16; +const BAND_MARKER_ROWS: usize = 64; +const BAND_MARKER_BITS: usize = 32; +const BAND_MARKER_CELL: usize = 4; #[derive(Clone, Debug)] struct Args { @@ -258,7 +261,7 @@ impl MjpegEncoder { let map = buffer .map_readable() .context("mapping encoded synthetic frame")?; - Ok(map.as_slice().to_vec()) + add_jpeg_app4_integrity(map.as_slice(), sequence) } } diff --git a/server/src/bin/lesavka-uvc.real.inc b/server/src/bin/lesavka-uvc.real.inc index bb5bda9..793a695 100644 --- a/server/src/bin/lesavka-uvc.real.inc +++ b/server/src/bin/lesavka-uvc.real.inc @@ -1,6 +1,8 @@ // lesavka-uvc - minimal UVC control handler for the gadget node. use anyhow::{Context, Result}; +use lesavka_server::uvc_contract::{self, UvcContractInput, UvcPayloadContract}; +use std::collections::VecDeque; use std::env; use std::fs::{File, OpenOptions}; use std::os::unix::fs::OpenOptionsExt; @@ -56,9 +58,13 @@ const DEFAULT_UVC_FRAME_MAX_AGE_MS: u64 = 1_000; const DEFAULT_UVC_QUEUE_PACING: bool = false; const DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC: u32 = 4_500_000; const DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT: u32 = 85; -const HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC: u32 = 8_000; const DEFAULT_UVC_STATS_INTERVAL_MS: u64 = 5_000; const DEFAULT_UVC_STATS_PATH: &str = "/run/lesavka-uvc-video-stats.json"; +const DEFAULT_UVC_IDLE_AFTER_STALE_MS: u64 = 2_000; +const DEFAULT_UVC_HANDOFF_AUDIT_PATH: &str = "/run/lesavka-uvc-handoff.jsonl"; +const DEFAULT_UVC_HANDOFF_AUDIT_MAX_RECORDS: usize = 1_024; +const DEFAULT_UVC_KERNEL_STATS_PATH: &str = "/run/lesavka-uvc-kernel-stats.json"; +const V4L2_BUF_FLAG_ERROR: u32 = 0x0040; #[repr(C)] struct V4l2EventSubscription { @@ -214,6 +220,7 @@ struct UvcState { probe: [u8; STREAM_CTRL_SIZE_MAX], commit: [u8; STREAM_CTRL_SIZE_MAX], cfg_snapshot: Option, + commit_observed: bool, } #[derive(Clone, Copy)] @@ -235,8 +242,10 @@ struct ConfigfsSnapshot { height: u32, default_interval: u32, frame_interval: u32, + frame_size: u32, maxpacket: u32, maxburst: u32, + streaming_bulk: Option, } struct MmapBuffer { @@ -255,6 +264,12 @@ struct UvcVideoStream { streaming: bool, transport_bulk: bool, max_packet: u32, + has_verified_frame: bool, + last_verified_mtime: Option, + last_spool_mtime_ns: Option, + last_refresh_disposition: &'static str, + handoff_sequence: u64, + handoff_audit: VecDeque, stats: UvcVideoStats, } @@ -266,6 +281,12 @@ struct UvcVideoStats { rejected_oversize: u64, rejected_invalid: u64, fallback_idle: u64, + held_last_good: u64, + read_errors: u64, + strict_validation_failures: u64, + dqbuf_ioctl_errors: u64, + dqbuf_flag_errors: u64, + qbuf_ioctl_errors: u64, latest_bytes: usize, last_rejected_oversize_bytes: usize, last_rejected_oversize_cap: usize, @@ -287,6 +308,12 @@ impl UvcVideoStream { streaming: false, transport_bulk: false, max_packet: 0, + has_verified_frame: false, + last_verified_mtime: None, + last_spool_mtime_ns: None, + last_refresh_disposition: "idle_startup", + handoff_sequence: 0, + handoff_audit: VecDeque::new(), stats: UvcVideoStats::default(), } } @@ -301,7 +328,7 @@ impl UvcVideoStream { self.set_format(cfg)?; self.request_buffers(uvc_buffer_count())?; for index in 0..self.buffers.len() { - self.queue_buffer(index as u32)?; + self.queue_buffer(index as u32, 0)?; } let req = ioctl_write::(b'V', 18); let mut type_ = V4L2_BUF_TYPE_VIDEO_OUTPUT as libc::c_int; @@ -361,10 +388,16 @@ impl UvcVideoStream { let err = std::io::Error::last_os_error(); match err.raw_os_error() { Some(libc::EAGAIN) | Some(libc::EINTR) => return Ok(()), - _ => return Err(err).context("VIDIOC_DQBUF"), + _ => { + self.stats.dqbuf_ioctl_errors += 1; + return Err(err).context("VIDIOC_DQBUF"); + } } } - self.queue_buffer(buf.index)?; + if buf.flags & V4L2_BUF_FLAG_ERROR != 0 { + self.stats.dqbuf_flag_errors += 1; + } + self.queue_buffer(buf.index, buf.flags)?; } } @@ -446,7 +479,7 @@ impl UvcVideoStream { Ok(()) } - fn queue_buffer(&mut self, index: u32) -> Result<()> { + fn queue_buffer(&mut self, index: u32, dqbuf_flags: u32) -> Result<()> { if self.streaming { self.pace_queue_if_needed(); } @@ -456,6 +489,8 @@ impl UvcVideoStream { }; let frame = self.frame_for_buffer(buffer.len); let bytes = frame.len(); + let frame_hash = fnv1a64(frame); + let frame_sequence = uvc_contract::lesavka_app4_sequence(frame); if bytes > 0 { unsafe { std::ptr::copy_nonoverlapping(frame.as_ptr(), buffer.ptr, bytes); @@ -478,10 +513,12 @@ impl UvcVideoStream { let req = ioctl_readwrite::(b'V', 15); let rc = unsafe { libc::ioctl(self.fd, req, &mut buf) }; if rc < 0 { + self.stats.qbuf_ioctl_errors += 1; return Err(std::io::Error::last_os_error()).context("VIDIOC_QBUF"); } self.stats.queued += 1; self.stats.latest_bytes = bytes; + self.record_handoff(index, dqbuf_flags, bytes, frame_hash, frame_sequence); self.report_stats_if_due(); Ok(()) } @@ -503,35 +540,63 @@ impl UvcVideoStream { } fn refresh_latest_frame(&mut self) { + self.last_spool_mtime_ns = frame_spool_mtime_ns(&self.frame_path); let stale = frame_spool_is_stale(&self.frame_path, frame_spool_max_age()); if stale { self.stats.replayed_stale += 1; - self.replace_latest_with_idle(); + self.hold_last_good_or_idle("hold_stale", "idle_stale"); return; } let max_frame_bytes = self.frame_payload_limit(); match std::fs::read(&self.frame_path) { Ok(frame) if !looks_like_mjpeg_frame(&frame) => { self.stats.rejected_invalid += 1; - self.replace_latest_with_idle(); + self.hold_last_good_or_idle("hold_invalid", "idle_invalid"); } Ok(frame) if frame.len() > max_frame_bytes => { self.stats.rejected_oversize += 1; self.stats.last_rejected_oversize_bytes = frame.len(); self.stats.last_rejected_oversize_cap = max_frame_bytes; - self.replace_latest_with_idle(); + self.hold_last_good_or_idle("hold_oversize", "idle_oversize"); } Ok(frame) => { + if strict_validation_due(self.stats.reloaded) + && (!uvc_contract::validate_jpeg_structure(&frame) + || uvc_contract::lesavka_app4_integrity_valid(&frame) == Some(false)) + { + self.stats.rejected_invalid += 1; + self.stats.strict_validation_failures += 1; + self.hold_last_good_or_idle("hold_strict_invalid", "idle_strict_invalid"); + return; + } self.stats.reloaded += 1; + self.stats.latest_bytes = frame.len(); self.latest_frame = frame; + self.has_verified_frame = true; + self.last_verified_mtime = frame_spool_modified(&self.frame_path); + self.last_refresh_disposition = "fresh"; + } + Err(_) => { + self.stats.read_errors += 1; + self.hold_last_good_or_idle("hold_read_error", "idle_read_error"); } - Err(_) => self.replace_latest_with_idle(), } if !looks_like_mjpeg_frame(&self.latest_frame) { self.replace_latest_with_idle(); + self.last_refresh_disposition = "idle_invalid_cache"; } } + fn hold_last_good_or_idle(&mut self, hold_reason: &'static str, idle_reason: &'static str) { + if self.has_verified_frame && !verified_frame_idle_timeout_elapsed(self.last_verified_mtime) { + self.stats.held_last_good += 1; + self.last_refresh_disposition = hold_reason; + return; + } + self.replace_latest_with_idle(); + self.last_refresh_disposition = idle_reason; + } + fn replace_latest_with_idle(&mut self) { if self.latest_frame.as_slice() != IDLE_MJPEG_FRAME { self.stats.fallback_idle += 1; @@ -539,6 +604,53 @@ impl UvcVideoStream { self.latest_frame = IDLE_MJPEG_FRAME.to_vec(); } + fn record_handoff( + &mut self, + buffer_index: u32, + dqbuf_flags: u32, + bytes: usize, + hash: u64, + frame_sequence: Option, + ) { + let Some(path) = uvc_handoff_audit_path() else { + return; + }; + self.handoff_sequence += 1; + let record = format!( + "{{\"schema\":\"lesavka.uvc-handoff.v1\",\"sequence\":{},\"frame_sequence\":{},\"unix_ns\":{},\"spool_mtime_ns\":{},\"bytes\":{},\"fnv1a64\":\"{:016x}\",\"frame_cap\":{},\"buffer_index\":{},\"dqbuf_flags\":{},\"v4l2_buffer_error\":{},\"disposition\":\"{}\"}}\n", + self.handoff_sequence, + frame_sequence + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + unix_now_ns(), + self.last_spool_mtime_ns + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + bytes, + hash, + self.frame_payload_limit(), + buffer_index, + dqbuf_flags, + dqbuf_flags & V4L2_BUF_FLAG_ERROR != 0, + self.last_refresh_disposition, + ); + let max_records = uvc_handoff_audit_max_records(); + if max_records == 0 { + return; + } + if self.handoff_audit.len() == max_records { + self.handoff_audit.pop_front(); + } + self.handoff_audit.push_back(record); + let payload = self.handoff_audit.iter().map(String::as_str).collect::(); + if let Err(err) = write_atomic_text(&path, &payload) { + eprintln!( + "[lesavka-uvc] failed to write handoff audit {}: {err:#}", + path.display() + ); + } + } + fn frame_payload_limit(&self) -> usize { self.buffers .iter() @@ -572,13 +684,19 @@ impl UvcVideoStream { } self.stats.last_report = Some(now); eprintln!( - "[lesavka-uvc] video stats queued={} reloaded={} stale_replay={} rejected_oversize={} rejected_invalid={} fallback_idle={} latest_bytes={} frame_cap={} last_rejected_oversize_bytes={} last_rejected_oversize_cap={} paced_sleeps={} paced_sleep_ms={}", + "[lesavka-uvc] video stats queued={} reloaded={} stale_replay={} rejected_oversize={} rejected_invalid={} fallback_idle={} held_last_good={} read_errors={} strict_validation_failures={} dqbuf_ioctl_errors={} dqbuf_flag_errors={} qbuf_ioctl_errors={} latest_bytes={} frame_cap={} last_rejected_oversize_bytes={} last_rejected_oversize_cap={} paced_sleeps={} paced_sleep_ms={}", self.stats.queued, self.stats.reloaded, self.stats.replayed_stale, self.stats.rejected_oversize, self.stats.rejected_invalid, self.stats.fallback_idle, + self.stats.held_last_good, + self.stats.read_errors, + self.stats.strict_validation_failures, + self.stats.dqbuf_ioctl_errors, + self.stats.dqbuf_flag_errors, + self.stats.qbuf_ioctl_errors, self.stats.latest_bytes, self.frame_payload_limit(), self.stats.last_rejected_oversize_bytes, @@ -683,18 +801,17 @@ fn uvc_queue_period(fps: u32) -> Option { /// half-frame grey smears, and freezing the last good frame is preferable to /// queueing a frame that is likely to arrive incomplete. fn uvc_frame_max_bytes(cfg: UvcConfig) -> usize { - if !uvc_frame_size_guard_enabled() { - return MAX_MJPEG_FRAME_BYTES; + if let Ok(contract) = uvc_contract::read_contract(&uvc_contract::contract_path()) { + return contract + .enforced_frame_cap_bytes + .min(cfg.frame_size) + .min(MAX_MJPEG_FRAME_BYTES as u32) as usize; } - if let Some(limit) = env_u32_opt("LESAVKA_UVC_FRAME_MAX_BYTES") { - return if limit == 0 { - derived_uvc_frame_max_bytes(cfg) - } else { - (limit as usize).min(MAX_MJPEG_FRAME_BYTES) - }; - } - - derived_uvc_frame_max_bytes(cfg) + let derived = derived_uvc_frame_max_bytes(cfg).min(cfg.frame_size as usize); + env_u32_opt("LESAVKA_UVC_FRAME_MAX_BYTES") + .filter(|limit| *limit > 0) + .map(|limit| derived.min(limit as usize)) + .unwrap_or(derived) } fn derived_uvc_frame_max_bytes(cfg: UvcConfig) -> usize { @@ -702,10 +819,8 @@ fn derived_uvc_frame_max_bytes(cfg: UvcConfig) -> usize { } fn derived_uvc_frame_max_bytes_for_transport(fps: u32, max_packet: u32, bulk: bool) -> usize { - let fps = fps.max(1); let budget_per_sec = effective_uvc_mjpeg_budget_bytes_per_sec(max_packet, bulk); - let per_frame = (budget_per_sec / fps).max(64 * 1024); - per_frame.min(MAX_MJPEG_FRAME_BYTES as u32) as usize + uvc_contract::frame_cap_from_budget(budget_per_sec, fps) as usize } fn effective_uvc_mjpeg_budget_bytes_per_sec(max_packet: u32, bulk: bool) -> u32 { @@ -714,26 +829,11 @@ fn effective_uvc_mjpeg_budget_bytes_per_sec(max_packet: u32, bulk: bool) -> u32 DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC, ) .max(1); - if bulk { - return configured; - } - - configured - .min(uvc_isochronous_budget_bytes_per_sec(max_packet)) - .max(1) -} - -fn uvc_isochronous_budget_bytes_per_sec(max_packet: u32) -> u32 { let pct = env_u32( "LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT", DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT, - ) - .clamp(1, 100); - let bytes = u64::from(max_packet) - .saturating_mul(u64::from(HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC)) - .saturating_mul(u64::from(pct)) - / 100; - bytes.min(u64::from(u32::MAX)) as u32 + ); + uvc_contract::effective_mjpeg_budget_bytes_per_sec(configured, max_packet, bulk, pct) } fn env_flag_enabled(name: &str, default: bool) -> bool { @@ -760,22 +860,19 @@ fn env_flag_enabled(name: &str, default: bool) -> bool { .unwrap_or(default) } -fn uvc_frame_size_guard_enabled() -> bool { - env_flag_enabled("LESAVKA_UVC_FRAME_SIZE_GUARD", true) +fn uvc_bulk_transfer_enabled() -> bool { + uvc_bulk_transfer_enabled_for_base(std::path::Path::new(CONFIGFS_UVC_BASE)) } -fn uvc_bulk_transfer_enabled() -> bool { - if !env_flag_enabled("LESAVKA_UVC_BULK", true) { - return false; - } - let base = std::path::Path::new(CONFIGFS_UVC_BASE); - if base.exists() && !base.join("streaming_bulk").exists() { +fn uvc_bulk_transfer_enabled_for_base(base: &std::path::Path) -> bool { + let requested = env_flag_enabled("LESAVKA_UVC_BULK", true); + let bulk = uvc_contract::bulk_enabled_from_configfs(base, requested); + if requested && !bulk { eprintln!( - "[lesavka-uvc] UVC bulk requested but live configfs has no streaming_bulk; using isochronous payload sizing" + "[lesavka-uvc] UVC bulk requested but live configfs streaming_bulk is not 1; using isochronous payload sizing" ); - return false; } - true + bulk } fn uvc_frame_size_for_active_mode( @@ -785,12 +882,12 @@ fn uvc_frame_size_for_active_mode( max_packet: u32, bulk: bool, ) -> u32 { - env_u32_opt("LESAVKA_UVC_FRAME_SIZE") - .unwrap_or_else(|| { - derived_uvc_frame_max_bytes_for_transport(fps, max_packet, bulk) - .min(u32::MAX as usize) as u32 - }) - .max((width.saturating_mul(height) / 32).max(64 * 1024)) + let derived = derived_uvc_frame_max_bytes_for_transport(fps, max_packet, bulk) + .min(u32::MAX as usize) as u32; + let requested = env_u32_opt("LESAVKA_UVC_FRAME_SIZE").filter(|value| *value > 0); + let frame_size = requested.unwrap_or(derived).min(derived); + let minimum = (width.saturating_mul(height) / 32).max(64 * 1024); + frame_size.max(minimum.min(derived)) } fn write_atomic_text(path: &std::path::Path, text: &str) -> Result<()> { @@ -804,23 +901,43 @@ fn write_atomic_text(path: &std::path::Path, text: &str) -> Result<()> { } fn uvc_stats_snapshot_json(stats: &UvcVideoStats, frame_cap: usize) -> String { + let kernel = uvc_kernel_stats_json(); format!( - "{{\"queued\":{},\"reloaded\":{},\"stale_replay\":{},\"rejected_oversize\":{},\"rejected_invalid\":{},\"fallback_idle\":{},\"latest_bytes\":{},\"frame_cap\":{},\"last_rejected_oversize_bytes\":{},\"last_rejected_oversize_cap\":{},\"paced_sleeps\":{},\"paced_sleep_ms\":{}}}\n", + "{{\"generated_unix_ms\":{},\"queued\":{},\"reloaded\":{},\"stale_replay\":{},\"rejected_oversize\":{},\"rejected_invalid\":{},\"fallback_idle\":{},\"held_last_good\":{},\"read_errors\":{},\"strict_validation_failures\":{},\"dqbuf_ioctl_errors\":{},\"dqbuf_flag_errors\":{},\"qbuf_ioctl_errors\":{},\"latest_bytes\":{},\"frame_cap\":{},\"last_rejected_oversize_bytes\":{},\"last_rejected_oversize_cap\":{},\"paced_sleeps\":{},\"paced_sleep_ms\":{},\"kernel\":{}}}\n", + unix_now_ns() / 1_000_000, stats.queued, stats.reloaded, stats.replayed_stale, stats.rejected_oversize, stats.rejected_invalid, stats.fallback_idle, + stats.held_last_good, + stats.read_errors, + stats.strict_validation_failures, + stats.dqbuf_ioctl_errors, + stats.dqbuf_flag_errors, + stats.qbuf_ioctl_errors, stats.latest_bytes, frame_cap, stats.last_rejected_oversize_bytes, stats.last_rejected_oversize_cap, stats.paced_sleeps, - stats.paced_sleep_ms + stats.paced_sleep_ms, + kernel ) } +fn uvc_kernel_stats_json() -> String { + let path = env::var("LESAVKA_UVC_KERNEL_STATS_PATH") + .map(std::path::PathBuf::from) + .unwrap_or_else(|_| std::path::PathBuf::from(DEFAULT_UVC_KERNEL_STATS_PATH)); + std::fs::read_to_string(path) + .ok() + .and_then(|value| serde_json::from_str::(&value).ok()) + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()) +} + fn uvc_stats_interval() -> Option { match env_u64("LESAVKA_UVC_STATS_INTERVAL_MS", DEFAULT_UVC_STATS_INTERVAL_MS) { 0 => None, @@ -838,6 +955,80 @@ fn frame_spool_max_age() -> Option { } } +fn uvc_idle_after_stale() -> Duration { + let value = env::var("LESAVKA_UVC_IDLE_AFTER_MS") + .ok() + .and_then(|value| value.parse::().ok()) + .or_else(|| { + env::var("LESAVKA_UVC_IDLE_AFTER_STALE_MS") + .ok() + .and_then(|value| value.parse::().ok()) + }) + .unwrap_or(DEFAULT_UVC_IDLE_AFTER_STALE_MS); + Duration::from_millis(value) +} + +fn verified_frame_idle_timeout_elapsed(modified: Option) -> bool { + let Some(modified) = modified else { + return true; + }; + SystemTime::now() + .duration_since(modified) + .map(|age| age >= uvc_idle_after_stale()) + .unwrap_or(false) +} + +fn frame_spool_modified(path: &std::path::Path) -> Option { + std::fs::metadata(path).ok()?.modified().ok() +} + +fn frame_spool_mtime_ns(path: &std::path::Path) -> Option { + frame_spool_modified(path)? + .duration_since(SystemTime::UNIX_EPOCH) + .ok() + .map(|duration| duration.as_nanos()) +} + +fn strict_validation_due(reloaded: u64) -> bool { + match env_u64("LESAVKA_UVC_STRICT_VALIDATE_EVERY", 30) { + 0 => false, + every => reloaded % every == 0, + } +} + +fn uvc_handoff_audit_path() -> Option { + if let Ok(value) = env::var("LESAVKA_UVC_HANDOFF_AUDIT_PATH") { + let value = value.trim(); + return (!value.is_empty() && value != "0").then(|| std::path::PathBuf::from(value)); + } + env_flag_enabled("LESAVKA_UVC_HANDOFF_AUDIT", false) + .then(|| std::path::PathBuf::from(DEFAULT_UVC_HANDOFF_AUDIT_PATH)) +} + +fn uvc_handoff_audit_max_records() -> usize { + env_u64( + "LESAVKA_UVC_HANDOFF_AUDIT_MAX_RECORDS", + DEFAULT_UVC_HANDOFF_AUDIT_MAX_RECORDS as u64, + ) + .min(16_384) as usize +} + +fn fnv1a64(data: &[u8]) -> u64 { + let mut hash = 0xcbf2_9ce4_8422_2325u64; + for byte in data { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash +} + +fn unix_now_ns() -> u128 { + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() +} + fn frame_spool_is_stale(path: &std::path::Path, max_age: Option) -> bool { let Some(max_age) = max_age else { return false; @@ -1069,7 +1260,8 @@ impl UvcConfig { } else { requested_interval }; - if let Some(snapshot) = read_configfs_snapshot() { + let live_snapshot = read_configfs_snapshot(); + if let Some(snapshot) = live_snapshot { let live_interval = if snapshot.default_interval == 0 { snapshot.frame_interval } else { @@ -1149,7 +1341,13 @@ impl UvcConfig { } else { max_packet = max_packet.min(1024); } - let frame_size = uvc_frame_size_for_active_mode(width, height, fps, max_packet, bulk); + let frame_size = uvc_frame_size_for_active_mode(width, height, fps, max_packet, bulk) + .min( + live_snapshot + .map(|snapshot| snapshot.frame_size) + .filter(|frame_size| *frame_size > 0) + .unwrap_or(u32::MAX), + ); Self { width, @@ -1168,14 +1366,77 @@ impl UvcState { let _profile_hint = (cfg.width, cfg.height, cfg.fps); let ctrl_len = stream_ctrl_len(); let default = build_streaming_control(&cfg, ctrl_len); - Self { + let state = Self { cfg, ctrl_len, default, probe: default, commit: default, cfg_snapshot: None, + commit_observed: false, + }; + state.publish_payload_contract("startup"); + state + } + + fn payload_contract(&self) -> UvcPayloadContract { + let snapshot = read_configfs_snapshot(); + let streaming_bulk_value = snapshot + .and_then(|snapshot| snapshot.streaming_bulk) + .or_else(|| { + uvc_contract::read_streaming_bulk_value(std::path::Path::new(CONFIGFS_UVC_BASE)) + }); + UvcPayloadContract::from_input(UvcContractInput { + configfs_available: snapshot.is_some(), + commit_observed: self.commit_observed, + bulk: self.cfg.bulk, + streaming_bulk_value, + width: self.cfg.width, + height: self.cfg.height, + fps: self.cfg.fps, + frame_interval_100ns: read_le32(&self.commit, 4).max(self.cfg.interval), + streaming_maxpacket: snapshot + .map(|value| value.maxpacket) + .unwrap_or(self.cfg.max_packet), + streaming_maxburst: snapshot.map(|value| value.maxburst).unwrap_or(0), + configured_budget_bytes_per_sec: env_u32( + "LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", + DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC, + ), + isochronous_limit_pct: env_u32( + "LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT", + DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT, + ), + advertised_frame_bytes: snapshot + .map(|value| value.frame_size) + .unwrap_or(self.cfg.frame_size), + committed_frame_bytes: read_le32(&self.commit, 18), + committed_payload_bytes: read_le32(&self.commit, 22), + requested_frame_cap_bytes: env_u32_opt("LESAVKA_UVC_FRAME_MAX_BYTES"), + }) + } + + fn publish_payload_contract(&self, reason: &str) { + let contract = self.payload_contract(); + let path = uvc_contract::contract_path(); + if let Err(err) = uvc_contract::write_contract(&path, &contract) { + eprintln!( + "[lesavka-uvc] failed to publish UVC payload contract {}: {err:#}", + path.display() + ); + return; } + eprintln!( + "[lesavka-uvc] payload contract reason={} transport={} frame_cap={} advertised={} committed={} payload={} divergent={} path={}", + reason, + contract.transport, + contract.enforced_frame_cap_bytes, + contract.advertised_frame_bytes, + contract.committed_frame_bytes, + contract.committed_payload_bytes, + contract.divergent, + path.display() + ); } } @@ -1456,6 +1717,7 @@ fn handle_data( let sanitized = sanitize_streaming_control(slice, state); if p.selector == UVC_VS_PROBE_CONTROL { state.probe = sanitized; + state.publish_payload_contract("probe"); if debug { let interval = read_le32(&state.probe, 4); let payload = read_le32(&state.probe, 22); @@ -1467,6 +1729,8 @@ fn handle_data( } } else { state.commit = sanitized; + state.commit_observed = true; + state.publish_payload_contract("commit"); if debug { let interval = read_le32(&state.commit, 4); let payload = read_le32(&state.commit, 22); @@ -1716,15 +1980,20 @@ fn read_configfs_snapshot() -> Option { "{frame_root}/dwDefaultFrameInterval" ))?; let frame_interval = read_u32_first(&format!("{frame_root}/dwFrameInterval")).unwrap_or(0); + let frame_size = read_u32_file(&format!("{frame_root}/dwMaxVideoFrameBufferSize"))?; let maxpacket = read_u32_file(&format!("{CONFIGFS_UVC_BASE}/streaming_maxpacket"))?; let maxburst = read_u32_file(&format!("{CONFIGFS_UVC_BASE}/streaming_maxburst")).unwrap_or(0); + let streaming_bulk = + uvc_contract::read_streaming_bulk_value(std::path::Path::new(CONFIGFS_UVC_BASE)); Some(ConfigfsSnapshot { width, height, default_interval, frame_interval, + frame_size, maxpacket, maxburst, + streaming_bulk, }) } @@ -1747,13 +2016,15 @@ fn log_configfs_snapshot(state: &mut UvcState, label: &str) { return; } eprintln!( - "[lesavka-uvc] configfs {label}: {}x{} default_interval={} frame_interval={} maxpacket={} maxburst={}", + "[lesavka-uvc] configfs {label}: {}x{} default_interval={} frame_interval={} frame_size={} maxpacket={} maxburst={} streaming_bulk={:?}", current.width, current.height, current.default_interval, current.frame_interval, + current.frame_size, current.maxpacket, - current.maxburst + current.maxburst, + current.streaming_bulk ); state.cfg_snapshot = Some(current); } diff --git a/server/src/bin/lesavka_synthetic_uplink/support.rs b/server/src/bin/lesavka_synthetic_uplink/support.rs index 4d840a0..17455ca 100644 --- a/server/src/bin/lesavka_synthetic_uplink/support.rs +++ b/server/src/bin/lesavka_synthetic_uplink/support.rs @@ -81,6 +81,9 @@ fn synthetic_luma( let (moving_width, moving_offset) = moving_bar; let width = width.max(1); let height = height.max(1); + if let Some(value) = synthetic_band_marker_luma(width, height, x, y, sequence) { + return value; + } let block_w = (width / 24).max(24); let block_h = (height / 18).max(18); let base = 44 @@ -107,6 +110,36 @@ fn synthetic_luma( value } +fn synthetic_band_marker_luma( + width: usize, + height: usize, + x: usize, + y: usize, + sequence: u64, +) -> Option { + let marker_width = BAND_MARKER_BITS * BAND_MARKER_CELL; + if width < marker_width + 8 || height < BAND_MARKER_ROWS { + return None; + } + let band = y / BAND_MARKER_ROWS; + let band_y = band * BAND_MARKER_ROWS + 4; + if !(band_y..band_y + BAND_MARKER_CELL * 2).contains(&y) { + return None; + } + let x0 = width - marker_width - 4; + if !(x0..x0 + marker_width).contains(&x) { + return None; + } + let bit = (x - x0) / BAND_MARKER_CELL; + let marker = (sequence as u32 & 0x00ff_ffff) | ((band as u32 & 0xff) << 24); + Some(if marker & (1 << bit) != 0 { 245 } else { 12 }) +} + +fn add_jpeg_app4_integrity(jpeg: &[u8], sequence: u64) -> Result> { + lesavka_server::uvc_contract::add_lesavka_app4_integrity(jpeg, sequence) + .context("synthetic encoder returned an incomplete JPEG") +} + fn marker_cell(width: usize, height: usize) -> usize { (width.min(height) / 80).clamp(6, 16) } diff --git a/server/src/bin/lesavka_synthetic_uplink/tests/mod.rs b/server/src/bin/lesavka_synthetic_uplink/tests/mod.rs index 9b1c587..52b25b5 100644 --- a/server/src/bin/lesavka_synthetic_uplink/tests/mod.rs +++ b/server/src/bin/lesavka_synthetic_uplink/tests/mod.rs @@ -88,6 +88,27 @@ fn synthetic_frame_helpers_handle_markers_bounds_and_silence() { fill_rect(&mut frame, 6, 4, 2, 8, 8, 77); assert_eq!(&frame[(2 * 6 + 4) * 3..(2 * 6 + 5) * 3], &[77, 77, 77]); assert_eq!(&frame[(3 * 6 + 5) * 3..(3 * 6 + 6) * 3], &[77, 77, 77]); + + let first_band = synthetic_band_marker_luma(1280, 720, 1244, 4, 0x55aa); + let second_band = synthetic_band_marker_luma(1280, 720, 1244, 68, 0x55aa); + assert!(first_band.is_some()); + assert_ne!(first_band, second_band); +} + +#[test] +fn app4_integrity_marker_carries_sequence_and_original_jpeg_crc() { + let jpeg = [0xff, 0xd8, 0xff, 0xda, 0x00, 0x02, 0x11, 0xff, 0xd9]; + let marked = add_jpeg_app4_integrity(&jpeg, 0x0102_0304_0506_0708).expect("APP4 marker"); + + assert_eq!(&marked[..4], &[0xff, 0xd8, 0xff, 0xe4]); + assert_eq!(&marked[6..10], b"LSVK"); + assert_eq!(marked[10], 1); + assert_eq!(&marked[11..19], &0x0102_0304_0506_0708u64.to_be_bytes()); + assert_eq!( + &marked[19..23], + &lesavka_server::uvc_contract::crc32_ieee(&jpeg).to_be_bytes() + ); + assert_eq!(&marked[23..], &jpeg[2..]); } #[test] diff --git a/server/src/bin/lesavka_uvc/control_requests.rs b/server/src/bin/lesavka_uvc/control_requests.rs index 432056e..5370bb9 100644 --- a/server/src/bin/lesavka_uvc/control_requests.rs +++ b/server/src/bin/lesavka_uvc/control_requests.rs @@ -100,6 +100,7 @@ fn handle_data( state.probe = sanitized; } else { state.commit = sanitized; + state.commit_observed = true; } } diff --git a/server/src/bin/lesavka_uvc/coverage_config.rs b/server/src/bin/lesavka_uvc/coverage_config.rs index 692aa8d..ebf7353 100644 --- a/server/src/bin/lesavka_uvc/coverage_config.rs +++ b/server/src/bin/lesavka_uvc/coverage_config.rs @@ -57,6 +57,7 @@ impl UvcState { probe: default, commit: default, cfg_snapshot: None, + commit_observed: false, } } } @@ -122,10 +123,10 @@ fn uvc_bulk_transfer_enabled() -> bool { /// Inputs: configfs function root. Output: effective bulk capability. Why: /// tests need this branch without depending on the host's real gadget tree. fn uvc_bulk_transfer_enabled_for_base(base: &std::path::Path) -> bool { - if base.exists() && !base.join("streaming_bulk").exists() { - return false; - } - true + lesavka_server::uvc_contract::bulk_enabled_from_configfs( + base, + env_flag_enabled("LESAVKA_UVC_BULK", true), + ) } #[cfg(coverage)] @@ -136,10 +137,11 @@ fn uvc_frame_size_for_active_mode( max_packet: u32, bulk: bool, ) -> u32 { + let derived = derived_uvc_frame_max_bytes_for_transport(fps, max_packet, bulk) + .min(u32::MAX as usize) as u32; env_u32_opt("LESAVKA_UVC_FRAME_SIZE") - .unwrap_or_else(|| { - derived_uvc_frame_max_bytes_for_transport(fps, max_packet, bulk) - .min(u32::MAX as usize) as u32 - }) - .max((width.saturating_mul(height) / 32).max(64 * 1024)) + .filter(|value| *value > 0) + .unwrap_or(derived) + .min(derived) + .max((width.saturating_mul(height) / 32).max(64 * 1024).min(derived)) } diff --git a/server/src/bin/lesavka_uvc/coverage_frame_budget.rs b/server/src/bin/lesavka_uvc/coverage_frame_budget.rs index 194ead6..5405af8 100644 --- a/server/src/bin/lesavka_uvc/coverage_frame_budget.rs +++ b/server/src/bin/lesavka_uvc/coverage_frame_budget.rs @@ -43,18 +43,19 @@ fn uvc_queue_period(fps: u32) -> Option { /// byte length. Why: coverage tests should lock the artifact-prevention budget /// that turns oversized UVC frames into freezes instead of grey half-frames. fn uvc_frame_max_bytes(cfg: UvcConfig) -> usize { - if !uvc_frame_size_guard_enabled() { - return MAX_MJPEG_FRAME_BYTES; + if let Ok(contract) = lesavka_server::uvc_contract::read_contract( + &lesavka_server::uvc_contract::contract_path(), + ) { + return contract + .enforced_frame_cap_bytes + .min(cfg.frame_size) + .min(MAX_MJPEG_FRAME_BYTES as u32) as usize; } - if let Some(limit) = env_u32_opt("LESAVKA_UVC_FRAME_MAX_BYTES") { - return if limit == 0 { - derived_uvc_frame_max_bytes(cfg) - } else { - (limit as usize).min(MAX_MJPEG_FRAME_BYTES) - }; - } - - derived_uvc_frame_max_bytes(cfg) + let derived = derived_uvc_frame_max_bytes(cfg).min(cfg.frame_size as usize); + env_u32_opt("LESAVKA_UVC_FRAME_MAX_BYTES") + .filter(|limit| *limit > 0) + .map(|limit| derived.min(limit as usize)) + .unwrap_or(derived) } #[cfg(coverage)] @@ -64,10 +65,8 @@ fn derived_uvc_frame_max_bytes(cfg: UvcConfig) -> usize { #[cfg(coverage)] fn derived_uvc_frame_max_bytes_for_transport(fps: u32, max_packet: u32, bulk: bool) -> usize { - let fps = fps.max(1); let budget_per_sec = effective_uvc_mjpeg_budget_bytes_per_sec(max_packet, bulk); - let per_frame = (budget_per_sec / fps).max(64 * 1024); - per_frame.min(MAX_MJPEG_FRAME_BYTES as u32) as usize + lesavka_server::uvc_contract::frame_cap_from_budget(budget_per_sec, fps) as usize } #[cfg(coverage)] @@ -81,31 +80,16 @@ fn effective_uvc_mjpeg_budget_bytes_per_sec(max_packet: u32, bulk: bool) -> u32 DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC, ) .max(1); - if bulk { - return configured; - } - - configured - .min(uvc_isochronous_budget_bytes_per_sec(max_packet)) - .max(1) -} - -#[cfg(coverage)] -/// Computes the high-speed isochronous payload budget. -/// -/// Inputs: endpoint packet size and `LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT`. -/// Output: capped bytes per second. Why: coverage locks the safety margin. -fn uvc_isochronous_budget_bytes_per_sec(max_packet: u32) -> u32 { let pct = env_u32( "LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT", DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT, + ); + lesavka_server::uvc_contract::effective_mjpeg_budget_bytes_per_sec( + configured, + max_packet, + bulk, + pct, ) - .clamp(1, 100); - let bytes = u64::from(max_packet) - .saturating_mul(u64::from(HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC)) - .saturating_mul(u64::from(pct)) - / 100; - bytes.min(u64::from(u32::MAX)) as u32 } #[cfg(coverage)] diff --git a/server/src/bin/lesavka_uvc/coverage_model.rs b/server/src/bin/lesavka_uvc/coverage_model.rs index c0bff2c..42e7f6d 100644 --- a/server/src/bin/lesavka_uvc/coverage_model.rs +++ b/server/src/bin/lesavka_uvc/coverage_model.rs @@ -1,5 +1,7 @@ use anyhow::{Context, Result}; #[cfg(coverage)] +use lesavka_server::uvc_contract; +#[cfg(coverage)] use std::env; #[cfg(coverage)] use std::fs::OpenOptions; @@ -63,12 +65,14 @@ const DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC: u32 = 4_500_000; #[cfg(coverage)] const DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT: u32 = 85; #[cfg(coverage)] -const HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC: u32 = 8_000; -#[cfg(coverage)] const DEFAULT_UVC_STATS_INTERVAL_MS: u64 = 5_000; #[cfg(coverage)] const DEFAULT_UVC_STATS_PATH: &str = "/run/lesavka-uvc-video-stats.json"; #[cfg(coverage)] +const DEFAULT_UVC_IDLE_AFTER_STALE_MS: u64 = 2_000; +#[cfg(coverage)] +const DEFAULT_UVC_KERNEL_STATS_PATH: &str = "/run/lesavka-uvc-kernel-stats.json"; +#[cfg(coverage)] const MAX_MJPEG_FRAME_BYTES: usize = 8 * 1024 * 1024; #[cfg(coverage)] const MINIMAL_MJPEG_FRAME: &[u8] = &[0xff, 0xd8, 0xff, 0xd9]; @@ -130,6 +134,7 @@ struct UvcState { probe: [u8; STREAM_CTRL_SIZE_MAX], commit: [u8; STREAM_CTRL_SIZE_MAX], cfg_snapshot: Option, + commit_observed: bool, } #[cfg(coverage)] @@ -154,8 +159,10 @@ struct ConfigfsSnapshot { height: u32, default_interval: u32, frame_interval: u32, + frame_size: u32, maxpacket: u32, maxburst: u32, + streaming_bulk: Option, } #[cfg(coverage)] @@ -175,6 +182,8 @@ struct UvcVideoStream { next_queue_at: Option, transport_bulk: bool, max_packet: u32, + has_verified_frame: bool, + last_verified_mtime: Option, } #[cfg(coverage)] @@ -186,6 +195,12 @@ struct UvcVideoStats { rejected_oversize: u64, rejected_invalid: u64, fallback_idle: u64, + held_last_good: u64, + read_errors: u64, + strict_validation_failures: u64, + dqbuf_ioctl_errors: u64, + dqbuf_flag_errors: u64, + qbuf_ioctl_errors: u64, latest_bytes: usize, last_rejected_oversize_bytes: usize, last_rejected_oversize_cap: usize, @@ -212,6 +227,8 @@ impl UvcVideoStream { next_queue_at: None, transport_bulk: false, max_packet: 0, + has_verified_frame: false, + last_verified_mtime: None, } } @@ -224,7 +241,7 @@ impl UvcVideoStream { let stale = frame_spool_is_stale(&self.frame_path, frame_spool_max_age()); if stale { self.stats.replayed_stale += 1; - self.replace_latest_with_idle(); + self.hold_last_good_or_idle(); return; } let max_frame_bytes = self.frame_payload_limit(); @@ -233,6 +250,8 @@ impl UvcVideoStream { self.stats.reloaded += 1; self.stats.latest_bytes = frame.len(); self.latest_frame = frame; + self.has_verified_frame = true; + self.last_verified_mtime = frame_spool_modified(&self.frame_path); } else { if frame.len() > max_frame_bytes && looks_like_mjpeg_frame(&frame) { self.stats.rejected_oversize += 1; @@ -241,16 +260,25 @@ impl UvcVideoStream { } else { self.stats.rejected_invalid += 1; } - self.replace_latest_with_idle(); + self.hold_last_good_or_idle(); } } else { - self.replace_latest_with_idle(); + self.stats.read_errors += 1; + self.hold_last_good_or_idle(); } if !looks_like_mjpeg_frame(&self.latest_frame) { self.replace_latest_with_idle(); } } + fn hold_last_good_or_idle(&mut self) { + if self.has_verified_frame && !verified_frame_idle_timeout_elapsed(self.last_verified_mtime) { + self.stats.held_last_good += 1; + } else { + self.replace_latest_with_idle(); + } + } + fn replace_latest_with_idle(&mut self) { if self.latest_frame.as_slice() != IDLE_MJPEG_FRAME { self.stats.fallback_idle += 1; diff --git a/server/src/bin/lesavka_uvc/coverage_stats.rs b/server/src/bin/lesavka_uvc/coverage_stats.rs index f809876..6869f67 100644 --- a/server/src/bin/lesavka_uvc/coverage_stats.rs +++ b/server/src/bin/lesavka_uvc/coverage_stats.rs @@ -38,23 +38,43 @@ fn write_atomic_text(path: &std::path::Path, text: &str) -> Result<()> { /// Inputs: current counters and frame cap. Output: one JSON object string. Why: /// shell probes need stable fields without depending on Rust serialization. fn uvc_stats_snapshot_json(stats: &UvcVideoStats, frame_cap: usize) -> String { + let kernel = uvc_kernel_stats_json(); format!( - "{{\"queued\":{},\"reloaded\":{},\"stale_replay\":{},\"rejected_oversize\":{},\"rejected_invalid\":{},\"fallback_idle\":{},\"latest_bytes\":{},\"frame_cap\":{},\"last_rejected_oversize_bytes\":{},\"last_rejected_oversize_cap\":{},\"paced_sleeps\":{},\"paced_sleep_ms\":{}}}\n", + "{{\"generated_unix_ms\":0,\"queued\":{},\"reloaded\":{},\"stale_replay\":{},\"rejected_oversize\":{},\"rejected_invalid\":{},\"fallback_idle\":{},\"held_last_good\":{},\"read_errors\":{},\"strict_validation_failures\":{},\"dqbuf_ioctl_errors\":{},\"dqbuf_flag_errors\":{},\"qbuf_ioctl_errors\":{},\"latest_bytes\":{},\"frame_cap\":{},\"last_rejected_oversize_bytes\":{},\"last_rejected_oversize_cap\":{},\"paced_sleeps\":{},\"paced_sleep_ms\":{},\"kernel\":{}}}\n", stats.queued, stats.reloaded, stats.replayed_stale, stats.rejected_oversize, stats.rejected_invalid, stats.fallback_idle, + stats.held_last_good, + stats.read_errors, + stats.strict_validation_failures, + stats.dqbuf_ioctl_errors, + stats.dqbuf_flag_errors, + stats.qbuf_ioctl_errors, stats.latest_bytes, frame_cap, stats.last_rejected_oversize_bytes, stats.last_rejected_oversize_cap, stats.paced_sleeps, - stats.paced_sleep_ms + stats.paced_sleep_ms, + kernel ) } +#[cfg(coverage)] +fn uvc_kernel_stats_json() -> String { + let path = std::env::var("LESAVKA_UVC_KERNEL_STATS_PATH") + .map(std::path::PathBuf::from) + .unwrap_or_else(|_| std::path::PathBuf::from(DEFAULT_UVC_KERNEL_STATS_PATH)); + std::fs::read_to_string(path) + .ok() + .and_then(|value| serde_json::from_str::(&value).ok()) + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()) +} + #[cfg(coverage)] /// Returns the optional periodic stats write interval. /// @@ -104,3 +124,26 @@ fn frame_spool_is_stale(path: &std::path::Path, max_age: Option max_age) .unwrap_or(false) } + +#[cfg(coverage)] +fn frame_spool_modified(path: &std::path::Path) -> Option { + std::fs::metadata(path).ok()?.modified().ok() +} + +#[cfg(coverage)] +fn verified_frame_idle_timeout_elapsed(modified: Option) -> bool { + let Some(modified) = modified else { + return true; + }; + std::time::SystemTime::now() + .duration_since(modified) + .map(|age| { + age >= std::time::Duration::from_millis( + std::env::var("LESAVKA_UVC_IDLE_AFTER_MS") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(DEFAULT_UVC_IDLE_AFTER_STALE_MS), + ) + }) + .unwrap_or(false) +} diff --git a/server/src/bin/tests/lesavka_uvc.rs b/server/src/bin/tests/lesavka_uvc.rs index 192be78..0a67bae 100644 --- a/server/src/bin/tests/lesavka_uvc.rs +++ b/server/src/bin/tests/lesavka_uvc.rs @@ -91,7 +91,7 @@ fn uvc_frame_max_bytes_defaults_to_freshness_budget_and_allows_override() { ("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", Some("1")), ], || { - assert_eq!(uvc_frame_max_bytes(sample_cfg()), 123_456); + assert_eq!(uvc_frame_max_bytes(sample_cfg()), 65_536); }, ); @@ -105,7 +105,7 @@ fn uvc_frame_max_bytes_defaults_to_freshness_budget_and_allows_override() { ("LESAVKA_UVC_FRAME_MAX_BYTES", Some("123456")), ], || { - assert_eq!(uvc_frame_max_bytes(sample_cfg()), MAX_MJPEG_FRAME_BYTES); + assert_eq!(uvc_frame_max_bytes(sample_cfg()), 123_456); }, ); @@ -159,7 +159,9 @@ fn uvc_env_flags_and_bulk_configfs_edges_are_covered() { let configfs = tempfile::tempdir().expect("tempdir"); assert!(!uvc_bulk_transfer_enabled_for_base(configfs.path())); - fs::create_dir(configfs.path().join("streaming_bulk")).expect("streaming_bulk dir"); + fs::write(configfs.path().join("streaming_bulk"), "0\n").expect("streaming_bulk zero"); + assert!(!uvc_bulk_transfer_enabled_for_base(configfs.path())); + fs::write(configfs.path().join("streaming_bulk"), "1\n").expect("streaming_bulk one"); assert!(uvc_bulk_transfer_enabled_for_base(configfs.path())); } @@ -243,6 +245,50 @@ fn uvc_video_stream_refresh_covers_fresh_invalid_and_missing_frames() { ); } +#[test] +fn uvc_video_stream_holds_last_good_frame_during_transient_rejection() { + let frame = NamedTempFile::new().expect("frame"); + let good = vec![0xff, 0xd8, 1, 2, 3, 0xff, 0xd9]; + fs::write(frame.path(), &good).expect("write good frame"); + let mut stream = UvcVideoStream::new(-1); + stream.frame_path = frame.path().to_path_buf(); + stream.frame_max_bytes = 8; + stream.refresh_latest_frame(); + assert_eq!(stream.latest_frame, good); + + fs::write( + frame.path(), + [0xff, 0xd8, 1, 2, 3, 4, 5, 6, 7, 8, 0xff, 0xd9], + ) + .expect("write oversized frame"); + stream.refresh_latest_frame(); + + assert_eq!(stream.latest_frame, good); + assert_eq!(stream.stats.rejected_oversize, 1); + assert_eq!(stream.stats.held_last_good, 1); + assert_eq!(stream.stats.fallback_idle, 0); +} + +#[test] +fn uvc_video_stream_uses_idle_only_after_last_good_timeout() { + let frame = NamedTempFile::new().expect("frame"); + let good = vec![0xff, 0xd8, 1, 2, 3, 0xff, 0xd9]; + fs::write(frame.path(), &good).expect("write good frame"); + let mut stream = UvcVideoStream::new(-1); + stream.frame_path = frame.path().to_path_buf(); + stream.refresh_latest_frame(); + assert_eq!(stream.latest_frame, good); + + stream.last_verified_mtime = Some(std::time::SystemTime::UNIX_EPOCH); + fs::write(frame.path(), b"truncated").expect("write invalid frame"); + stream.refresh_latest_frame(); + + assert_eq!(stream.stats.rejected_invalid, 1); + assert_eq!(stream.stats.held_last_good, 0); + assert_eq!(stream.stats.fallback_idle, 1); + assert_eq!(stream.latest_frame, IDLE_MJPEG_FRAME); +} + #[test] #[serial] fn main_coverage_mode_returns_error_for_non_uvc_node() { diff --git a/server/src/lib.rs b/server/src/lib.rs index 55c6462..832e591 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -20,6 +20,7 @@ pub mod paste; pub mod runtime_support; pub mod security; pub mod upstream_media_runtime; +pub mod uvc_contract; pub mod uvc_runtime; pub mod video; pub(crate) mod video_sinks; diff --git a/server/src/main.rs b/server/src/main.rs index b90e128..74a6f13 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -39,7 +39,7 @@ use lesavka_server::{ PlannedUpstreamPacket, UpstreamClientTiming, UpstreamMediaKind, UpstreamMediaRuntime, UpstreamPlanDecision, }, - uvc_runtime, video, + uvc_contract, uvc_runtime, video, }; /*──────────────── constants ────────────────*/ diff --git a/server/src/main/rpc_helpers.rs b/server/src/main/rpc_helpers.rs index d7466ef..70d3b09 100644 --- a/server/src/main/rpc_helpers.rs +++ b/server/src/main/rpc_helpers.rs @@ -1,3 +1,134 @@ +#[derive(Default)] +struct UvcIntegritySnapshot { + status: String, + detail: String, + rejected_oversize: u64, + rejected_invalid: u64, + fallback_idle: u64, + held_last_good: u64, + strict_validation_failures: u64, + dqbuf_errors: u64, + qbuf_errors: u64, + kernel_errors: u64, + stale_replay: u64, + read_errors: u64, + generated_unix_ms: Option, +} + +fn read_uvc_integrity_snapshot() -> UvcIntegritySnapshot { + let path = std::env::var("LESAVKA_UVC_STATS_PATH") + .map(std::path::PathBuf::from) + .unwrap_or_else(|_| std::path::PathBuf::from("/run/lesavka-uvc-video-stats.json")); + match std::fs::read(&path) + .map_err(anyhow::Error::from) + .and_then(|bytes| serde_json::from_slice::(&bytes).map_err(Into::into)) + { + Ok(value) => parse_uvc_integrity_snapshot(&value), + Err(err) => { + UvcIntegritySnapshot { + status: "unavailable".to_string(), + detail: format!("{}: {err}", path.display()), + ..Default::default() + } + } + } +} + +fn parse_uvc_integrity_snapshot(value: &serde_json::Value) -> UvcIntegritySnapshot { + let number = |name: &str| value.get(name).and_then(serde_json::Value::as_u64).unwrap_or(0); + let kernel_number = |name: &str| { + value + .pointer(&format!("/kernel/counters/{name}")) + .and_then(serde_json::Value::as_u64) + .unwrap_or(0) + }; + let rejected_oversize = number("rejected_oversize"); + let rejected_invalid = number("rejected_invalid"); + let fallback_idle = number("fallback_idle"); + let held_last_good = number("held_last_good"); + let strict_validation_failures = number("strict_validation_failures"); + let dqbuf_errors = number("dqbuf_ioctl_errors").saturating_add(number("dqbuf_flag_errors")); + let qbuf_errors = number("qbuf_ioctl_errors"); + let stale_replay = number("stale_replay"); + let read_errors = number("read_errors"); + let kernel_errors = [ + "dwc2_errors", + "uvc_errors", + "udc_errors", + "urb_errors", + "usb_resets", + ] + .into_iter() + .map(kernel_number) + .sum(); + let hard_faults = rejected_oversize + .saturating_add(rejected_invalid) + .saturating_add(fallback_idle) + .saturating_add(strict_validation_failures) + .saturating_add(dqbuf_errors) + .saturating_add(qbuf_errors) + .saturating_add(kernel_errors) + .saturating_add(read_errors); + let status = if hard_faults > 0 { + "fault" + } else if held_last_good > 0 || stale_replay > 0 { + "holding" + } else { + "ready" + }; + UvcIntegritySnapshot { + status: status.to_string(), + detail: format!( + "oversize={rejected_oversize} invalid={rejected_invalid} idle={fallback_idle} held={held_last_good} stale={stale_replay} strict={strict_validation_failures} dq={dqbuf_errors} q={qbuf_errors} read={read_errors} kernel={kernel_errors}" + ), + rejected_oversize, + rejected_invalid, + fallback_idle, + held_last_good, + strict_validation_failures, + dqbuf_errors, + qbuf_errors, + kernel_errors, + stale_replay, + read_errors, + generated_unix_ms: value + .get("generated_unix_ms") + .and_then(serde_json::Value::as_u64), + } +} + +#[cfg(test)] +mod uvc_integrity_snapshot_tests { + use super::parse_uvc_integrity_snapshot; + + #[test] + fn helper_and_kernel_faults_drive_integrity_status() { + let ready = parse_uvc_integrity_snapshot(&serde_json::json!({ + "generated_unix_ms": 123, + "held_last_good": 0, + "kernel": {"counters": {"dwc2_errors": 0}} + })); + assert_eq!(ready.status, "ready"); + assert_eq!(ready.generated_unix_ms, Some(123)); + + let holding = parse_uvc_integrity_snapshot(&serde_json::json!({ + "held_last_good": 2, + "stale_replay": 1 + })); + assert_eq!(holding.status, "holding"); + + let fault = parse_uvc_integrity_snapshot(&serde_json::json!({ + "rejected_oversize": 1, + "dqbuf_flag_errors": 2, + "kernel": {"counters": {"dwc2_errors": 3, "usb_resets": 4}} + })); + assert_eq!(fault.status, "fault"); + assert_eq!(fault.dqbuf_errors, 2); + assert_eq!(fault.kernel_errors, 7); + assert!(fault.detail.contains("oversize=1")); + } +} + impl Handler { /// Reopen HID handles and verify enumeration without cycling the USB gadget. async fn recover_usb_reply(&self) -> Result, Status> { @@ -175,6 +306,53 @@ impl Handler { /// Inputs are the typed parameters; output is the return value or side effect. async fn get_upstream_sync_reply(&self) -> Result, Status> { let snapshot = self.upstream_media_rt.snapshot(); + let contract_path = uvc_contract::contract_path(); + let contract = uvc_contract::read_contract(&contract_path); + let ( + uvc_contract_status, + uvc_transport, + uvc_enforced_frame_cap_bytes, + uvc_advertised_frame_bytes, + uvc_committed_frame_bytes, + uvc_committed_payload_bytes, + uvc_contract_divergent, + uvc_contract_detail, + uvc_contract_generated_unix_ms, + ) = match contract { + Ok(contract) => ( + if !contract.configfs_available { + "missing_configfs" + } else if !contract.commit_observed { + "unverified" + } else if contract.divergent { + "divergent" + } else { + "ready" + } + .to_string(), + contract.transport, + Some(contract.enforced_frame_cap_bytes), + Some(contract.advertised_frame_bytes), + Some(contract.committed_frame_bytes), + Some(contract.committed_payload_bytes), + contract.divergent, + contract.detail, + Some(contract.generated_unix_ms), + ), + Err(err) => ( + "missing_or_invalid".to_string(), + "unknown".to_string(), + None, + None, + None, + None, + true, + format!("{}: {err}", contract_path.display()), + None, + ), + }; + let uvc_contract_read_stats = uvc_contract::contract_read_stats(); + let uvc_integrity = read_uvc_integrity_snapshot(); Ok(Response::new(UpstreamSyncState { session_id: snapshot.session_id, phase: snapshot.phase.to_string(), @@ -237,6 +415,30 @@ impl Handler { .map(|value| value as f32), client_timing_window_samples: snapshot.client_timing_window_samples, sink_handoff_window_samples: snapshot.sink_handoff_window_samples, + uvc_contract_status, + uvc_transport, + uvc_enforced_frame_cap_bytes, + uvc_advertised_frame_bytes, + uvc_committed_frame_bytes, + uvc_committed_payload_bytes, + uvc_contract_divergent, + uvc_contract_detail, + uvc_contract_generated_unix_ms, + uvc_contract_missing_reads: uvc_contract_read_stats.missing_or_invalid, + uvc_contract_divergent_reads: uvc_contract_read_stats.divergent, + uvc_integrity_status: uvc_integrity.status, + uvc_integrity_detail: uvc_integrity.detail, + uvc_rejected_oversize: uvc_integrity.rejected_oversize, + uvc_rejected_invalid: uvc_integrity.rejected_invalid, + uvc_fallback_idle: uvc_integrity.fallback_idle, + uvc_held_last_good: uvc_integrity.held_last_good, + uvc_strict_validation_failures: uvc_integrity.strict_validation_failures, + uvc_dqbuf_errors: uvc_integrity.dqbuf_errors, + uvc_qbuf_errors: uvc_integrity.qbuf_errors, + uvc_kernel_errors: uvc_integrity.kernel_errors, + uvc_integrity_generated_unix_ms: uvc_integrity.generated_unix_ms, + uvc_stale_replay: uvc_integrity.stale_replay, + uvc_read_errors: uvc_integrity.read_errors, })) } } diff --git a/server/src/uvc_contract.rs b/server/src/uvc_contract.rs new file mode 100644 index 0000000..adddd3e --- /dev/null +++ b/server/src/uvc_contract.rs @@ -0,0 +1,566 @@ +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub const UVC_CONTRACT_SCHEMA: &str = "lesavka.uvc-payload-contract.v1"; +pub const DEFAULT_UVC_CONTRACT_PATH: &str = "/run/lesavka-uvc-contract.json"; +pub const DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC: u32 = 4_500_000; +pub const DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT: u32 = 85; +pub const MAX_MJPEG_FRAME_BYTES: u32 = 8 * 1024 * 1024; +pub const HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC: u32 = 8_000; +const LESAVKA_APP4_MAGIC: &[u8; 4] = b"LSVK"; +static CONTRACT_MISSING_READS: AtomicU64 = AtomicU64::new(0); +static CONTRACT_DIVERGENT_READS: AtomicU64 = AtomicU64::new(0); + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct UvcContractReadStats { + pub missing_or_invalid: u64, + pub divergent: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct UvcPayloadContract { + pub schema: String, + pub generated_unix_ms: u64, + pub configfs_available: bool, + pub commit_observed: bool, + pub transport: String, + pub transport_source: String, + pub streaming_bulk_value: Option, + pub width: u32, + pub height: u32, + pub fps: u32, + pub frame_interval_100ns: u32, + pub streaming_maxpacket: u32, + pub streaming_maxburst: u32, + pub configured_budget_bytes_per_sec: u32, + pub transport_budget_bytes_per_sec: u32, + pub advertised_frame_bytes: u32, + pub committed_frame_bytes: u32, + pub committed_payload_bytes: u32, + pub requested_frame_cap_bytes: Option, + pub enforced_frame_cap_bytes: u32, + pub divergent: bool, + pub detail: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct UvcContractInput { + pub configfs_available: bool, + pub commit_observed: bool, + pub bulk: bool, + pub streaming_bulk_value: Option, + pub width: u32, + pub height: u32, + pub fps: u32, + pub frame_interval_100ns: u32, + pub streaming_maxpacket: u32, + pub streaming_maxburst: u32, + pub configured_budget_bytes_per_sec: u32, + pub isochronous_limit_pct: u32, + pub advertised_frame_bytes: u32, + pub committed_frame_bytes: u32, + pub committed_payload_bytes: u32, + pub requested_frame_cap_bytes: Option, +} + +impl UvcPayloadContract { + pub fn from_input(input: UvcContractInput) -> Self { + let fps = input.fps.max(1); + let maxpacket = input.streaming_maxpacket.max(1); + let configured_budget = input.configured_budget_bytes_per_sec.max(1); + let transport_budget = effective_mjpeg_budget_bytes_per_sec( + configured_budget, + maxpacket, + input.bulk, + input.isochronous_limit_pct, + ); + let derived_frame_cap = frame_cap_from_budget(transport_budget, fps); + let advertised = nonzero_or(input.advertised_frame_bytes, derived_frame_cap); + let committed = nonzero_or(input.committed_frame_bytes, advertised); + let requested = input.requested_frame_cap_bytes.filter(|value| *value > 0); + let enforced = requested + .unwrap_or(derived_frame_cap) + .min(derived_frame_cap) + .min(advertised) + .min(committed) + .min(MAX_MJPEG_FRAME_BYTES) + .max(1); + let bulk_value_matches = if input.bulk { + input.streaming_bulk_value == Some(1) + } else { + input.streaming_bulk_value != Some(1) + }; + let divergent = !input.configfs_available + || !input.commit_observed + || !bulk_value_matches + || input.advertised_frame_bytes == 0 + || input.committed_frame_bytes == 0 + || input.committed_payload_bytes == 0 + || input.committed_payload_bytes > maxpacket; + let detail = if divergent { + format!( + "live UVC state differs: configfs_available={} commit_observed={} bulk_value={:?} advertised={} committed_frame={} committed_payload={} maxpacket={}", + input.configfs_available, + input.commit_observed, + input.streaming_bulk_value, + input.advertised_frame_bytes, + input.committed_frame_bytes, + input.committed_payload_bytes, + maxpacket + ) + } else { + "live configfs and committed UVC controls agree".to_string() + }; + + Self { + schema: UVC_CONTRACT_SCHEMA.to_string(), + generated_unix_ms: unix_time_ms(), + configfs_available: input.configfs_available, + commit_observed: input.commit_observed, + transport: if input.bulk { "bulk" } else { "isochronous" }.to_string(), + transport_source: "configfs.streaming_bulk.value".to_string(), + streaming_bulk_value: input.streaming_bulk_value, + width: input.width, + height: input.height, + fps, + frame_interval_100ns: input.frame_interval_100ns, + streaming_maxpacket: maxpacket, + streaming_maxburst: input.streaming_maxburst, + configured_budget_bytes_per_sec: configured_budget, + transport_budget_bytes_per_sec: transport_budget, + advertised_frame_bytes: advertised, + committed_frame_bytes: committed, + committed_payload_bytes: input.committed_payload_bytes, + requested_frame_cap_bytes: requested, + enforced_frame_cap_bytes: enforced, + divergent, + detail, + } + } + + pub fn is_usable(&self) -> bool { + self.schema == UVC_CONTRACT_SCHEMA + && self.enforced_frame_cap_bytes > 0 + && self.enforced_frame_cap_bytes <= MAX_MJPEG_FRAME_BYTES + && self.enforced_frame_cap_bytes <= self.advertised_frame_bytes + && self.enforced_frame_cap_bytes <= self.committed_frame_bytes + } +} + +pub fn contract_path() -> PathBuf { + std::env::var("LESAVKA_UVC_CONTRACT_PATH") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(DEFAULT_UVC_CONTRACT_PATH)) +} + +pub fn read_contract(path: &Path) -> anyhow::Result { + let bytes = std::fs::read(path)?; + let contract: UvcPayloadContract = serde_json::from_slice(&bytes)?; + anyhow::ensure!(contract.is_usable(), "unusable UVC payload contract"); + Ok(contract) +} + +pub fn record_missing_contract_read() -> u64 { + CONTRACT_MISSING_READS.fetch_add(1, Ordering::Relaxed) + 1 +} + +pub fn record_divergent_contract_read() -> u64 { + CONTRACT_DIVERGENT_READS.fetch_add(1, Ordering::Relaxed) + 1 +} + +pub fn contract_read_stats() -> UvcContractReadStats { + UvcContractReadStats { + missing_or_invalid: CONTRACT_MISSING_READS.load(Ordering::Relaxed), + divergent: CONTRACT_DIVERGENT_READS.load(Ordering::Relaxed), + } +} + +pub fn write_contract(path: &Path, contract: &UvcPayloadContract) -> anyhow::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let tmp = path.with_extension(format!("tmp.{}", std::process::id())); + let mut bytes = serde_json::to_vec_pretty(contract)?; + bytes.push(b'\n'); + std::fs::write(&tmp, bytes)?; + std::fs::rename(&tmp, path)?; + Ok(()) +} + +pub fn read_streaming_bulk_value(base: &Path) -> Option { + std::fs::read_to_string(base.join("streaming_bulk")) + .ok() + .and_then(|value| value.trim().parse::().ok()) +} + +pub fn bulk_enabled_from_configfs(base: &Path, requested: bool) -> bool { + requested && read_streaming_bulk_value(base) == Some(1) +} + +pub fn isochronous_budget_bytes_per_sec(maxpacket: u32, pct: u32) -> u32 { + let bytes = u64::from(maxpacket.max(1)) + .saturating_mul(u64::from(HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC)) + .saturating_mul(u64::from(pct.clamp(1, 100))) + / 100; + bytes.min(u64::from(u32::MAX)) as u32 +} + +pub fn effective_mjpeg_budget_bytes_per_sec( + configured: u32, + maxpacket: u32, + bulk: bool, + isochronous_limit_pct: u32, +) -> u32 { + if bulk { + configured.max(1) + } else { + configured + .max(1) + .min(isochronous_budget_bytes_per_sec( + maxpacket, + isochronous_limit_pct, + )) + .max(1) + } +} + +pub fn frame_cap_from_budget(budget_bytes_per_sec: u32, fps: u32) -> u32 { + (budget_bytes_per_sec / fps.max(1)) + .max(1) + .min(MAX_MJPEG_FRAME_BYTES) +} + +pub fn clamp_frame_cap( + requested: Option, + advertised: u32, + budget_bytes_per_sec: u32, + fps: u32, +) -> u32 { + let derived = frame_cap_from_budget(budget_bytes_per_sec, fps); + requested + .filter(|value| *value > 0) + .unwrap_or(derived) + .min(derived) + .min(nonzero_or(advertised, derived)) + .min(MAX_MJPEG_FRAME_BYTES) + .max(1) +} + +/// Validate the complete marker and entropy structure of one JPEG frame. +/// +/// This is intentionally stricter than checking SOI/EOI bytes: a truncated +/// entropy scan can still have those sentinels after a bad splice. +pub fn validate_jpeg_structure(frame: &[u8]) -> bool { + if frame.len() < 4 || !frame.starts_with(&[0xff, 0xd8]) { + return false; + } + let mut pos = 2usize; + let mut saw_scan = false; + while pos < frame.len() { + if frame[pos] != 0xff { + return false; + } + let marker_start = pos; + while pos < frame.len() && frame[pos] == 0xff { + pos += 1; + } + if pos >= frame.len() { + return false; + } + let marker = frame[pos]; + pos += 1; + match marker { + 0xd9 => return saw_scan && pos == frame.len(), + 0xd8 | 0x00 => return false, + 0x01 | 0xd0..=0xd7 => continue, + _ => {} + } + if pos + 2 > frame.len() { + return false; + } + let segment_len = usize::from(u16::from_be_bytes([frame[pos], frame[pos + 1]])); + if segment_len < 2 || pos + segment_len > frame.len() { + return false; + } + pos += segment_len; + if marker != 0xda { + continue; + } + + saw_scan = true; + loop { + if pos >= frame.len() { + return false; + } + if frame[pos] != 0xff { + pos += 1; + continue; + } + let entropy_marker_start = pos; + while pos < frame.len() && frame[pos] == 0xff { + pos += 1; + } + if pos >= frame.len() { + return false; + } + match frame[pos] { + 0x00 => pos += 1, + 0xd0..=0xd7 => pos += 1, + _ => { + pos = entropy_marker_start; + break; + } + } + } + debug_assert!(pos >= marker_start); + } + false +} + +pub fn lesavka_app4_sequence(frame: &[u8]) -> Option { + if !frame.starts_with(&[0xff, 0xd8]) { + return None; + } + let mut pos = 2usize; + while pos + 4 <= frame.len() && frame[pos] == 0xff { + while pos < frame.len() && frame[pos] == 0xff { + pos += 1; + } + let marker = *frame.get(pos)?; + pos += 1; + if marker == 0xda || marker == 0xd9 { + return None; + } + if marker == 0x01 || (0xd0..=0xd7).contains(&marker) { + continue; + } + let segment_len = usize::from(u16::from_be_bytes([*frame.get(pos)?, *frame.get(pos + 1)?])); + if segment_len < 2 || pos + segment_len > frame.len() { + return None; + } + let payload = &frame[pos + 2..pos + segment_len]; + if marker == 0xe4 && payload.starts_with(LESAVKA_APP4_MAGIC) && payload.len() >= 17 { + return Some(u64::from_be_bytes(payload[5..13].try_into().ok()?)); + } + pos += segment_len; + } + None +} + +pub fn lesavka_app4_integrity_valid(frame: &[u8]) -> Option { + if !frame.starts_with(&[0xff, 0xd8]) { + return None; + } + let mut pos = 2usize; + while pos + 4 <= frame.len() && frame[pos] == 0xff { + let marker_start = pos; + while pos < frame.len() && frame[pos] == 0xff { + pos += 1; + } + let marker = *frame.get(pos)?; + pos += 1; + if marker == 0xda || marker == 0xd9 { + return None; + } + if marker == 0x01 || (0xd0..=0xd7).contains(&marker) { + continue; + } + let segment_len = usize::from(u16::from_be_bytes([*frame.get(pos)?, *frame.get(pos + 1)?])); + if segment_len < 2 || pos + segment_len > frame.len() { + return None; + } + let segment_end = pos + segment_len; + let payload = &frame[pos + 2..segment_end]; + if marker == 0xe4 && payload.starts_with(LESAVKA_APP4_MAGIC) && payload.len() >= 17 { + let expected = u32::from_be_bytes(payload[13..17].try_into().ok()?); + let mut original = Vec::with_capacity(frame.len() - (segment_end - marker_start)); + original.extend_from_slice(&frame[..marker_start]); + original.extend_from_slice(&frame[segment_end..]); + return Some(crc32_ieee(&original) == expected); + } + pos = segment_end; + } + None +} + +pub fn add_lesavka_app4_integrity(frame: &[u8], sequence: u64) -> Option> { + if frame.len() < 4 || !frame.starts_with(&[0xff, 0xd8]) || !frame.ends_with(&[0xff, 0xd9]) { + return None; + } + let mut payload = Vec::with_capacity(17); + payload.extend_from_slice(LESAVKA_APP4_MAGIC); + payload.push(1); + payload.extend_from_slice(&sequence.to_be_bytes()); + payload.extend_from_slice(&crc32_ieee(frame).to_be_bytes()); + let segment_len = u16::try_from(payload.len() + 2).ok()?; + let mut marked = Vec::with_capacity(frame.len() + payload.len() + 4); + marked.extend_from_slice(&frame[..2]); + marked.extend_from_slice(&[0xff, 0xe4]); + marked.extend_from_slice(&segment_len.to_be_bytes()); + marked.extend_from_slice(&payload); + marked.extend_from_slice(&frame[2..]); + Some(marked) +} + +pub fn crc32_ieee(data: &[u8]) -> u32 { + let mut crc = 0xffff_ffffu32; + for byte in data { + crc ^= u32::from(*byte); + for _ in 0..8 { + crc = (crc >> 1) ^ (0xedb8_8320 & 0u32.wrapping_sub(crc & 1)); + } + } + !crc +} + +fn nonzero_or(value: u32, fallback: u32) -> u32 { + if value == 0 { fallback } else { value } +} + +fn unix_time_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .min(u128::from(u64::MAX)) as u64 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bulk_requires_live_attribute_value_one() { + let dir = tempfile::tempdir().expect("tempdir"); + assert!(!bulk_enabled_from_configfs(dir.path(), true)); + std::fs::write(dir.path().join("streaming_bulk"), "0\n").expect("write zero"); + assert!(!bulk_enabled_from_configfs(dir.path(), true)); + std::fs::write(dir.path().join("streaming_bulk"), "1\n").expect("write one"); + assert!(bulk_enabled_from_configfs(dir.path(), true)); + assert!(!bulk_enabled_from_configfs(dir.path(), false)); + } + + #[test] + fn explicit_frame_cap_cannot_exceed_wire_or_descriptor_contract() { + assert_eq!( + clamp_frame_cap(Some(900_000), 200_000, 4_500_000, 30), + 150_000 + ); + assert_eq!( + clamp_frame_cap(Some(100_000), 200_000, 4_500_000, 30), + 100_000 + ); + assert_eq!( + clamp_frame_cap(Some(149_999), 200_000, 4_500_000, 30), + 149_999 + ); + assert_eq!( + clamp_frame_cap(Some(150_001), 200_000, 4_500_000, 30), + 150_000 + ); + assert_eq!(clamp_frame_cap(None, 100_000, 30_000, 30), 1_000); + } + + #[test] + fn contract_round_trips_and_bounds_requested_override() { + let contract = UvcPayloadContract::from_input(UvcContractInput { + configfs_available: true, + commit_observed: true, + bulk: false, + streaming_bulk_value: Some(0), + width: 1280, + height: 720, + fps: 30, + frame_interval_100ns: 333_333, + streaming_maxpacket: 1024, + streaming_maxburst: 0, + configured_budget_bytes_per_sec: 10_000_000, + isochronous_limit_pct: 85, + advertised_frame_bytes: 232_106, + committed_frame_bytes: 232_106, + committed_payload_bytes: 1024, + requested_frame_cap_bytes: Some(999_999), + }); + assert!(contract.configfs_available); + assert!(contract.commit_observed); + assert_eq!(contract.transport, "isochronous"); + assert_eq!(contract.enforced_frame_cap_bytes, 232_106); + assert!(!contract.divergent); + + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("contract.json"); + write_contract(&path, &contract).expect("write contract"); + assert_eq!(read_contract(&path).expect("read contract"), contract); + } + + #[test] + fn contract_marks_missing_configfs_and_unobserved_commit_as_divergent() { + let contract = UvcPayloadContract::from_input(UvcContractInput { + configfs_available: false, + commit_observed: false, + bulk: false, + streaming_bulk_value: None, + width: 1280, + height: 720, + fps: 30, + frame_interval_100ns: 333_333, + streaming_maxpacket: 1024, + streaming_maxburst: 0, + configured_budget_bytes_per_sec: 4_500_000, + isochronous_limit_pct: 85, + advertised_frame_bytes: 150_000, + committed_frame_bytes: 150_000, + committed_payload_bytes: 1024, + requested_frame_cap_bytes: None, + }); + + assert!(contract.divergent); + assert!(contract.detail.contains("configfs_available=false")); + assert!(contract.detail.contains("commit_observed=false")); + } + + #[test] + fn strict_jpeg_validation_rejects_truncation_and_trailing_splices() { + let valid = include_bytes!("bin/lesavka_uvc/idle_1280x720_black.jpg"); + assert!(validate_jpeg_structure(valid)); + assert!(!validate_jpeg_structure(&valid[..valid.len() - 2])); + + let mut trailing = valid.to_vec(); + trailing.extend_from_slice(&[0xff, 0xd9]); + assert!(!validate_jpeg_structure(&trailing)); + + let mut broken_segment = valid.to_vec(); + broken_segment[4] = 0xff; + broken_segment[5] = 0xff; + assert!(!validate_jpeg_structure(&broken_segment)); + } + + #[test] + fn app4_integrity_marker_round_trips_sequence_and_keeps_jpeg_valid() { + let valid = include_bytes!("bin/lesavka_uvc/idle_1280x720_black.jpg"); + let marked = add_lesavka_app4_integrity(valid, 0x0102_0304_0506_0708).expect("mark JPEG"); + assert_eq!(lesavka_app4_sequence(&marked), Some(0x0102_0304_0506_0708)); + assert_eq!(lesavka_app4_integrity_valid(&marked), Some(true)); + assert!(validate_jpeg_structure(&marked)); + + let mut corrupted = marked; + let index = corrupted.len() - 10; + corrupted[index] ^= 1; + assert_eq!(lesavka_app4_integrity_valid(&corrupted), Some(false)); + } + + #[test] + fn downstream_uvc_consumers_use_shared_budget_math() { + let spool = include_str!("video_sinks/mjpeg_spool.rs"); + let helper = include_str!("bin/lesavka-uvc.real.inc"); + + assert!(spool.contains("uvc_contract::effective_mjpeg_budget_bytes_per_sec")); + assert!(spool.contains("uvc_contract::clamp_frame_cap")); + assert!(helper.contains("uvc_contract::effective_mjpeg_budget_bytes_per_sec")); + assert!(helper.contains("uvc_contract::frame_cap_from_budget")); + assert!(!spool.contains("HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC")); + assert!(!helper.contains("saturating_mul(u64::from(HIGH_SPEED_ISOCHRONOUS")); + } +} diff --git a/server/src/video_sinks/mjpeg_spool.rs b/server/src/video_sinks/mjpeg_spool.rs index aba1d7c..1d0ff93 100644 --- a/server/src/video_sinks/mjpeg_spool.rs +++ b/server/src/video_sinks/mjpeg_spool.rs @@ -6,16 +6,20 @@ use std::time::{SystemTime, UNIX_EPOCH}; use gstreamer as gst; use gstreamer_app as gst_app; +#[cfg(not(coverage))] +use crate::uvc_contract::{ + self, DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT, DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC, +}; +#[cfg(coverage)] +use lesavka_server::uvc_contract::{ + self, DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT, DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC, +}; #[path = "mjpeg_spool/audit.rs"] mod audit; static SPOOL_SEQUENCE: AtomicU64 = AtomicU64::new(1); static SPOOL_TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(1); -const MAX_MJPEG_FRAME_BYTES: usize = 8 * 1024 * 1024; -const DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC: u32 = 4_500_000; -const DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT: u32 = 85; -const HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC: u32 = 8_000; const CONFIGFS_UVC_BASE: &str = "/sys/kernel/config/usb_gadget/lesavka/functions/uvc.usb0"; #[derive(Clone, Copy)] @@ -173,11 +177,11 @@ fn read_u32_file(path: impl AsRef) -> Option { } fn uvc_bulk_transfer_enabled() -> bool { - if !env_flag_enabled("LESAVKA_UVC_BULK", true) { - return false; - } let base = Path::new(CONFIGFS_UVC_BASE); - !base.exists() || base.join("streaming_bulk").exists() + uvc_contract::bulk_enabled_from_configfs( + base, + env_flag_enabled("LESAVKA_UVC_BULK", true), + ) } fn uvc_streaming_maxpacket(bulk: bool) -> u32 { @@ -192,28 +196,15 @@ fn uvc_streaming_maxpacket(bulk: bool) -> u32 { } } -fn uvc_isochronous_budget_bytes_per_sec(maxpacket: u32) -> u32 { - let pct = env_u32_opt("LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT") - .unwrap_or(DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT) - .clamp(1, 100); - let bytes = u64::from(maxpacket) - .saturating_mul(u64::from(HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC)) - .saturating_mul(u64::from(pct)) - / 100; - bytes.min(u64::from(u32::MAX)) as u32 -} - fn effective_mjpeg_budget_bytes_per_sec() -> u32 { let configured = env_u32_opt("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC") .unwrap_or(DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC) .max(1); - if uvc_bulk_transfer_enabled() { - configured - } else { - configured - .min(uvc_isochronous_budget_bytes_per_sec(uvc_streaming_maxpacket(false))) - .max(1) - } + let bulk = uvc_bulk_transfer_enabled(); + let maxpacket = uvc_streaming_maxpacket(bulk); + let pct = env_u32_opt("LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT") + .unwrap_or(DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT); + uvc_contract::effective_mjpeg_budget_bytes_per_sec(configured, maxpacket, bulk, pct) } /// Resolve the MJPEG byte budget used before publishing to the helper. @@ -224,19 +215,62 @@ fn effective_mjpeg_budget_bytes_per_sec() -> u32 { /// Why: oversized MJPEG frames are a common source of host-visible UVC tearing; /// a short freeze is better than letting the USB gadget emit partial pictures. pub(super) fn mjpeg_spool_frame_max_bytes(fps: u32) -> usize { - if !env_flag_enabled("LESAVKA_UVC_FRAME_SIZE_GUARD", true) { - return MAX_MJPEG_FRAME_BYTES; - } - if let Some(limit) = env_u32_opt("LESAVKA_UVC_FRAME_MAX_BYTES") - && limit > 0 - { - return (limit as usize).min(MAX_MJPEG_FRAME_BYTES); + let path = uvc_contract::contract_path(); + match uvc_contract::read_contract(&path) { + Ok(contract) => { + if contract.divergent { + let count = uvc_contract::record_divergent_contract_read(); + warn_contract_issue( + count, + "divergent", + &format!("{} ({})", path.display(), contract.detail), + ); + } + return contract.enforced_frame_cap_bytes as usize; + } + Err(err) => { + let count = uvc_contract::record_missing_contract_read(); + warn_contract_issue( + count, + "missing_or_invalid", + &format!("{} ({err})", path.display()), + ); + } } let fps = fps.max(1); let budget_per_sec = effective_mjpeg_budget_bytes_per_sec(); - let per_frame = (budget_per_sec / fps).max(64 * 1024); - per_frame.min(MAX_MJPEG_FRAME_BYTES as u32) as usize + let requested = env_u32_opt("LESAVKA_UVC_FRAME_MAX_BYTES"); + let advertised = live_advertised_frame_bytes().unwrap_or(0); + uvc_contract::clamp_frame_cap(requested, advertised, budget_per_sec, fps) as usize +} + +fn live_advertised_frame_bytes() -> Option { + let frame = match ( + env_u32_opt("LESAVKA_UVC_WIDTH").unwrap_or(1280), + env_u32_opt("LESAVKA_UVC_HEIGHT").unwrap_or(720), + ) { + (1920, 1080) => "1080p", + _ => "720p", + }; + read_u32_file( + Path::new(CONFIGFS_UVC_BASE) + .join("streaming/mjpeg/m") + .join(frame) + .join("dwMaxVideoFrameBufferSize"), + ) +} + +fn warn_contract_issue(count: u64, kind: &str, detail: &str) { + if count <= 3 || count.is_power_of_two() { + tracing::warn!( + target: "lesavka_server::video", + contract_issue = kind, + occurrences = count, + detail, + "UVC payload contract unavailable or divergent; using conservative live fallback" + ); + } } /// Decide whether frame spool metadata should be published. diff --git a/server/src/video_sinks/mjpeg_spool/tests.rs b/server/src/video_sinks/mjpeg_spool/tests.rs index b00213d..8e1c2e2 100644 --- a/server/src/video_sinks/mjpeg_spool/tests.rs +++ b/server/src/video_sinks/mjpeg_spool/tests.rs @@ -55,7 +55,7 @@ fn mjpeg_spool_frame_budget_uses_live_budget_when_zero_or_unset() { ("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", Some("9")), ], || { - assert_eq!(super::mjpeg_spool_frame_max_bytes(30), 65_536); + assert_eq!(super::mjpeg_spool_frame_max_bytes(30), 1); }, ); @@ -75,7 +75,7 @@ fn mjpeg_spool_frame_budget_uses_live_budget_when_zero_or_unset() { } #[test] -fn mjpeg_spool_frame_budget_allows_explicit_limit_or_diagnostic_disable() { +fn mjpeg_spool_frame_budget_clamps_explicit_limit_and_diagnostic_flags() { temp_env::with_vars( [ ("LESAVKA_UVC_FRAME_SIZE_GUARD", Some("1")), @@ -83,7 +83,7 @@ fn mjpeg_spool_frame_budget_allows_explicit_limit_or_diagnostic_disable() { ("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", Some("1")), ], || { - assert_eq!(super::mjpeg_spool_frame_max_bytes(30), 123_456); + assert_eq!(super::mjpeg_spool_frame_max_bytes(30), 1); }, ); @@ -94,10 +94,7 @@ fn mjpeg_spool_frame_budget_allows_explicit_limit_or_diagnostic_disable() { ("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", Some("1")), ], || { - assert_eq!( - super::mjpeg_spool_frame_max_bytes(30), - super::MAX_MJPEG_FRAME_BYTES - ); + assert_eq!(super::mjpeg_spool_frame_max_bytes(30), 1); }, ); } diff --git a/server/src/video_sinks/webcam_sink/frame_handoff.rs b/server/src/video_sinks/webcam_sink/frame_handoff.rs index 95ff3f6..4007c5b 100644 --- a/server/src/video_sinks/webcam_sink/frame_handoff.rs +++ b/server/src/video_sinks/webcam_sink/frame_handoff.rs @@ -10,6 +10,8 @@ use super::mjpeg_spool::freshest_mjpeg_sample; use super::mjpeg_spool::{MjpegSpoolTiming, spool_mjpeg_frame_with_timing}; use super::*; use crate::video_support::{contains_hevc_irap, reserve_local_pts}; +#[cfg(not(coverage))] +use crate::uvc_contract::{add_lesavka_app4_integrity, lesavka_app4_sequence}; impl WebcamSink { /// Push one client frame into the UVC pipeline. @@ -344,7 +346,9 @@ impl WebcamSink { self.spool_guarded_passthrough_direct_mjpeg_frame(path, pkt); return; }; - let normalized = map.as_slice(); + let normalized_marked = lesavka_app4_sequence(&pkt.data) + .and_then(|sequence| add_lesavka_app4_integrity(map.as_slice(), sequence)); + let normalized = normalized_marked.as_deref().unwrap_or_else(|| map.as_slice()); let previous_bytes = self.last_mjpeg_passthrough_bytes.load(Ordering::Relaxed); if let Some(reason) = hevc_mjpeg_guard::direct_mjpeg_reject_reason( previous_bytes, diff --git a/tests/contract/scripts/daemon/server_core_script_contract.rs b/tests/contract/scripts/daemon/server_core_script_contract.rs index 76bdd14..288a6db 100644 --- a/tests/contract/scripts/daemon/server_core_script_contract.rs +++ b/tests/contract/scripts/daemon/server_core_script_contract.rs @@ -110,7 +110,10 @@ fn core_script_keeps_uvc_output_on_supported_mjpeg_descriptor() { "UVC_ISOCHRONOUS_LIMIT_PCT=${LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT:-85}", "uvc_isochronous_budget_bytes_per_sec()", "isoch_budget=\"$(uvc_isochronous_budget_bytes_per_sec)\"", - "UVC_FRAME_SIZE=\"$(uvc_mjpeg_frame_size_for_fps \"$UVC_FPS\")\"", + "UVC_DERIVED_FRAME_SIZE=\"$(uvc_mjpeg_frame_size_for_fps \"$UVC_FPS\")\"", + "clamping requested UVC frame size $UVC_FRAME_SIZE -> $UVC_DERIVED_FRAME_SIZE (wire budget)", + "UVC bulk request did not stick; using isochronous descriptors", + "UVC bulk readback diverged after descriptor setup; falling back to isochronous sizing", "write_active_mjpeg_frame_descriptor()", "if flag_enabled \"$UVC_ADVERTISE_EXTRA_MODES\"; then", "write_mjpeg_frame_descriptor 1080p 1920 1080", diff --git a/tests/contract/scripts/install/server_install_script_contract.rs b/tests/contract/scripts/install/server_install_script_contract.rs index cd95479..b58a9d1 100644 --- a/tests/contract/scripts/install/server_install_script_contract.rs +++ b/tests/contract/scripts/install/server_install_script_contract.rs @@ -431,10 +431,12 @@ fn server_install_pins_hdmi_camera_and_display_defaults() { assert!( SERVER_INSTALL.contains( "updated UVC env matches the live descriptor, restarting lesavka-server only" - ) && SERVER_INSTALL.contains( - "Preserving lesavka-core and lesavka-uvc so the attached USB gadget is not cycled" - ), - "safe env repairs should restart only the server process, not the attached gadget path" + ) && SERVER_INSTALL + .contains("Preserving lesavka-core so the attached USB gadget is not cycled") + && SERVER_INSTALL.contains( + "set LESAVKA_INSTALL_RESTART_UVC_HELPER=1 when a helper-only refresh is required" + ), + "safe env repairs should preserve the gadget and require an explicit helper-only refresh" ); assert!( SERVER_INSTALL.contains("dwDefaultFrameInterval"), @@ -470,7 +472,17 @@ fn server_install_pins_hdmi_camera_and_display_defaults() { < SERVER_INSTALL .rfind("restart_lesavka_uvc_helper_only") .unwrap(), - "safe env repair must exit before the later version-update helper refresh path" + "safe env repair must remain separate from the later version-update helper refresh path" + ); + assert!( + SERVER_INSTALL.contains("install_uvc_kernel_watch_unit") + && SERVER_INSTALL + .find("\ninstall_uvc_kernel_watch_unit\n") + .unwrap() + < SERVER_INSTALL + .find("if [[ \"$ATTACHED_UVC_RESTART_DEFERRED\" == \"1\" ]]") + .unwrap(), + "kernel UVC telemetry must be installed even when an attached-gadget branch exits early" ); assert!( SERVER_INSTALL diff --git a/tests/contract/server/uvc/server_uvc_binary_contract.rs b/tests/contract/server/uvc/server_uvc_binary_contract.rs index dd35e28..03e1318 100644 --- a/tests/contract/server/uvc/server_uvc_binary_contract.rs +++ b/tests/contract/server/uvc/server_uvc_binary_contract.rs @@ -315,7 +315,7 @@ mod uvc_binary { #[test] #[serial] - fn uvc_defaults_to_reliable_bulk_and_budgeted_mjpeg_frame_size() { + fn uvc_missing_configfs_falls_back_to_isochronous_budget() { with_vars( [ ("LESAVKA_UVC_WIDTH", Some("1280")), @@ -331,10 +331,10 @@ mod uvc_binary { || { let cfg = UvcConfig::from_env(); assert_eq!(cfg.interval, 10_000_000 / 30); - assert_eq!(cfg.frame_size, 300_000); - assert_eq!(cfg.max_packet, 512); - assert!(cfg.bulk); - assert!(uvc_bulk_transfer_enabled()); + assert_eq!(cfg.frame_size, 232_106); + assert_eq!(cfg.max_packet, 1024); + assert!(!cfg.bulk); + assert!(!uvc_bulk_transfer_enabled()); }, ); @@ -372,8 +372,8 @@ mod uvc_binary { fn uvc_bulk_mode_is_tied_to_live_configfs_support() { let source = include_str!("../../../../server/src/bin/lesavka-uvc.real.inc"); - assert!(source.contains("base.exists() && !base.join(\"streaming_bulk\").exists()")); - assert!(source.contains("using isochronous payload sizing")); + assert!(source.contains("bulk_enabled_from_configfs")); + assert!(source.contains("streaming_bulk is not 1; using isochronous payload sizing")); } #[test] @@ -386,6 +386,12 @@ mod uvc_binary { rejected_oversize: 1, rejected_invalid: 3, fallback_idle: 4, + held_last_good: 5, + read_errors: 6, + strict_validation_failures: 7, + dqbuf_ioctl_errors: 8, + dqbuf_flag_errors: 9, + qbuf_ioctl_errors: 10, latest_bytes: 77_036, last_rejected_oversize_bytes: 300_001, last_rejected_oversize_cap: 300_000, @@ -402,6 +408,21 @@ mod uvc_binary { assert!(json.contains("\"paced_sleeps\":5")); assert!(json.contains("\"paced_sleep_ms\":123")); + let kernel_path = tempfile::NamedTempFile::new().expect("kernel stats"); + fs::write( + kernel_path.path(), + r#"{"schema":"lesavka.uvc-kernel-watch.v1","counters":{"dwc2_errors":2}}"#, + ) + .expect("write kernel stats"); + with_var( + "LESAVKA_UVC_KERNEL_STATS_PATH", + Some(kernel_path.path().to_str().expect("kernel stats path")), + || { + let json = uvc_stats_snapshot_json(&stats, 333_333); + assert!(json.contains("\"kernel\":{\"counters\":{\"dwc2_errors\":2}")); + }, + ); + let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("uvc").join("stats.json"); write_atomic_text(&path, &json).expect("write stats"); @@ -454,8 +475,10 @@ mod uvc_binary { height: 480, default_interval: 333_333, frame_interval: 333_333, + frame_size: 150_000, maxpacket: 1024, maxburst: 0, + streaming_bulk: Some(0), }); log_configfs_snapshot(&mut state, "contract"); assert!(state.cfg_snapshot.is_some()); diff --git a/tests/contract/server/video_sinks/server_video_sinks_include_contract.rs b/tests/contract/server/video_sinks/server_video_sinks_include_contract.rs index 78fbfad..64b3ab5 100644 --- a/tests/contract/server/video_sinks/server_video_sinks_include_contract.rs +++ b/tests/contract/server/video_sinks/server_video_sinks_include_contract.rs @@ -15,6 +15,10 @@ mod video_support { pub use lesavka_server::video_support::*; } +mod uvc_contract { + pub use lesavka_server::uvc_contract::*; +} + #[path = "../../../../server/src/media_timing.rs"] #[allow(warnings)] mod media_timing; 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 ab0f852..116f4ef 100644 --- a/tests/integration/server/video_sinks/hevc_mjpeg_spool_integration.rs +++ b/tests/integration/server/video_sinks/hevc_mjpeg_spool_integration.rs @@ -10,6 +10,10 @@ use std::fs; use serial_test::serial; +mod uvc_contract { + pub use lesavka_server::uvc_contract::*; +} + mod video_support { pub fn env_u32(name: &str, default: u32) -> u32 { std::env::var(name) 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 abac4a5..9df1bec 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 @@ -58,6 +58,7 @@ fn synthetic_probe_keeps_bundled_network_ingress_and_rct_comparison_markers() { "--server-uvc-audit-dir", "--server-uvc-audit-sample-frames", "--stream-analyze", + "--deep-capture", "--sequence-window", "--mix-mae-threshold", "--mix-improvement", @@ -138,6 +139,11 @@ fn synthetic_probe_keeps_bundled_network_ingress_and_rct_comparison_markers() { "ffmpeg", "v4l2", "x11grab", + "capture.mjpg", + "usbmon.pcapng", + "uvcvideo-dynamic-debug", + "power/autosuspend", + "analyze_marked_capture.py", ] { assert!( PROBE_SRC.contains(expected), @@ -157,6 +163,8 @@ fn synthetic_injector_enters_the_public_bundled_media_rpc() { "silence_pcm(args.frame_step_us())", "synthetic_rgb_frame", "draw_sequence_marker", + "synthetic_band_marker_luma", + "add_jpeg_app4_integrity", "image/jpeg", "jpegenc", "client_capture_pts_us: pts_us", diff --git a/tests/manual/server/rct/uvc_mjpeg_integrity_manual_contract.rs b/tests/manual/server/rct/uvc_mjpeg_integrity_manual_contract.rs new file mode 100644 index 0000000..fd61c74 --- /dev/null +++ b/tests/manual/server/rct/uvc_mjpeg_integrity_manual_contract.rs @@ -0,0 +1,82 @@ +use std::process::Command; + +const ANALYZER: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/manual/analyze_uvc_mjpeg_integrity.py" +)); +const KERNEL_WATCHER: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/daemon/lesavka-uvc-kernel-watch.py" +)); + +#[test] +fn uvc_integrity_analyzer_has_machine_readable_failure_classes() { + for expected in [ + "lesavka.uvc-mjpeg-integrity.v1", + "intact", + "truncated", + "spliced", + "stale_repeat", + "idle_black", + "APP4_MAGIC", + "crc_ok", + "report.json", + "frames.jsonl", + ] { + assert!( + ANALYZER.contains(expected), + "missing analyzer contract {expected}" + ); + } +} + +#[test] +fn uvc_integrity_analyzer_self_test_exercises_all_classes() { + let script = concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/manual/analyze_uvc_mjpeg_integrity.py" + ); + let output = Command::new("python3") + .args([script, "--self-test"]) + .output() + .expect("run analyzer self-test"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(String::from_utf8_lossy(&output.stdout).contains("self-test: pass")); +} + +#[test] +fn uvc_kernel_watcher_has_bounded_machine_readable_counters() { + for expected in [ + "lesavka.uvc-kernel-watch.v1", + "dwc2_errors", + "uvc_errors", + "udc_errors", + "urb_errors", + "usb_resets", + "deque(maxlen=40)", + ] { + assert!( + KERNEL_WATCHER.contains(expected), + "missing watcher contract {expected}" + ); + } + + let script = concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/daemon/lesavka-uvc-kernel-watch.py" + ); + let output = Command::new("python3") + .args([script, "--self-test"]) + .output() + .expect("run kernel watcher self-test"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(String::from_utf8_lossy(&output.stdout).contains("self-test: pass")); +}