fix(uvc): enforce negotiated payload integrity

This commit is contained in:
Brad Stein 2026-08-12 07:53:40 -03:00
parent d5b1ecaae1
commit 75df8c94e1
46 changed files with 3049 additions and 213 deletions

6
Cargo.lock generated
View File

@ -1658,7 +1658,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]] [[package]]
name = "lesavka_client" name = "lesavka_client"
version = "0.27.6" version = "0.27.7"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-stream", "async-stream",
@ -1692,7 +1692,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_common" name = "lesavka_common"
version = "0.27.6" version = "0.27.7"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@ -1704,7 +1704,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_server" name = "lesavka_server"
version = "0.27.6" version = "0.27.7"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",

View File

@ -485,6 +485,10 @@ path = "tests/manual/server/rct/rct_uvc_artifact_probe_manual_contract.rs"
name = "synthetic_rct_uvc_probe_manual_contract" name = "synthetic_rct_uvc_probe_manual_contract"
path = "tests/manual/server/rct/synthetic_rct_uvc_probe_manual_contract.rs" 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]] [[test]]
name = "google_meet_observer_manual_contract" name = "google_meet_observer_manual_contract"
path = "tests/manual/google_meet/google_meet_observer_manual_contract.rs" path = "tests/manual/google_meet/google_meet_observer_manual_contract.rs"

View File

@ -74,6 +74,9 @@ Useful entry points:
## Operational Notes ## 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`. - Runtime and test environment variables are indexed in `docs/operational-env.md`.
- Gate criteria and evidence paths are documented in `docs/quality-gate.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. - Manual hardware checks belong in clearly marked manual scripts, not hidden in CI-only assumptions.

View File

@ -4,7 +4,7 @@ path = "src/main.rs"
[package] [package]
name = "lesavka_client" name = "lesavka_client"
version = "0.27.6" version = "0.27.7"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View File

@ -185,5 +185,60 @@ fn print_upstream_sync(state: lesavka_common::lesavka::UpstreamSyncState) {
"planner_sink_handoff_window_samples={}", "planner_sink_handoff_window_samples={}",
state.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); println!("planner_detail={}", state.last_reason);
} }

View File

@ -321,6 +321,30 @@ fn print_upstream_sync_accepts_complete_and_pending_payloads() {
microphone_sink_late_p95_ms: Some(18.5), microphone_sink_late_p95_ms: Some(18.5),
client_timing_window_samples: 19, client_timing_window_samples: 19,
sink_handoff_window_samples: 20, 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 { super::print_upstream_sync(UpstreamSyncState {

View File

@ -85,6 +85,13 @@ pub struct UpstreamSyncStatus {
pub freshness_reanchors: u64, pub freshness_reanchors: u64,
pub startup_timeouts: u64, pub startup_timeouts: u64,
pub video_freezes: u64, pub video_freezes: u64,
pub uvc_contract_status: String,
pub uvc_transport: String,
pub uvc_enforced_frame_cap_bytes: Option<u32>,
pub uvc_contract_divergent: bool,
pub uvc_contract_detail: String,
pub uvc_integrity_status: String,
pub uvc_integrity_detail: String,
pub detail: String, pub detail: String,
} }
@ -109,6 +116,13 @@ impl UpstreamSyncStatus {
freshness_reanchors: reply.freshness_reanchors, freshness_reanchors: reply.freshness_reanchors,
startup_timeouts: reply.startup_timeouts, startup_timeouts: reply.startup_timeouts,
video_freezes: reply.video_freezes, 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, detail: reply.last_reason,
} }
} }
@ -146,6 +160,13 @@ impl Default for UpstreamSyncStatus {
freshness_reanchors: 0, freshness_reanchors: 0,
startup_timeouts: 0, startup_timeouts: 0,
video_freezes: 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(), detail: "upstream sync planner unavailable".to_string(),
} }
} }

View File

@ -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] #[test]
fn capture_power_detail_mentions_detected_eyes_when_powered() { fn capture_power_detail_mentions_detected_eyes_when_powered() {
let power = CapturePowerStatus { let power = CapturePowerStatus {

View File

@ -216,6 +216,21 @@ fn recovery_uvc_health(
.unwrap_or(codec); .unwrap_or(codec);
return (StatusLightState::Caution, value); 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 { if !relay_live {
return (StatusLightState::Live, codec); return (StatusLightState::Live, codec);
} }

View File

@ -139,8 +139,18 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
set_status_light(&widgets.summary.uvc_light, uvc_state); set_status_light(&widgets.summary.uvc_light, uvc_state);
widgets.summary.uvc_value.set_text(&uvc_value); widgets.summary.uvc_value.set_text(&uvc_value);
widgets.summary.uvc_value.set_tooltip_text(Some(&format!( widgets.summary.uvc_value.set_tooltip_text(Some(&format!(
"Upstream webcam transport: {}. Server calibration is profile-specific.", "Upstream webcam transport: {}. UVC payload contract: {} via {}, cap {} bytes. {} Integrity: {}. {}",
state.effective_webcam_transport().label() 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) = let (lag_state, lag_value, lag_tooltip) =
upstream_lag_health(&state.upstream_sync, relay_live); upstream_lag_health(&state.upstream_sync, relay_live);

View File

@ -1,6 +1,6 @@
[package] [package]
name = "lesavka_common" name = "lesavka_common"
version = "0.27.6" version = "0.27.7"
edition = "2024" edition = "2024"
build = "build.rs" build = "build.rs"

View File

@ -195,6 +195,30 @@ message UpstreamSyncState {
optional float microphone_sink_late_p95_ms = 33; optional float microphone_sink_late_p95_ms = 33;
uint64 client_timing_window_samples = 34; uint64 client_timing_window_samples = 34;
uint64 sink_handoff_window_samples = 35; 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 { message HandshakeSet {

View File

@ -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.

View File

@ -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_EXTERNAL` | server hardware/device override |
| `LESAVKA_UVC_FALLBACK` | server hardware/device override | | `LESAVKA_UVC_FALLBACK` | server hardware/device override |
| `LESAVKA_UVC_FPS` | 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_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_DIR` | UVC helper boundary-audit directory; when set, the server saves exact MJPEG frames published to the UVC helper plus a JSONL index so recordings can be compared against pre-UVC payloads |
| `LESAVKA_UVC_FRAME_AUDIT_EVERY` | UVC helper boundary-audit sampling interval; saves every Nth spooled frame, defaults to `1` for short repros | | `LESAVKA_UVC_FRAME_AUDIT_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_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_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_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` | 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_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_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_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_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_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_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 | | `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_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_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_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_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_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_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_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_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_STREAMING_INTERVAL` | server hardware/device override |
| `LESAVKA_UVC_STREAM_INTF` | server hardware/device override | | `LESAVKA_UVC_STREAM_INTF` | server hardware/device override |
| `LESAVKA_UVC_WIDTH` | server hardware/device override | | `LESAVKA_UVC_WIDTH` | server hardware/device override |

View File

@ -319,7 +319,7 @@ MAX_SPEED=${LESAVKA_MAX_SPEED:-high-speed}
if [[ -z $UVC_INTERVAL ]]; then if [[ -z $UVC_INTERVAL ]]; then
UVC_INTERVAL=$((10000000 / UVC_FPS)) UVC_INTERVAL=$((10000000 / UVC_FPS))
fi 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_30=${LESAVKA_UVC_INTERVAL_30:-333333}
UVC_INTERVAL_20=${LESAVKA_UVC_INTERVAL_20:-500000} UVC_INTERVAL_20=${LESAVKA_UVC_INTERVAL_20:-500000}
UVC_MJPEG_BUDGET_BYTES_PER_SEC=${LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC:-4500000} 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
fi fi
local per_frame=$((budget / fps)) local per_frame=$((budget / fps))
if ((per_frame < 65536)); then if ((per_frame < 1)); then
per_frame=65536 per_frame=1
elif ((per_frame > 8388608)); then elif ((per_frame > 8388608)); then
per_frame=8388608 per_frame=8388608
fi fi
@ -367,8 +367,15 @@ uvc_fps_for_interval() {
fi fi
} }
if [[ -z ${LESAVKA_UVC_FRAME_SIZE:-} ]]; then UVC_DERIVED_FRAME_SIZE="$(uvc_mjpeg_frame_size_for_fps "$UVC_FPS")"
UVC_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 fi
uvc_selected_frame_index() { uvc_selected_frame_index() {
@ -400,10 +407,16 @@ write_active_mjpeg_frame_descriptor() {
uvc_frame_size_for() { uvc_frame_size_for() {
local width=$1 height=$2 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 if [[ $width == "$UVC_WIDTH" && $height == "$UVC_HEIGHT" && -n ${LESAVKA_UVC_FRAME_SIZE:-} ]]; then
if ((LESAVKA_UVC_FRAME_SIZE < derived)); then
echo "$LESAVKA_UVC_FRAME_SIZE" echo "$LESAVKA_UVC_FRAME_SIZE"
else else
uvc_mjpeg_frame_size_for_fps "$(uvc_fps_for_interval "$(uvc_default_interval_for "$width" "$height")")" echo "$derived"
fi
else
echo "$derived"
fi fi
} }
@ -640,7 +653,13 @@ if [[ -z $DISABLE_UVC ]]; then
F="$G/functions/uvc.usb0" F="$G/functions/uvc.usb0"
if [[ -n $UVC_BULK_REQUESTED ]]; then if [[ -n $UVC_BULK_REQUESTED ]]; then
if [[ -e "$F/streaming_bulk" ]]; then if [[ -e "$F/streaming_bulk" ]]; then
if echo 1 >"$F/streaming_bulk" 2>/dev/null && [[ $(cat "$F/streaming_bulk" 2>/dev/null) == 1 ]]; then
UVC_BULK=1 UVC_BULK=1
else
log "UVC bulk request did not stick; using isochronous descriptors"
UVC_BULK=
UVC_MAXPACKET=${LESAVKA_UVC_MAXPACKET:-1024}
fi
else else
# Some kernels do not expose the patched bulk-transfer knob. Falling # Some kernels do not expose the patched bulk-transfer knob. Falling
# back to isochronous must also avoid the 512-byte bulk packet clamp; # back to isochronous must also avoid the 512-byte bulk packet clamp;
@ -649,13 +668,22 @@ if [[ -z $DISABLE_UVC ]]; then
UVC_BULK= UVC_BULK=
UVC_MAXPACKET=${LESAVKA_UVC_MAXPACKET:-1024} UVC_MAXPACKET=${LESAVKA_UVC_MAXPACKET:-1024}
fi 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 fi
apply_uvc_payload_limits apply_uvc_payload_limits
echo "$UVC_STREAMING_INTERVAL" >"$F/streaming_interval" echo "$UVC_STREAMING_INTERVAL" >"$F/streaming_interval"
echo "$UVC_MAXPACKET" >"$F/streaming_maxpacket" echo "$UVC_MAXPACKET" >"$F/streaming_maxpacket"
echo "$UVC_MAXBURST" >"$F/streaming_maxburst" echo "$UVC_MAXBURST" >"$F/streaming_maxburst"
if [[ -n $UVC_BULK ]]; then if [[ -n $UVC_BULK && $(cat "$F/streaming_bulk" 2>/dev/null) != 1 ]]; then
echo 1 >"$F/streaming_bulk" 2>/dev/null || true 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 fi
# ── 1. FORMAT DESCRIPTOR ────────────────────────────────────────── # ── 1. FORMAT DESCRIPTOR ──────────────────────────────────────────

View File

@ -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())

View File

@ -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_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_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_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 EOF
} }
@ -1040,6 +1046,26 @@ WantedBy=multi-user.target
UNIT 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() { restart_lesavka_server_only() {
sudo truncate -s 0 /var/log/lesavka/server.stderr sudo truncate -s 0 /var/log/lesavka/server.stderr
sudo systemctl stop lesavka-server >/dev/null 2>&1 || true 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/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-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.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/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_verified_executable "$SRC_DIR/scripts/manual/run_uac_output_sanity.sh" /usr/local/bin/lesavka-uac-sanity "lesavka-uac-sanity"
install_recovery_ladder_units install_recovery_ladder_units
@ -1820,12 +1848,18 @@ if [[ "$ATTACHED_UVC_RESTART_DEFERRED" == "1" ]]; then
fi fi
echo "✅ No active gRPC clients detected; updated UVC env matches the live descriptor, restarting lesavka-server only." >&2 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 install -d -m 0755 /var/log/lesavka
sudo truncate -s 0 /var/log/lesavka/server.log sudo truncate -s 0 /var/log/lesavka/server.log
install_lesavka_server_unit_file install_lesavka_server_unit_file
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl enable lesavka-server >/dev/null 2>&1 || true 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 restart_lesavka_server_only
sudo /usr/local/bin/lesavka-recovery-ladder snapshot || true sudo /usr/local/bin/lesavka-recovery-ladder snapshot || true
INSTALLED_VERSION=$(manifest_package_version "$SRC_DIR/server/Cargo.toml" 2>/dev/null || true) INSTALLED_VERSION=$(manifest_package_version "$SRC_DIR/server/Cargo.toml" 2>/dev/null || true)

View File

@ -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())

View File

@ -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: def derived_cap(fps: int, budget: int, explicit: Optional[int], _guard: bool) -> int:
if not guard: physical_cap = min(max(1, budget // max(fps, 1)), MAX_MJPEG_FRAME_BYTES)
return MAX_MJPEG_FRAME_BYTES
if explicit and explicit > 0: if explicit and explicit > 0:
return min(explicit, MAX_MJPEG_FRAME_BYTES) return min(explicit, physical_cap)
per_frame = max(budget // max(fps, 1), 64 * 1024) return physical_cap
return min(per_frame, MAX_MJPEG_FRAME_BYTES)
def main() -> int: def main() -> int:

View File

@ -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())

View File

@ -25,6 +25,9 @@ DEFAULT_MEDIA_CONTROL_PATH = "/tmp/lesavka-media.control"
DEFAULT_SERVER_UVC_AUDIT_CONTROL_PATH = "/tmp/lesavka-uvc-frame-audit.control" DEFAULT_SERVER_UVC_AUDIT_CONTROL_PATH = "/tmp/lesavka-uvc-frame-audit.control"
MARKER_BITS = 32 MARKER_BITS = 32
MARKER_COLUMNS = 16 MARKER_COLUMNS = 16
BAND_MARKER_ROWS = 64
BAND_MARKER_BITS = 32
BAND_MARKER_CELL = 4
CADENCE_REASONS = {"frame_repeat", "frame_gap", "frame_backwards"} CADENCE_REASONS = {"frame_repeat", "frame_gap", "frame_backwards"}
NON_VISUAL_REASONS = CADENCE_REASONS | {"sequence_marker_mismatch"} NON_VISUAL_REASONS = CADENCE_REASONS | {"sequence_marker_mismatch"}
@ -267,6 +270,11 @@ def parse_args() -> argparse.Namespace:
action="store_true", action="store_true",
help="debug path: analyze ffmpeg stdout directly instead of spooling raw frames first", 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("--capture-only", action="store_true", help=argparse.SUPPRESS)
parser.add_argument("--self-test", action="store_true") parser.add_argument("--self-test", action="store_true")
return parser.parse_args() return parser.parse_args()
@ -305,7 +313,7 @@ def default_inject_max_frame_bytes(fps: int) -> int:
* DEFAULT_ISOCHRONOUS_LIMIT_PCT * DEFAULT_ISOCHRONOUS_LIMIT_PCT
// 100 // 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: def default_artifact_dir(mode: str) -> pathlib.Path:
@ -791,6 +799,8 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int:
] ]
if args.stream_analyze: if args.stream_analyze:
capture_cmd.append("--stream-analyze") capture_cmd.append("--stream-analyze")
if args.deep_capture:
capture_cmd.append("--deep-capture")
inject_cmd = [ inject_cmd = [
args.inject_binary, args.inject_binary,
"--server", "--server",
@ -929,6 +939,20 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int:
local_server_audit = artifact_dir / "server-uvc-audit" local_server_audit = artifact_dir / "server-uvc-audit"
if capture is not None: if capture is not None:
subprocess.run(["scp", "-r", f"{args.rct_host}:{remote_rct_dir}", str(local_capture)], check=False) 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 args.local_inject:
if pathlib.Path(remote_inject_dir).exists(): if pathlib.Path(remote_inject_dir).exists():
if local_inject.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) marker = synthetic_marker_luma(width, height, sequence, x, y)
if marker is not None: if marker is not None:
return marker 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) 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: def synthetic_gray(width: int, height: int, sequence: int) -> bytes:
data = bytearray(width * height) data = bytearray(width * height)
for y in range(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) 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: def run_capture(args: argparse.Namespace) -> int:
width, height, fps = mode_dimensions(args) width, height, fps = mode_dimensions(args)
command, capture_width, capture_height, device = ffmpeg_cmd(args, width, height) 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 = 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) 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 frame_size = capture_width * capture_height
stderr_path = artifact_dir / "ffmpeg.stderr" stderr_path = artifact_dir / "ffmpeg.stderr"
metrics_path = artifact_dir / "frame-metrics.jsonl" 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: if frame_index % args.progress_every == 0:
print(f"frames={frame_index} suspicious={suspicious_count} latest={result}", file=sys.stderr) print(f"frames={frame_index} suspicious={suspicious_count} latest={result}", file=sys.stderr)
try:
with stderr_path.open("wb") as err, metrics_path.open("w") as metrics: with stderr_path.open("wb") as err, metrics_path.open("w") as metrics:
if args.stream_analyze: if args.stream_analyze and not args.deep_capture:
(artifact_dir / "command.txt").write_text(" ".join(shlex.quote(part) for part in command) + "\n") (artifact_dir / "command.txt").write_text(" ".join(shlex.quote(part) for part in command) + "\n")
proc = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=err) proc = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=err)
assert proc.stdout is not None assert proc.stdout is not None
@ -1633,6 +1785,21 @@ def run_capture(args: argparse.Namespace) -> int:
analysis_elapsed = capture_elapsed analysis_elapsed = capture_elapsed
else: else:
raw_path = artifact_dir / "capture.raw" raw_path = artifact_dir / "capture.raw"
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:
native_mjpeg = None
capture_command = command[:] capture_command = command[:]
if "-an" in capture_command: if "-an" in capture_command:
capture_command[capture_command.index("-an") : capture_command.index("-an")] = ["-t", str(args.duration)] capture_command[capture_command.index("-an") : capture_command.index("-an")] = ["-t", str(args.duration)]
@ -1645,6 +1812,13 @@ def run_capture(args: argparse.Namespace) -> int:
proc = subprocess.run(capture_command, stdout=subprocess.DEVNULL, stderr=err, check=False) proc = subprocess.run(capture_command, stdout=subprocess.DEVNULL, stderr=err, check=False)
capture_elapsed = time.monotonic() - capture_started capture_elapsed = time.monotonic() - capture_started
ffmpeg_rc = proc.returncode 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 raw_capture_bytes = raw_path.stat().st_size if raw_path.exists() else 0
print( print(
f"analyzing captured raw RCT frames bytes={raw_capture_bytes} capture_s={capture_elapsed:.3f}", 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: finally:
raw_path.unlink(missing_ok=True) raw_path.unlink(missing_ok=True)
analysis_elapsed = time.monotonic() - analysis_started 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) elapsed = max(0.001, capture_elapsed)
summary = { summary = {
"schema": "lesavka.synthetic-rct-capture.v1", "schema": "lesavka.synthetic-rct-capture.v1",
"source": args.source, "source": args.source,
"device": device, "device": device,
"mode": args.mode, "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, "width": capture_width,
"height": capture_height, "height": capture_height,
"fps_requested": fps, "fps_requested": fps,

View File

@ -16,7 +16,7 @@ bench = false
[package] [package]
name = "lesavka_server" name = "lesavka_server"
version = "0.27.6" version = "0.27.7"
edition = "2024" edition = "2024"
autobins = false autobins = false

View File

@ -20,6 +20,9 @@ const DEFAULT_CHANNELS: u32 = 2;
const DEFAULT_JPEG_QUALITY: i32 = 82; const DEFAULT_JPEG_QUALITY: i32 = 82;
const MARKER_BITS: usize = 32; const MARKER_BITS: usize = 32;
const MARKER_COLUMNS: usize = 16; 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)] #[derive(Clone, Debug)]
struct Args { struct Args {
@ -258,7 +261,7 @@ impl MjpegEncoder {
let map = buffer let map = buffer
.map_readable() .map_readable()
.context("mapping encoded synthetic frame")?; .context("mapping encoded synthetic frame")?;
Ok(map.as_slice().to_vec()) add_jpeg_app4_integrity(map.as_slice(), sequence)
} }
} }

View File

@ -1,6 +1,8 @@
// lesavka-uvc - minimal UVC control handler for the gadget node. // lesavka-uvc - minimal UVC control handler for the gadget node.
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use lesavka_server::uvc_contract::{self, UvcContractInput, UvcPayloadContract};
use std::collections::VecDeque;
use std::env; use std::env;
use std::fs::{File, OpenOptions}; use std::fs::{File, OpenOptions};
use std::os::unix::fs::OpenOptionsExt; 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_QUEUE_PACING: bool = false;
const DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC: u32 = 4_500_000; const DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC: u32 = 4_500_000;
const DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT: u32 = 85; 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_INTERVAL_MS: u64 = 5_000;
const DEFAULT_UVC_STATS_PATH: &str = "/run/lesavka-uvc-video-stats.json"; 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)] #[repr(C)]
struct V4l2EventSubscription { struct V4l2EventSubscription {
@ -214,6 +220,7 @@ struct UvcState {
probe: [u8; STREAM_CTRL_SIZE_MAX], probe: [u8; STREAM_CTRL_SIZE_MAX],
commit: [u8; STREAM_CTRL_SIZE_MAX], commit: [u8; STREAM_CTRL_SIZE_MAX],
cfg_snapshot: Option<ConfigfsSnapshot>, cfg_snapshot: Option<ConfigfsSnapshot>,
commit_observed: bool,
} }
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
@ -235,8 +242,10 @@ struct ConfigfsSnapshot {
height: u32, height: u32,
default_interval: u32, default_interval: u32,
frame_interval: u32, frame_interval: u32,
frame_size: u32,
maxpacket: u32, maxpacket: u32,
maxburst: u32, maxburst: u32,
streaming_bulk: Option<u32>,
} }
struct MmapBuffer { struct MmapBuffer {
@ -255,6 +264,12 @@ struct UvcVideoStream {
streaming: bool, streaming: bool,
transport_bulk: bool, transport_bulk: bool,
max_packet: u32, max_packet: u32,
has_verified_frame: bool,
last_verified_mtime: Option<SystemTime>,
last_spool_mtime_ns: Option<u128>,
last_refresh_disposition: &'static str,
handoff_sequence: u64,
handoff_audit: VecDeque<String>,
stats: UvcVideoStats, stats: UvcVideoStats,
} }
@ -266,6 +281,12 @@ struct UvcVideoStats {
rejected_oversize: u64, rejected_oversize: u64,
rejected_invalid: u64, rejected_invalid: u64,
fallback_idle: 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, latest_bytes: usize,
last_rejected_oversize_bytes: usize, last_rejected_oversize_bytes: usize,
last_rejected_oversize_cap: usize, last_rejected_oversize_cap: usize,
@ -287,6 +308,12 @@ impl UvcVideoStream {
streaming: false, streaming: false,
transport_bulk: false, transport_bulk: false,
max_packet: 0, 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(), stats: UvcVideoStats::default(),
} }
} }
@ -301,7 +328,7 @@ impl UvcVideoStream {
self.set_format(cfg)?; self.set_format(cfg)?;
self.request_buffers(uvc_buffer_count())?; self.request_buffers(uvc_buffer_count())?;
for index in 0..self.buffers.len() { for index in 0..self.buffers.len() {
self.queue_buffer(index as u32)?; self.queue_buffer(index as u32, 0)?;
} }
let req = ioctl_write::<libc::c_int>(b'V', 18); let req = ioctl_write::<libc::c_int>(b'V', 18);
let mut type_ = V4L2_BUF_TYPE_VIDEO_OUTPUT as libc::c_int; 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(); let err = std::io::Error::last_os_error();
match err.raw_os_error() { match err.raw_os_error() {
Some(libc::EAGAIN) | Some(libc::EINTR) => return Ok(()), 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(()) Ok(())
} }
fn queue_buffer(&mut self, index: u32) -> Result<()> { fn queue_buffer(&mut self, index: u32, dqbuf_flags: u32) -> Result<()> {
if self.streaming { if self.streaming {
self.pace_queue_if_needed(); self.pace_queue_if_needed();
} }
@ -456,6 +489,8 @@ impl UvcVideoStream {
}; };
let frame = self.frame_for_buffer(buffer.len); let frame = self.frame_for_buffer(buffer.len);
let bytes = frame.len(); let bytes = frame.len();
let frame_hash = fnv1a64(frame);
let frame_sequence = uvc_contract::lesavka_app4_sequence(frame);
if bytes > 0 { if bytes > 0 {
unsafe { unsafe {
std::ptr::copy_nonoverlapping(frame.as_ptr(), buffer.ptr, bytes); std::ptr::copy_nonoverlapping(frame.as_ptr(), buffer.ptr, bytes);
@ -478,10 +513,12 @@ impl UvcVideoStream {
let req = ioctl_readwrite::<V4l2Buffer>(b'V', 15); let req = ioctl_readwrite::<V4l2Buffer>(b'V', 15);
let rc = unsafe { libc::ioctl(self.fd, req, &mut buf) }; let rc = unsafe { libc::ioctl(self.fd, req, &mut buf) };
if rc < 0 { if rc < 0 {
self.stats.qbuf_ioctl_errors += 1;
return Err(std::io::Error::last_os_error()).context("VIDIOC_QBUF"); return Err(std::io::Error::last_os_error()).context("VIDIOC_QBUF");
} }
self.stats.queued += 1; self.stats.queued += 1;
self.stats.latest_bytes = bytes; self.stats.latest_bytes = bytes;
self.record_handoff(index, dqbuf_flags, bytes, frame_hash, frame_sequence);
self.report_stats_if_due(); self.report_stats_if_due();
Ok(()) Ok(())
} }
@ -503,35 +540,63 @@ impl UvcVideoStream {
} }
fn refresh_latest_frame(&mut self) { 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()); let stale = frame_spool_is_stale(&self.frame_path, frame_spool_max_age());
if stale { if stale {
self.stats.replayed_stale += 1; self.stats.replayed_stale += 1;
self.replace_latest_with_idle(); self.hold_last_good_or_idle("hold_stale", "idle_stale");
return; return;
} }
let max_frame_bytes = self.frame_payload_limit(); let max_frame_bytes = self.frame_payload_limit();
match std::fs::read(&self.frame_path) { match std::fs::read(&self.frame_path) {
Ok(frame) if !looks_like_mjpeg_frame(&frame) => { Ok(frame) if !looks_like_mjpeg_frame(&frame) => {
self.stats.rejected_invalid += 1; 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 => { Ok(frame) if frame.len() > max_frame_bytes => {
self.stats.rejected_oversize += 1; self.stats.rejected_oversize += 1;
self.stats.last_rejected_oversize_bytes = frame.len(); self.stats.last_rejected_oversize_bytes = frame.len();
self.stats.last_rejected_oversize_cap = max_frame_bytes; 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) => { Ok(frame) => {
self.stats.reloaded += 1; if strict_validation_due(self.stats.reloaded)
self.latest_frame = frame; && (!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) { if !looks_like_mjpeg_frame(&self.latest_frame) {
self.replace_latest_with_idle(); 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) { fn replace_latest_with_idle(&mut self) {
if self.latest_frame.as_slice() != IDLE_MJPEG_FRAME { if self.latest_frame.as_slice() != IDLE_MJPEG_FRAME {
self.stats.fallback_idle += 1; self.stats.fallback_idle += 1;
@ -539,6 +604,53 @@ impl UvcVideoStream {
self.latest_frame = IDLE_MJPEG_FRAME.to_vec(); 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<u64>,
) {
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::<String>();
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 { fn frame_payload_limit(&self) -> usize {
self.buffers self.buffers
.iter() .iter()
@ -572,13 +684,19 @@ impl UvcVideoStream {
} }
self.stats.last_report = Some(now); self.stats.last_report = Some(now);
eprintln!( 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.queued,
self.stats.reloaded, self.stats.reloaded,
self.stats.replayed_stale, self.stats.replayed_stale,
self.stats.rejected_oversize, self.stats.rejected_oversize,
self.stats.rejected_invalid, self.stats.rejected_invalid,
self.stats.fallback_idle, 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.stats.latest_bytes,
self.frame_payload_limit(), self.frame_payload_limit(),
self.stats.last_rejected_oversize_bytes, self.stats.last_rejected_oversize_bytes,
@ -683,18 +801,17 @@ fn uvc_queue_period(fps: u32) -> Option<Duration> {
/// half-frame grey smears, and freezing the last good frame is preferable to /// half-frame grey smears, and freezing the last good frame is preferable to
/// queueing a frame that is likely to arrive incomplete. /// queueing a frame that is likely to arrive incomplete.
fn uvc_frame_max_bytes(cfg: UvcConfig) -> usize { fn uvc_frame_max_bytes(cfg: UvcConfig) -> usize {
if !uvc_frame_size_guard_enabled() { if let Ok(contract) = uvc_contract::read_contract(&uvc_contract::contract_path()) {
return MAX_MJPEG_FRAME_BYTES; 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") { let derived = derived_uvc_frame_max_bytes(cfg).min(cfg.frame_size as usize);
return if limit == 0 { env_u32_opt("LESAVKA_UVC_FRAME_MAX_BYTES")
derived_uvc_frame_max_bytes(cfg) .filter(|limit| *limit > 0)
} else { .map(|limit| derived.min(limit as usize))
(limit as usize).min(MAX_MJPEG_FRAME_BYTES) .unwrap_or(derived)
};
}
derived_uvc_frame_max_bytes(cfg)
} }
fn derived_uvc_frame_max_bytes(cfg: UvcConfig) -> usize { 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 { 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 budget_per_sec = effective_uvc_mjpeg_budget_bytes_per_sec(max_packet, bulk);
let per_frame = (budget_per_sec / fps).max(64 * 1024); uvc_contract::frame_cap_from_budget(budget_per_sec, fps) as usize
per_frame.min(MAX_MJPEG_FRAME_BYTES as u32) as usize
} }
fn effective_uvc_mjpeg_budget_bytes_per_sec(max_packet: u32, bulk: bool) -> u32 { 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, DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC,
) )
.max(1); .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( let pct = env_u32(
"LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT", "LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT",
DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT, DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT,
) );
.clamp(1, 100); uvc_contract::effective_mjpeg_budget_bytes_per_sec(configured, max_packet, bulk, pct)
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
} }
fn env_flag_enabled(name: &str, default: bool) -> bool { 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) .unwrap_or(default)
} }
fn uvc_frame_size_guard_enabled() -> bool { fn uvc_bulk_transfer_enabled() -> bool {
env_flag_enabled("LESAVKA_UVC_FRAME_SIZE_GUARD", true) uvc_bulk_transfer_enabled_for_base(std::path::Path::new(CONFIGFS_UVC_BASE))
} }
fn uvc_bulk_transfer_enabled() -> bool { fn uvc_bulk_transfer_enabled_for_base(base: &std::path::Path) -> bool {
if !env_flag_enabled("LESAVKA_UVC_BULK", true) { let requested = env_flag_enabled("LESAVKA_UVC_BULK", true);
return false; let bulk = uvc_contract::bulk_enabled_from_configfs(base, requested);
} if requested && !bulk {
let base = std::path::Path::new(CONFIGFS_UVC_BASE);
if base.exists() && !base.join("streaming_bulk").exists() {
eprintln!( 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( fn uvc_frame_size_for_active_mode(
@ -785,12 +882,12 @@ fn uvc_frame_size_for_active_mode(
max_packet: u32, max_packet: u32,
bulk: bool, bulk: bool,
) -> u32 { ) -> u32 {
env_u32_opt("LESAVKA_UVC_FRAME_SIZE") let derived = derived_uvc_frame_max_bytes_for_transport(fps, max_packet, bulk)
.unwrap_or_else(|| { .min(u32::MAX as usize) as u32;
derived_uvc_frame_max_bytes_for_transport(fps, max_packet, bulk) let requested = env_u32_opt("LESAVKA_UVC_FRAME_SIZE").filter(|value| *value > 0);
.min(u32::MAX as usize) as u32 let frame_size = requested.unwrap_or(derived).min(derived);
}) let minimum = (width.saturating_mul(height) / 32).max(64 * 1024);
.max((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<()> { 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 { fn uvc_stats_snapshot_json(stats: &UvcVideoStats, frame_cap: usize) -> String {
let kernel = uvc_kernel_stats_json();
format!( 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.queued,
stats.reloaded, stats.reloaded,
stats.replayed_stale, stats.replayed_stale,
stats.rejected_oversize, stats.rejected_oversize,
stats.rejected_invalid, stats.rejected_invalid,
stats.fallback_idle, 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, stats.latest_bytes,
frame_cap, frame_cap,
stats.last_rejected_oversize_bytes, stats.last_rejected_oversize_bytes,
stats.last_rejected_oversize_cap, stats.last_rejected_oversize_cap,
stats.paced_sleeps, 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::<serde_json::Value>(&value).ok())
.map(|value| value.to_string())
.unwrap_or_else(|| "null".to_string())
}
fn uvc_stats_interval() -> Option<Duration> { fn uvc_stats_interval() -> Option<Duration> {
match env_u64("LESAVKA_UVC_STATS_INTERVAL_MS", DEFAULT_UVC_STATS_INTERVAL_MS) { match env_u64("LESAVKA_UVC_STATS_INTERVAL_MS", DEFAULT_UVC_STATS_INTERVAL_MS) {
0 => None, 0 => None,
@ -838,6 +955,80 @@ fn frame_spool_max_age() -> Option<Duration> {
} }
} }
fn uvc_idle_after_stale() -> Duration {
let value = env::var("LESAVKA_UVC_IDLE_AFTER_MS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.or_else(|| {
env::var("LESAVKA_UVC_IDLE_AFTER_STALE_MS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
})
.unwrap_or(DEFAULT_UVC_IDLE_AFTER_STALE_MS);
Duration::from_millis(value)
}
fn verified_frame_idle_timeout_elapsed(modified: Option<SystemTime>) -> 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<SystemTime> {
std::fs::metadata(path).ok()?.modified().ok()
}
fn frame_spool_mtime_ns(path: &std::path::Path) -> Option<u128> {
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<std::path::PathBuf> {
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<Duration>) -> bool { fn frame_spool_is_stale(path: &std::path::Path, max_age: Option<Duration>) -> bool {
let Some(max_age) = max_age else { let Some(max_age) = max_age else {
return false; return false;
@ -1069,7 +1260,8 @@ impl UvcConfig {
} else { } else {
requested_interval 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 { let live_interval = if snapshot.default_interval == 0 {
snapshot.frame_interval snapshot.frame_interval
} else { } else {
@ -1149,7 +1341,13 @@ impl UvcConfig {
} else { } else {
max_packet = max_packet.min(1024); 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 { Self {
width, width,
@ -1168,14 +1366,77 @@ impl UvcState {
let _profile_hint = (cfg.width, cfg.height, cfg.fps); let _profile_hint = (cfg.width, cfg.height, cfg.fps);
let ctrl_len = stream_ctrl_len(); let ctrl_len = stream_ctrl_len();
let default = build_streaming_control(&cfg, ctrl_len); let default = build_streaming_control(&cfg, ctrl_len);
Self { let state = Self {
cfg, cfg,
ctrl_len, ctrl_len,
default, default,
probe: default, probe: default,
commit: default, commit: default,
cfg_snapshot: None, 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); let sanitized = sanitize_streaming_control(slice, state);
if p.selector == UVC_VS_PROBE_CONTROL { if p.selector == UVC_VS_PROBE_CONTROL {
state.probe = sanitized; state.probe = sanitized;
state.publish_payload_contract("probe");
if debug { if debug {
let interval = read_le32(&state.probe, 4); let interval = read_le32(&state.probe, 4);
let payload = read_le32(&state.probe, 22); let payload = read_le32(&state.probe, 22);
@ -1467,6 +1729,8 @@ fn handle_data(
} }
} else { } else {
state.commit = sanitized; state.commit = sanitized;
state.commit_observed = true;
state.publish_payload_contract("commit");
if debug { if debug {
let interval = read_le32(&state.commit, 4); let interval = read_le32(&state.commit, 4);
let payload = read_le32(&state.commit, 22); let payload = read_le32(&state.commit, 22);
@ -1716,15 +1980,20 @@ fn read_configfs_snapshot() -> Option<ConfigfsSnapshot> {
"{frame_root}/dwDefaultFrameInterval" "{frame_root}/dwDefaultFrameInterval"
))?; ))?;
let frame_interval = read_u32_first(&format!("{frame_root}/dwFrameInterval")).unwrap_or(0); 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 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 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 { Some(ConfigfsSnapshot {
width, width,
height, height,
default_interval, default_interval,
frame_interval, frame_interval,
frame_size,
maxpacket, maxpacket,
maxburst, maxburst,
streaming_bulk,
}) })
} }
@ -1747,13 +2016,15 @@ fn log_configfs_snapshot(state: &mut UvcState, label: &str) {
return; return;
} }
eprintln!( 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.width,
current.height, current.height,
current.default_interval, current.default_interval,
current.frame_interval, current.frame_interval,
current.frame_size,
current.maxpacket, current.maxpacket,
current.maxburst current.maxburst,
current.streaming_bulk
); );
state.cfg_snapshot = Some(current); state.cfg_snapshot = Some(current);
} }

View File

@ -81,6 +81,9 @@ fn synthetic_luma(
let (moving_width, moving_offset) = moving_bar; let (moving_width, moving_offset) = moving_bar;
let width = width.max(1); let width = width.max(1);
let height = height.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_w = (width / 24).max(24);
let block_h = (height / 18).max(18); let block_h = (height / 18).max(18);
let base = 44 let base = 44
@ -107,6 +110,36 @@ fn synthetic_luma(
value value
} }
fn synthetic_band_marker_luma(
width: usize,
height: usize,
x: usize,
y: usize,
sequence: u64,
) -> Option<u8> {
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<Vec<u8>> {
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 { fn marker_cell(width: usize, height: usize) -> usize {
(width.min(height) / 80).clamp(6, 16) (width.min(height) / 80).clamp(6, 16)
} }

View File

@ -88,6 +88,27 @@ fn synthetic_frame_helpers_handle_markers_bounds_and_silence() {
fill_rect(&mut frame, 6, 4, 2, 8, 8, 77); 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[(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]); 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] #[test]

View File

@ -100,6 +100,7 @@ fn handle_data(
state.probe = sanitized; state.probe = sanitized;
} else { } else {
state.commit = sanitized; state.commit = sanitized;
state.commit_observed = true;
} }
} }

View File

@ -57,6 +57,7 @@ impl UvcState {
probe: default, probe: default,
commit: default, commit: default,
cfg_snapshot: None, 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: /// Inputs: configfs function root. Output: effective bulk capability. Why:
/// tests need this branch without depending on the host's real gadget tree. /// 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 { fn uvc_bulk_transfer_enabled_for_base(base: &std::path::Path) -> bool {
if base.exists() && !base.join("streaming_bulk").exists() { lesavka_server::uvc_contract::bulk_enabled_from_configfs(
return false; base,
} env_flag_enabled("LESAVKA_UVC_BULK", true),
true )
} }
#[cfg(coverage)] #[cfg(coverage)]
@ -136,10 +137,11 @@ fn uvc_frame_size_for_active_mode(
max_packet: u32, max_packet: u32,
bulk: bool, bulk: bool,
) -> u32 { ) -> 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") env_u32_opt("LESAVKA_UVC_FRAME_SIZE")
.unwrap_or_else(|| { .filter(|value| *value > 0)
derived_uvc_frame_max_bytes_for_transport(fps, max_packet, bulk) .unwrap_or(derived)
.min(u32::MAX as usize) as u32 .min(derived)
}) .max((width.saturating_mul(height) / 32).max(64 * 1024).min(derived))
.max((width.saturating_mul(height) / 32).max(64 * 1024))
} }

View File

@ -43,18 +43,19 @@ fn uvc_queue_period(fps: u32) -> Option<std::time::Duration> {
/// byte length. Why: coverage tests should lock the artifact-prevention budget /// byte length. Why: coverage tests should lock the artifact-prevention budget
/// that turns oversized UVC frames into freezes instead of grey half-frames. /// that turns oversized UVC frames into freezes instead of grey half-frames.
fn uvc_frame_max_bytes(cfg: UvcConfig) -> usize { fn uvc_frame_max_bytes(cfg: UvcConfig) -> usize {
if !uvc_frame_size_guard_enabled() { if let Ok(contract) = lesavka_server::uvc_contract::read_contract(
return MAX_MJPEG_FRAME_BYTES; &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") { let derived = derived_uvc_frame_max_bytes(cfg).min(cfg.frame_size as usize);
return if limit == 0 { env_u32_opt("LESAVKA_UVC_FRAME_MAX_BYTES")
derived_uvc_frame_max_bytes(cfg) .filter(|limit| *limit > 0)
} else { .map(|limit| derived.min(limit as usize))
(limit as usize).min(MAX_MJPEG_FRAME_BYTES) .unwrap_or(derived)
};
}
derived_uvc_frame_max_bytes(cfg)
} }
#[cfg(coverage)] #[cfg(coverage)]
@ -64,10 +65,8 @@ fn derived_uvc_frame_max_bytes(cfg: UvcConfig) -> usize {
#[cfg(coverage)] #[cfg(coverage)]
fn derived_uvc_frame_max_bytes_for_transport(fps: u32, max_packet: u32, bulk: bool) -> 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 budget_per_sec = effective_uvc_mjpeg_budget_bytes_per_sec(max_packet, bulk);
let per_frame = (budget_per_sec / fps).max(64 * 1024); lesavka_server::uvc_contract::frame_cap_from_budget(budget_per_sec, fps) as usize
per_frame.min(MAX_MJPEG_FRAME_BYTES as u32) as usize
} }
#[cfg(coverage)] #[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, DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC,
) )
.max(1); .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( let pct = env_u32(
"LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT", "LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT",
DEFAULT_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)] #[cfg(coverage)]

View File

@ -1,5 +1,7 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
#[cfg(coverage)] #[cfg(coverage)]
use lesavka_server::uvc_contract;
#[cfg(coverage)]
use std::env; use std::env;
#[cfg(coverage)] #[cfg(coverage)]
use std::fs::OpenOptions; use std::fs::OpenOptions;
@ -63,12 +65,14 @@ const DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC: u32 = 4_500_000;
#[cfg(coverage)] #[cfg(coverage)]
const DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT: u32 = 85; const DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT: u32 = 85;
#[cfg(coverage)] #[cfg(coverage)]
const HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC: u32 = 8_000;
#[cfg(coverage)]
const DEFAULT_UVC_STATS_INTERVAL_MS: u64 = 5_000; const DEFAULT_UVC_STATS_INTERVAL_MS: u64 = 5_000;
#[cfg(coverage)] #[cfg(coverage)]
const DEFAULT_UVC_STATS_PATH: &str = "/run/lesavka-uvc-video-stats.json"; const DEFAULT_UVC_STATS_PATH: &str = "/run/lesavka-uvc-video-stats.json";
#[cfg(coverage)] #[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; const MAX_MJPEG_FRAME_BYTES: usize = 8 * 1024 * 1024;
#[cfg(coverage)] #[cfg(coverage)]
const MINIMAL_MJPEG_FRAME: &[u8] = &[0xff, 0xd8, 0xff, 0xd9]; const MINIMAL_MJPEG_FRAME: &[u8] = &[0xff, 0xd8, 0xff, 0xd9];
@ -130,6 +134,7 @@ struct UvcState {
probe: [u8; STREAM_CTRL_SIZE_MAX], probe: [u8; STREAM_CTRL_SIZE_MAX],
commit: [u8; STREAM_CTRL_SIZE_MAX], commit: [u8; STREAM_CTRL_SIZE_MAX],
cfg_snapshot: Option<ConfigfsSnapshot>, cfg_snapshot: Option<ConfigfsSnapshot>,
commit_observed: bool,
} }
#[cfg(coverage)] #[cfg(coverage)]
@ -154,8 +159,10 @@ struct ConfigfsSnapshot {
height: u32, height: u32,
default_interval: u32, default_interval: u32,
frame_interval: u32, frame_interval: u32,
frame_size: u32,
maxpacket: u32, maxpacket: u32,
maxburst: u32, maxburst: u32,
streaming_bulk: Option<u32>,
} }
#[cfg(coverage)] #[cfg(coverage)]
@ -175,6 +182,8 @@ struct UvcVideoStream {
next_queue_at: Option<std::time::Instant>, next_queue_at: Option<std::time::Instant>,
transport_bulk: bool, transport_bulk: bool,
max_packet: u32, max_packet: u32,
has_verified_frame: bool,
last_verified_mtime: Option<std::time::SystemTime>,
} }
#[cfg(coverage)] #[cfg(coverage)]
@ -186,6 +195,12 @@ struct UvcVideoStats {
rejected_oversize: u64, rejected_oversize: u64,
rejected_invalid: u64, rejected_invalid: u64,
fallback_idle: 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, latest_bytes: usize,
last_rejected_oversize_bytes: usize, last_rejected_oversize_bytes: usize,
last_rejected_oversize_cap: usize, last_rejected_oversize_cap: usize,
@ -212,6 +227,8 @@ impl UvcVideoStream {
next_queue_at: None, next_queue_at: None,
transport_bulk: false, transport_bulk: false,
max_packet: 0, 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()); let stale = frame_spool_is_stale(&self.frame_path, frame_spool_max_age());
if stale { if stale {
self.stats.replayed_stale += 1; self.stats.replayed_stale += 1;
self.replace_latest_with_idle(); self.hold_last_good_or_idle();
return; return;
} }
let max_frame_bytes = self.frame_payload_limit(); let max_frame_bytes = self.frame_payload_limit();
@ -233,6 +250,8 @@ impl UvcVideoStream {
self.stats.reloaded += 1; self.stats.reloaded += 1;
self.stats.latest_bytes = frame.len(); self.stats.latest_bytes = frame.len();
self.latest_frame = frame; self.latest_frame = frame;
self.has_verified_frame = true;
self.last_verified_mtime = frame_spool_modified(&self.frame_path);
} else { } else {
if frame.len() > max_frame_bytes && looks_like_mjpeg_frame(&frame) { if frame.len() > max_frame_bytes && looks_like_mjpeg_frame(&frame) {
self.stats.rejected_oversize += 1; self.stats.rejected_oversize += 1;
@ -241,16 +260,25 @@ impl UvcVideoStream {
} else { } else {
self.stats.rejected_invalid += 1; self.stats.rejected_invalid += 1;
} }
self.replace_latest_with_idle(); self.hold_last_good_or_idle();
} }
} else { } 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) { if !looks_like_mjpeg_frame(&self.latest_frame) {
self.replace_latest_with_idle(); 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) { fn replace_latest_with_idle(&mut self) {
if self.latest_frame.as_slice() != IDLE_MJPEG_FRAME { if self.latest_frame.as_slice() != IDLE_MJPEG_FRAME {
self.stats.fallback_idle += 1; self.stats.fallback_idle += 1;

View File

@ -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: /// Inputs: current counters and frame cap. Output: one JSON object string. Why:
/// shell probes need stable fields without depending on Rust serialization. /// shell probes need stable fields without depending on Rust serialization.
fn uvc_stats_snapshot_json(stats: &UvcVideoStats, frame_cap: usize) -> String { fn uvc_stats_snapshot_json(stats: &UvcVideoStats, frame_cap: usize) -> String {
let kernel = uvc_kernel_stats_json();
format!( 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.queued,
stats.reloaded, stats.reloaded,
stats.replayed_stale, stats.replayed_stale,
stats.rejected_oversize, stats.rejected_oversize,
stats.rejected_invalid, stats.rejected_invalid,
stats.fallback_idle, 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, stats.latest_bytes,
frame_cap, frame_cap,
stats.last_rejected_oversize_bytes, stats.last_rejected_oversize_bytes,
stats.last_rejected_oversize_cap, stats.last_rejected_oversize_cap,
stats.paced_sleeps, 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::<serde_json::Value>(&value).ok())
.map(|value| value.to_string())
.unwrap_or_else(|| "null".to_string())
}
#[cfg(coverage)] #[cfg(coverage)]
/// Returns the optional periodic stats write interval. /// Returns the optional periodic stats write interval.
/// ///
@ -104,3 +124,26 @@ fn frame_spool_is_stale(path: &std::path::Path, max_age: Option<std::time::Durat
.map(|age| age > max_age) .map(|age| age > max_age)
.unwrap_or(false) .unwrap_or(false)
} }
#[cfg(coverage)]
fn frame_spool_modified(path: &std::path::Path) -> Option<std::time::SystemTime> {
std::fs::metadata(path).ok()?.modified().ok()
}
#[cfg(coverage)]
fn verified_frame_idle_timeout_elapsed(modified: Option<std::time::SystemTime>) -> 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::<u64>().ok())
.unwrap_or(DEFAULT_UVC_IDLE_AFTER_STALE_MS),
)
})
.unwrap_or(false)
}

View File

@ -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")), ("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")), ("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"); let configfs = tempfile::tempdir().expect("tempdir");
assert!(!uvc_bulk_transfer_enabled_for_base(configfs.path())); 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())); 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] #[test]
#[serial] #[serial]
fn main_coverage_mode_returns_error_for_non_uvc_node() { fn main_coverage_mode_returns_error_for_non_uvc_node() {

View File

@ -20,6 +20,7 @@ pub mod paste;
pub mod runtime_support; pub mod runtime_support;
pub mod security; pub mod security;
pub mod upstream_media_runtime; pub mod upstream_media_runtime;
pub mod uvc_contract;
pub mod uvc_runtime; pub mod uvc_runtime;
pub mod video; pub mod video;
pub(crate) mod video_sinks; pub(crate) mod video_sinks;

View File

@ -39,7 +39,7 @@ use lesavka_server::{
PlannedUpstreamPacket, UpstreamClientTiming, UpstreamMediaKind, UpstreamMediaRuntime, PlannedUpstreamPacket, UpstreamClientTiming, UpstreamMediaKind, UpstreamMediaRuntime,
UpstreamPlanDecision, UpstreamPlanDecision,
}, },
uvc_runtime, video, uvc_contract, uvc_runtime, video,
}; };
/*──────────────── constants ────────────────*/ /*──────────────── constants ────────────────*/

View File

@ -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<u64>,
}
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::<serde_json::Value>(&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 { impl Handler {
/// Reopen HID handles and verify enumeration without cycling the USB gadget. /// Reopen HID handles and verify enumeration without cycling the USB gadget.
async fn recover_usb_reply(&self) -> Result<Response<ResetUsbReply>, Status> { async fn recover_usb_reply(&self) -> Result<Response<ResetUsbReply>, Status> {
@ -175,6 +306,53 @@ impl Handler {
/// Inputs are the typed parameters; output is the return value or side effect. /// Inputs are the typed parameters; output is the return value or side effect.
async fn get_upstream_sync_reply(&self) -> Result<Response<UpstreamSyncState>, Status> { async fn get_upstream_sync_reply(&self) -> Result<Response<UpstreamSyncState>, Status> {
let snapshot = self.upstream_media_rt.snapshot(); 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 { Ok(Response::new(UpstreamSyncState {
session_id: snapshot.session_id, session_id: snapshot.session_id,
phase: snapshot.phase.to_string(), phase: snapshot.phase.to_string(),
@ -237,6 +415,30 @@ impl Handler {
.map(|value| value as f32), .map(|value| value as f32),
client_timing_window_samples: snapshot.client_timing_window_samples, client_timing_window_samples: snapshot.client_timing_window_samples,
sink_handoff_window_samples: snapshot.sink_handoff_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,
})) }))
} }
} }

566
server/src/uvc_contract.rs Normal file
View File

@ -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<u32>,
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<u32>,
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<u32>,
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<u32>,
}
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<UvcPayloadContract> {
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<u32> {
std::fs::read_to_string(base.join("streaming_bulk"))
.ok()
.and_then(|value| value.trim().parse::<u32>().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<u32>,
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<u64> {
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<bool> {
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<Vec<u8>> {
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"));
}
}

View File

@ -6,16 +6,20 @@ use std::time::{SystemTime, UNIX_EPOCH};
use gstreamer as gst; use gstreamer as gst;
use gstreamer_app as gst_app; 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"] #[path = "mjpeg_spool/audit.rs"]
mod audit; mod audit;
static SPOOL_SEQUENCE: AtomicU64 = AtomicU64::new(1); static SPOOL_SEQUENCE: AtomicU64 = AtomicU64::new(1);
static SPOOL_TEMP_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"; const CONFIGFS_UVC_BASE: &str = "/sys/kernel/config/usb_gadget/lesavka/functions/uvc.usb0";
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
@ -173,11 +177,11 @@ fn read_u32_file(path: impl AsRef<Path>) -> Option<u32> {
} }
fn uvc_bulk_transfer_enabled() -> bool { fn uvc_bulk_transfer_enabled() -> bool {
if !env_flag_enabled("LESAVKA_UVC_BULK", true) {
return false;
}
let base = Path::new(CONFIGFS_UVC_BASE); 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 { 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 { fn effective_mjpeg_budget_bytes_per_sec() -> u32 {
let configured = env_u32_opt("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC") let configured = env_u32_opt("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC")
.unwrap_or(DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC) .unwrap_or(DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC)
.max(1); .max(1);
if uvc_bulk_transfer_enabled() { let bulk = uvc_bulk_transfer_enabled();
configured let maxpacket = uvc_streaming_maxpacket(bulk);
} else { let pct = env_u32_opt("LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT")
configured .unwrap_or(DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT);
.min(uvc_isochronous_budget_bytes_per_sec(uvc_streaming_maxpacket(false))) uvc_contract::effective_mjpeg_budget_bytes_per_sec(configured, maxpacket, bulk, pct)
.max(1)
}
} }
/// Resolve the MJPEG byte budget used before publishing to the helper. /// 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; /// 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. /// a short freeze is better than letting the USB gadget emit partial pictures.
pub(super) fn mjpeg_spool_frame_max_bytes(fps: u32) -> usize { pub(super) fn mjpeg_spool_frame_max_bytes(fps: u32) -> usize {
if !env_flag_enabled("LESAVKA_UVC_FRAME_SIZE_GUARD", true) { let path = uvc_contract::contract_path();
return MAX_MJPEG_FRAME_BYTES; 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()),
);
} }
if let Some(limit) = env_u32_opt("LESAVKA_UVC_FRAME_MAX_BYTES")
&& limit > 0
{
return (limit as usize).min(MAX_MJPEG_FRAME_BYTES);
} }
let fps = fps.max(1); let fps = fps.max(1);
let budget_per_sec = effective_mjpeg_budget_bytes_per_sec(); let budget_per_sec = effective_mjpeg_budget_bytes_per_sec();
let per_frame = (budget_per_sec / fps).max(64 * 1024); let requested = env_u32_opt("LESAVKA_UVC_FRAME_MAX_BYTES");
per_frame.min(MAX_MJPEG_FRAME_BYTES as u32) as usize 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<u32> {
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. /// Decide whether frame spool metadata should be published.

View File

@ -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")), ("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] #[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( temp_env::with_vars(
[ [
("LESAVKA_UVC_FRAME_SIZE_GUARD", Some("1")), ("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")), ("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")), ("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", Some("1")),
], ],
|| { || {
assert_eq!( assert_eq!(super::mjpeg_spool_frame_max_bytes(30), 1);
super::mjpeg_spool_frame_max_bytes(30),
super::MAX_MJPEG_FRAME_BYTES
);
}, },
); );
} }

View File

@ -10,6 +10,8 @@ use super::mjpeg_spool::freshest_mjpeg_sample;
use super::mjpeg_spool::{MjpegSpoolTiming, spool_mjpeg_frame_with_timing}; use super::mjpeg_spool::{MjpegSpoolTiming, spool_mjpeg_frame_with_timing};
use super::*; use super::*;
use crate::video_support::{contains_hevc_irap, reserve_local_pts}; 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 { impl WebcamSink {
/// Push one client frame into the UVC pipeline. /// Push one client frame into the UVC pipeline.
@ -344,7 +346,9 @@ impl WebcamSink {
self.spool_guarded_passthrough_direct_mjpeg_frame(path, pkt); self.spool_guarded_passthrough_direct_mjpeg_frame(path, pkt);
return; 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); let previous_bytes = self.last_mjpeg_passthrough_bytes.load(Ordering::Relaxed);
if let Some(reason) = hevc_mjpeg_guard::direct_mjpeg_reject_reason( if let Some(reason) = hevc_mjpeg_guard::direct_mjpeg_reject_reason(
previous_bytes, previous_bytes,

View File

@ -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_LIMIT_PCT=${LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT:-85}",
"uvc_isochronous_budget_bytes_per_sec()", "uvc_isochronous_budget_bytes_per_sec()",
"isoch_budget=\"$(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()", "write_active_mjpeg_frame_descriptor()",
"if flag_enabled \"$UVC_ADVERTISE_EXTRA_MODES\"; then", "if flag_enabled \"$UVC_ADVERTISE_EXTRA_MODES\"; then",
"write_mjpeg_frame_descriptor 1080p 1920 1080", "write_mjpeg_frame_descriptor 1080p 1920 1080",

View File

@ -431,10 +431,12 @@ fn server_install_pins_hdmi_camera_and_display_defaults() {
assert!( assert!(
SERVER_INSTALL.contains( SERVER_INSTALL.contains(
"updated UVC env matches the live descriptor, restarting lesavka-server only" "updated UVC env matches the live descriptor, restarting lesavka-server only"
) && SERVER_INSTALL.contains( ) && SERVER_INSTALL
"Preserving lesavka-core and lesavka-uvc so the attached USB gadget is not cycled" .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 restart only the server process, not the attached gadget path" "safe env repairs should preserve the gadget and require an explicit helper-only refresh"
); );
assert!( assert!(
SERVER_INSTALL.contains("dwDefaultFrameInterval"), SERVER_INSTALL.contains("dwDefaultFrameInterval"),
@ -470,7 +472,17 @@ fn server_install_pins_hdmi_camera_and_display_defaults() {
< SERVER_INSTALL < SERVER_INSTALL
.rfind("restart_lesavka_uvc_helper_only") .rfind("restart_lesavka_uvc_helper_only")
.unwrap(), .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!( assert!(
SERVER_INSTALL SERVER_INSTALL

View File

@ -315,7 +315,7 @@ mod uvc_binary {
#[test] #[test]
#[serial] #[serial]
fn uvc_defaults_to_reliable_bulk_and_budgeted_mjpeg_frame_size() { fn uvc_missing_configfs_falls_back_to_isochronous_budget() {
with_vars( with_vars(
[ [
("LESAVKA_UVC_WIDTH", Some("1280")), ("LESAVKA_UVC_WIDTH", Some("1280")),
@ -331,10 +331,10 @@ mod uvc_binary {
|| { || {
let cfg = UvcConfig::from_env(); let cfg = UvcConfig::from_env();
assert_eq!(cfg.interval, 10_000_000 / 30); assert_eq!(cfg.interval, 10_000_000 / 30);
assert_eq!(cfg.frame_size, 300_000); assert_eq!(cfg.frame_size, 232_106);
assert_eq!(cfg.max_packet, 512); assert_eq!(cfg.max_packet, 1024);
assert!(cfg.bulk); assert!(!cfg.bulk);
assert!(uvc_bulk_transfer_enabled()); assert!(!uvc_bulk_transfer_enabled());
}, },
); );
@ -372,8 +372,8 @@ mod uvc_binary {
fn uvc_bulk_mode_is_tied_to_live_configfs_support() { fn uvc_bulk_mode_is_tied_to_live_configfs_support() {
let source = include_str!("../../../../server/src/bin/lesavka-uvc.real.inc"); let source = include_str!("../../../../server/src/bin/lesavka-uvc.real.inc");
assert!(source.contains("base.exists() && !base.join(\"streaming_bulk\").exists()")); assert!(source.contains("bulk_enabled_from_configfs"));
assert!(source.contains("using isochronous payload sizing")); assert!(source.contains("streaming_bulk is not 1; using isochronous payload sizing"));
} }
#[test] #[test]
@ -386,6 +386,12 @@ mod uvc_binary {
rejected_oversize: 1, rejected_oversize: 1,
rejected_invalid: 3, rejected_invalid: 3,
fallback_idle: 4, 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, latest_bytes: 77_036,
last_rejected_oversize_bytes: 300_001, last_rejected_oversize_bytes: 300_001,
last_rejected_oversize_cap: 300_000, last_rejected_oversize_cap: 300_000,
@ -402,6 +408,21 @@ mod uvc_binary {
assert!(json.contains("\"paced_sleeps\":5")); assert!(json.contains("\"paced_sleeps\":5"));
assert!(json.contains("\"paced_sleep_ms\":123")); 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 dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("uvc").join("stats.json"); let path = dir.path().join("uvc").join("stats.json");
write_atomic_text(&path, &json).expect("write stats"); write_atomic_text(&path, &json).expect("write stats");
@ -454,8 +475,10 @@ mod uvc_binary {
height: 480, height: 480,
default_interval: 333_333, default_interval: 333_333,
frame_interval: 333_333, frame_interval: 333_333,
frame_size: 150_000,
maxpacket: 1024, maxpacket: 1024,
maxburst: 0, maxburst: 0,
streaming_bulk: Some(0),
}); });
log_configfs_snapshot(&mut state, "contract"); log_configfs_snapshot(&mut state, "contract");
assert!(state.cfg_snapshot.is_some()); assert!(state.cfg_snapshot.is_some());

View File

@ -15,6 +15,10 @@ mod video_support {
pub use lesavka_server::video_support::*; pub use lesavka_server::video_support::*;
} }
mod uvc_contract {
pub use lesavka_server::uvc_contract::*;
}
#[path = "../../../../server/src/media_timing.rs"] #[path = "../../../../server/src/media_timing.rs"]
#[allow(warnings)] #[allow(warnings)]
mod media_timing; mod media_timing;

View File

@ -10,6 +10,10 @@ use std::fs;
use serial_test::serial; use serial_test::serial;
mod uvc_contract {
pub use lesavka_server::uvc_contract::*;
}
mod video_support { mod video_support {
pub fn env_u32(name: &str, default: u32) -> u32 { pub fn env_u32(name: &str, default: u32) -> u32 {
std::env::var(name) std::env::var(name)

View File

@ -58,6 +58,7 @@ fn synthetic_probe_keeps_bundled_network_ingress_and_rct_comparison_markers() {
"--server-uvc-audit-dir", "--server-uvc-audit-dir",
"--server-uvc-audit-sample-frames", "--server-uvc-audit-sample-frames",
"--stream-analyze", "--stream-analyze",
"--deep-capture",
"--sequence-window", "--sequence-window",
"--mix-mae-threshold", "--mix-mae-threshold",
"--mix-improvement", "--mix-improvement",
@ -138,6 +139,11 @@ fn synthetic_probe_keeps_bundled_network_ingress_and_rct_comparison_markers() {
"ffmpeg", "ffmpeg",
"v4l2", "v4l2",
"x11grab", "x11grab",
"capture.mjpg",
"usbmon.pcapng",
"uvcvideo-dynamic-debug",
"power/autosuspend",
"analyze_marked_capture.py",
] { ] {
assert!( assert!(
PROBE_SRC.contains(expected), PROBE_SRC.contains(expected),
@ -157,6 +163,8 @@ fn synthetic_injector_enters_the_public_bundled_media_rpc() {
"silence_pcm(args.frame_step_us())", "silence_pcm(args.frame_step_us())",
"synthetic_rgb_frame", "synthetic_rgb_frame",
"draw_sequence_marker", "draw_sequence_marker",
"synthetic_band_marker_luma",
"add_jpeg_app4_integrity",
"image/jpeg", "image/jpeg",
"jpegenc", "jpegenc",
"client_capture_pts_us: pts_us", "client_capture_pts_us: pts_us",

View File

@ -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"));
}