fix(uvc): enforce negotiated payload integrity
This commit is contained in:
parent
d5b1ecaae1
commit
75df8c94e1
6
Cargo.lock
generated
6
Cargo.lock
generated
@ -1658,7 +1658,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "lesavka_client"
|
||||
version = "0.27.6"
|
||||
version = "0.27.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@ -1692,7 +1692,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lesavka_common"
|
||||
version = "0.27.6"
|
||||
version = "0.27.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@ -1704,7 +1704,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lesavka_server"
|
||||
version = "0.27.6"
|
||||
version = "0.27.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
@ -485,6 +485,10 @@ path = "tests/manual/server/rct/rct_uvc_artifact_probe_manual_contract.rs"
|
||||
name = "synthetic_rct_uvc_probe_manual_contract"
|
||||
path = "tests/manual/server/rct/synthetic_rct_uvc_probe_manual_contract.rs"
|
||||
|
||||
[[test]]
|
||||
name = "uvc_mjpeg_integrity_manual_contract"
|
||||
path = "tests/manual/server/rct/uvc_mjpeg_integrity_manual_contract.rs"
|
||||
|
||||
[[test]]
|
||||
name = "google_meet_observer_manual_contract"
|
||||
path = "tests/manual/google_meet/google_meet_observer_manual_contract.rs"
|
||||
|
||||
@ -74,6 +74,9 @@ Useful entry points:
|
||||
|
||||
## Operational Notes
|
||||
|
||||
- The full topology, media paths, current incident evidence, and remaining
|
||||
completion gates are documented in
|
||||
[`docs/architecture-and-media-readiness.md`](docs/architecture-and-media-readiness.md).
|
||||
- Runtime and test environment variables are indexed in `docs/operational-env.md`.
|
||||
- Gate criteria and evidence paths are documented in `docs/quality-gate.md`.
|
||||
- Manual hardware checks belong in clearly marked manual scripts, not hidden in CI-only assumptions.
|
||||
|
||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
||||
|
||||
[package]
|
||||
name = "lesavka_client"
|
||||
version = "0.27.6"
|
||||
version = "0.27.7"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -185,5 +185,60 @@ fn print_upstream_sync(state: lesavka_common::lesavka::UpstreamSyncState) {
|
||||
"planner_sink_handoff_window_samples={}",
|
||||
state.sink_handoff_window_samples
|
||||
);
|
||||
println!("uvc_contract_status={}", state.uvc_contract_status);
|
||||
println!("uvc_transport={}", state.uvc_transport);
|
||||
println!(
|
||||
"uvc_enforced_frame_cap_bytes={}",
|
||||
state
|
||||
.uvc_enforced_frame_cap_bytes
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_else(|| "unavailable".to_string())
|
||||
);
|
||||
println!(
|
||||
"uvc_advertised_frame_bytes={}",
|
||||
state
|
||||
.uvc_advertised_frame_bytes
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_else(|| "unavailable".to_string())
|
||||
);
|
||||
println!(
|
||||
"uvc_committed_frame_bytes={}",
|
||||
state
|
||||
.uvc_committed_frame_bytes
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_else(|| "unavailable".to_string())
|
||||
);
|
||||
println!(
|
||||
"uvc_committed_payload_bytes={}",
|
||||
state
|
||||
.uvc_committed_payload_bytes
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_else(|| "unavailable".to_string())
|
||||
);
|
||||
println!("uvc_contract_divergent={}", state.uvc_contract_divergent);
|
||||
println!(
|
||||
"uvc_contract_missing_reads={}",
|
||||
state.uvc_contract_missing_reads
|
||||
);
|
||||
println!(
|
||||
"uvc_contract_divergent_reads={}",
|
||||
state.uvc_contract_divergent_reads
|
||||
);
|
||||
println!("uvc_contract_detail={}", state.uvc_contract_detail);
|
||||
println!("uvc_integrity_status={}", state.uvc_integrity_status);
|
||||
println!("uvc_integrity_detail={}", state.uvc_integrity_detail);
|
||||
println!("uvc_rejected_oversize={}", state.uvc_rejected_oversize);
|
||||
println!("uvc_rejected_invalid={}", state.uvc_rejected_invalid);
|
||||
println!("uvc_fallback_idle={}", state.uvc_fallback_idle);
|
||||
println!("uvc_held_last_good={}", state.uvc_held_last_good);
|
||||
println!("uvc_stale_replay={}", state.uvc_stale_replay);
|
||||
println!("uvc_read_errors={}", state.uvc_read_errors);
|
||||
println!(
|
||||
"uvc_strict_validation_failures={}",
|
||||
state.uvc_strict_validation_failures
|
||||
);
|
||||
println!("uvc_dqbuf_errors={}", state.uvc_dqbuf_errors);
|
||||
println!("uvc_qbuf_errors={}", state.uvc_qbuf_errors);
|
||||
println!("uvc_kernel_errors={}", state.uvc_kernel_errors);
|
||||
println!("planner_detail={}", state.last_reason);
|
||||
}
|
||||
|
||||
@ -321,6 +321,30 @@ fn print_upstream_sync_accepts_complete_and_pending_payloads() {
|
||||
microphone_sink_late_p95_ms: Some(18.5),
|
||||
client_timing_window_samples: 19,
|
||||
sink_handoff_window_samples: 20,
|
||||
uvc_contract_status: "ready".to_string(),
|
||||
uvc_transport: "isochronous".to_string(),
|
||||
uvc_enforced_frame_cap_bytes: Some(150_000),
|
||||
uvc_advertised_frame_bytes: Some(150_000),
|
||||
uvc_committed_frame_bytes: Some(150_000),
|
||||
uvc_committed_payload_bytes: Some(1024),
|
||||
uvc_contract_divergent: false,
|
||||
uvc_contract_detail: "live contract agrees".to_string(),
|
||||
uvc_contract_generated_unix_ms: Some(1_777_777_777_000),
|
||||
uvc_contract_missing_reads: 0,
|
||||
uvc_contract_divergent_reads: 0,
|
||||
uvc_integrity_status: "ready".to_string(),
|
||||
uvc_integrity_detail: "no helper or kernel faults".to_string(),
|
||||
uvc_rejected_oversize: 0,
|
||||
uvc_rejected_invalid: 0,
|
||||
uvc_fallback_idle: 0,
|
||||
uvc_held_last_good: 0,
|
||||
uvc_strict_validation_failures: 0,
|
||||
uvc_dqbuf_errors: 0,
|
||||
uvc_qbuf_errors: 0,
|
||||
uvc_kernel_errors: 0,
|
||||
uvc_integrity_generated_unix_ms: Some(1_777_777_777_000),
|
||||
uvc_stale_replay: 0,
|
||||
uvc_read_errors: 0,
|
||||
});
|
||||
|
||||
super::print_upstream_sync(UpstreamSyncState {
|
||||
|
||||
@ -85,6 +85,13 @@ pub struct UpstreamSyncStatus {
|
||||
pub freshness_reanchors: u64,
|
||||
pub startup_timeouts: u64,
|
||||
pub video_freezes: u64,
|
||||
pub uvc_contract_status: String,
|
||||
pub uvc_transport: String,
|
||||
pub uvc_enforced_frame_cap_bytes: Option<u32>,
|
||||
pub uvc_contract_divergent: bool,
|
||||
pub uvc_contract_detail: String,
|
||||
pub uvc_integrity_status: String,
|
||||
pub uvc_integrity_detail: String,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
@ -109,6 +116,13 @@ impl UpstreamSyncStatus {
|
||||
freshness_reanchors: reply.freshness_reanchors,
|
||||
startup_timeouts: reply.startup_timeouts,
|
||||
video_freezes: reply.video_freezes,
|
||||
uvc_contract_status: reply.uvc_contract_status,
|
||||
uvc_transport: reply.uvc_transport,
|
||||
uvc_enforced_frame_cap_bytes: reply.uvc_enforced_frame_cap_bytes,
|
||||
uvc_contract_divergent: reply.uvc_contract_divergent,
|
||||
uvc_contract_detail: reply.uvc_contract_detail,
|
||||
uvc_integrity_status: reply.uvc_integrity_status,
|
||||
uvc_integrity_detail: reply.uvc_integrity_detail,
|
||||
detail: reply.last_reason,
|
||||
}
|
||||
}
|
||||
@ -146,6 +160,13 @@ impl Default for UpstreamSyncStatus {
|
||||
freshness_reanchors: 0,
|
||||
startup_timeouts: 0,
|
||||
video_freezes: 0,
|
||||
uvc_contract_status: "unavailable".to_string(),
|
||||
uvc_transport: "unknown".to_string(),
|
||||
uvc_enforced_frame_cap_bytes: None,
|
||||
uvc_contract_divergent: true,
|
||||
uvc_contract_detail: "UVC payload contract unavailable".to_string(),
|
||||
uvc_integrity_status: "unavailable".to_string(),
|
||||
uvc_integrity_detail: "UVC helper integrity telemetry unavailable".to_string(),
|
||||
detail: "upstream sync planner unavailable".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -906,6 +906,50 @@ fn uvc_chip_degrades_when_live_camera_frames_are_not_flowing() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uvc_chip_uses_helper_integrity_and_payload_contract_evidence() {
|
||||
let mut state = LauncherState::new();
|
||||
state.set_server_available(true);
|
||||
state.set_server_media_caps(
|
||||
Some(true),
|
||||
Some(true),
|
||||
Some("uvc".to_string()),
|
||||
Some("mjpeg".to_string()),
|
||||
);
|
||||
state.upstream_sync.available = true;
|
||||
state.upstream_sync.uvc_contract_status = "ready".to_string();
|
||||
state.upstream_sync.uvc_contract_divergent = false;
|
||||
state.upstream_sync.uvc_integrity_status = "ready".to_string();
|
||||
assert_eq!(
|
||||
recovery_uvc_health(&state, false, None),
|
||||
(StatusLightState::Live, "MJPEG".to_string())
|
||||
);
|
||||
|
||||
state.upstream_sync.uvc_integrity_status = "holding".to_string();
|
||||
assert_eq!(
|
||||
recovery_uvc_health(&state, false, None),
|
||||
(StatusLightState::Caution, "Holding".to_string())
|
||||
);
|
||||
state.upstream_sync.uvc_integrity_status = "fault".to_string();
|
||||
assert_eq!(
|
||||
recovery_uvc_health(&state, false, None),
|
||||
(StatusLightState::Warning, "Integrity fault".to_string())
|
||||
);
|
||||
|
||||
state.upstream_sync.uvc_integrity_status = "ready".to_string();
|
||||
state.upstream_sync.uvc_contract_status = "unverified".to_string();
|
||||
assert_eq!(
|
||||
recovery_uvc_health(&state, false, None),
|
||||
(StatusLightState::Caution, "Unverified".to_string())
|
||||
);
|
||||
state.upstream_sync.uvc_contract_status = "divergent".to_string();
|
||||
state.upstream_sync.uvc_contract_divergent = true;
|
||||
assert_eq!(
|
||||
recovery_uvc_health(&state, false, None),
|
||||
(StatusLightState::Warning, "Contract fault".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_power_detail_mentions_detected_eyes_when_powered() {
|
||||
let power = CapturePowerStatus {
|
||||
|
||||
@ -216,6 +216,21 @@ fn recovery_uvc_health(
|
||||
.unwrap_or(codec);
|
||||
return (StatusLightState::Caution, value);
|
||||
}
|
||||
if state.upstream_sync.available {
|
||||
match state.upstream_sync.uvc_integrity_status.as_str() {
|
||||
"ready" => {}
|
||||
"holding" => return (StatusLightState::Caution, "Holding".to_string()),
|
||||
"fault" => return (StatusLightState::Warning, "Integrity fault".to_string()),
|
||||
_ => return (StatusLightState::Caution, "Unverified".to_string()),
|
||||
}
|
||||
match state.upstream_sync.uvc_contract_status.as_str() {
|
||||
"ready" if !state.upstream_sync.uvc_contract_divergent => {}
|
||||
"" | "unavailable" | "unverified" => {
|
||||
return (StatusLightState::Caution, "Unverified".to_string());
|
||||
}
|
||||
_ => return (StatusLightState::Warning, "Contract fault".to_string()),
|
||||
}
|
||||
}
|
||||
if !relay_live {
|
||||
return (StatusLightState::Live, codec);
|
||||
}
|
||||
|
||||
@ -139,8 +139,18 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
|
||||
set_status_light(&widgets.summary.uvc_light, uvc_state);
|
||||
widgets.summary.uvc_value.set_text(&uvc_value);
|
||||
widgets.summary.uvc_value.set_tooltip_text(Some(&format!(
|
||||
"Upstream webcam transport: {}. Server calibration is profile-specific.",
|
||||
state.effective_webcam_transport().label()
|
||||
"Upstream webcam transport: {}. UVC payload contract: {} via {}, cap {} bytes. {} Integrity: {}. {}",
|
||||
state.effective_webcam_transport().label(),
|
||||
state.upstream_sync.uvc_contract_status,
|
||||
state.upstream_sync.uvc_transport,
|
||||
state
|
||||
.upstream_sync
|
||||
.uvc_enforced_frame_cap_bytes
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_else(|| "unavailable".to_string()),
|
||||
state.upstream_sync.uvc_contract_detail,
|
||||
state.upstream_sync.uvc_integrity_status,
|
||||
state.upstream_sync.uvc_integrity_detail,
|
||||
)));
|
||||
let (lag_state, lag_value, lag_tooltip) =
|
||||
upstream_lag_health(&state.upstream_sync, relay_live);
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lesavka_common"
|
||||
version = "0.27.6"
|
||||
version = "0.27.7"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@ -195,6 +195,30 @@ message UpstreamSyncState {
|
||||
optional float microphone_sink_late_p95_ms = 33;
|
||||
uint64 client_timing_window_samples = 34;
|
||||
uint64 sink_handoff_window_samples = 35;
|
||||
string uvc_contract_status = 36;
|
||||
string uvc_transport = 37;
|
||||
optional uint32 uvc_enforced_frame_cap_bytes = 38;
|
||||
optional uint32 uvc_advertised_frame_bytes = 39;
|
||||
optional uint32 uvc_committed_frame_bytes = 40;
|
||||
optional uint32 uvc_committed_payload_bytes = 41;
|
||||
bool uvc_contract_divergent = 42;
|
||||
string uvc_contract_detail = 43;
|
||||
optional uint64 uvc_contract_generated_unix_ms = 44;
|
||||
uint64 uvc_contract_missing_reads = 45;
|
||||
uint64 uvc_contract_divergent_reads = 46;
|
||||
string uvc_integrity_status = 47;
|
||||
string uvc_integrity_detail = 48;
|
||||
uint64 uvc_rejected_oversize = 49;
|
||||
uint64 uvc_rejected_invalid = 50;
|
||||
uint64 uvc_fallback_idle = 51;
|
||||
uint64 uvc_held_last_good = 52;
|
||||
uint64 uvc_strict_validation_failures = 53;
|
||||
uint64 uvc_dqbuf_errors = 54;
|
||||
uint64 uvc_qbuf_errors = 55;
|
||||
uint64 uvc_kernel_errors = 56;
|
||||
optional uint64 uvc_integrity_generated_unix_ms = 57;
|
||||
uint64 uvc_stale_replay = 58;
|
||||
uint64 uvc_read_errors = 59;
|
||||
}
|
||||
|
||||
message HandshakeSet {
|
||||
|
||||
445
docs/architecture-and-media-readiness.md
Normal file
445
docs/architecture-and-media-readiness.md
Normal 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.
|
||||
@ -340,6 +340,7 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta
|
||||
| `LESAVKA_UVC_EXTERNAL` | server hardware/device override |
|
||||
| `LESAVKA_UVC_FALLBACK` | server hardware/device override |
|
||||
| `LESAVKA_UVC_FPS` | server hardware/device override |
|
||||
| `LESAVKA_UVC_CONTRACT_PATH` | Atomic helper-published UVC payload contract consumed by the server spool and diagnostics; defaults to `/run/lesavka-uvc-contract.json` |
|
||||
| `LESAVKA_UVC_FRAME_AUDIT_CONTROL_PATH` | UVC helper boundary-audit runtime control path; defaults to `/tmp/lesavka-uvc-frame-audit.control`, where a path value enables exact-frame audit capture and `off`/`0` disables it without restarting the server |
|
||||
| `LESAVKA_UVC_FRAME_AUDIT_DIR` | UVC helper boundary-audit directory; when set, the server saves exact MJPEG frames published to the UVC helper plus a JSONL index so recordings can be compared against pre-UVC payloads |
|
||||
| `LESAVKA_UVC_FRAME_AUDIT_EVERY` | UVC helper boundary-audit sampling interval; saves every Nth spooled frame, defaults to `1` for short repros |
|
||||
@ -350,13 +351,17 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta
|
||||
| `LESAVKA_UVC_FRAME_META_LOG_PATH` | UVC helper diagnostic override; when set with `LESAVKA_UVC_FRAME_META=1`, append every MJPEG spool timing record as JSONL for full-probe HEVC/RCT correlation; summarize with `scripts/manual/summarize_uvc_frame_meta_log.py` |
|
||||
| `LESAVKA_UVC_FRAME_META_PATH` | UVC helper diagnostic override; explicit path for the optional MJPEG spool metadata sidecar |
|
||||
| `LESAVKA_UVC_FRAME_MAX_AGE_MS` | UVC helper freshness override; stale spooled MJPEG frames older than this are not replayed, defaults to `1000`; `0` disables TTL |
|
||||
| `LESAVKA_UVC_FRAME_MAX_BYTES` | UVC helper MJPEG frame-size guard; explicit maximum accepted frame bytes. Unset or `0` uses the live-call byte budget so oversized frames freeze instead of tearing on the host |
|
||||
| `LESAVKA_UVC_FRAME_MAX_BYTES` | Requested UVC MJPEG frame cap. The effective value is always clamped to the live descriptor, browser commit, and transport budget; unset or `0` uses that physical contract directly |
|
||||
| `LESAVKA_UVC_FRAME_SIZE` | UVC advertised maximum MJPEG frame bytes; defaults to the live-call byte budget instead of raw uncompressed frame size so the gadget/host do not schedule oversized phantom frames |
|
||||
| `LESAVKA_UVC_FRAME_SIZE_GUARD` | UVC helper MJPEG frame-size guard toggle; defaults to `1`; set `0` only for diagnostics when oversized MJPEG frames must be allowed through |
|
||||
| `LESAVKA_UVC_FRAME_SIZE_GUARD` | Legacy diagnostic compatibility knob. It no longer permits frames to exceed the live physical UVC payload contract |
|
||||
| `LESAVKA_UVC_HANDOFF_AUDIT` | Enables the bounded helper-to-gadget JSONL handoff ring when set to `1`; defaults off |
|
||||
| `LESAVKA_UVC_HANDOFF_AUDIT_MAX_RECORDS` | Maximum retained helper handoff records; defaults to `1024` and is capped at `16384` |
|
||||
| `LESAVKA_UVC_HANDOFF_AUDIT_PATH` | Helper handoff audit path; defaults to `/run/lesavka-uvc-handoff.jsonl` |
|
||||
| `LESAVKA_UVC_HEIGHT` | server hardware/device override |
|
||||
| `LESAVKA_UVC_HEVC_SPOOL_PULL_TIMEOUT_MS` | server HEVC decode-to-MJPEG freshness override; appsink pull wait for decoded MJPEG handoff before publishing newest frame to the UVC helper, defaults to `20` and is capped at `50` |
|
||||
| `LESAVKA_UVC_HEVC_FRESHNESS_QUEUE_BUFFERS` | server HEVC decode-to-MJPEG branch queue depth; defaults to `2` and is capped at `4` so decode/JPEG scheduling jitter does not starve the UVC helper while stale frames still get dropped |
|
||||
| `LESAVKA_UVC_IDLE_PUMP_MS` | UVC helper freshness override; idle poll sleep while pumping host-returned buffers, defaults to `2` |
|
||||
| `LESAVKA_UVC_IDLE_AFTER_MS` | Sustained failure/staleness window before the helper replaces its last verified good frame with idle black; defaults to `2000` |
|
||||
| `LESAVKA_UVC_INTERVAL` | server hardware/device override |
|
||||
| `LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT` | UVC MJPEG isochronous safety cap; defaults to `85`, limiting advertised/runtime frame bytes to a safe fraction of high-speed isochronous payload capacity when bulk UVC is unavailable |
|
||||
| `LESAVKA_UVC_LIMIT_PCT` | server hardware/device override |
|
||||
@ -378,11 +383,13 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta
|
||||
| `LESAVKA_UVC_MJPEG_PIXEL_GUARD_SAMPLE_WIDTH` | server MJPEG pixel-artifact guard sampling width; defaults to `160` pixels and is clamped to `64..320` |
|
||||
| `LESAVKA_UVC_MJPEG_PIXEL_GUARD_SEAM_COVERAGE_PCT` | server MJPEG pixel-artifact guard threshold; minimum sampled-row coverage for hard horizontal tile seams, default `42` |
|
||||
| `LESAVKA_UVC_MJPEG_PIXEL_GUARD_SEAM_DELTA` | server MJPEG pixel-artifact guard threshold; sampled luma jump that marks a row as suspicious, default `34` |
|
||||
| `LESAVKA_UVC_KERNEL_STATS_PATH` | Kernel UVC/UDC watcher JSON path embedded in helper stats; defaults to `/run/lesavka-uvc-kernel-stats.json` |
|
||||
| `LESAVKA_UVC_QUEUE_PACING` | UVC helper queue pacing override; defaults to `0` because the RCT host already paces UVC consumption, and delaying returned buffer requeueing can starve isochronous gadget transfers |
|
||||
| `LESAVKA_UVC_RESTART_DELAY_MS` | UVC control helper supervisor restart delay after helper exit or failure; defaults to `1000` |
|
||||
| `LESAVKA_UVC_SKIP_UDEV` | server hardware/device override |
|
||||
| `LESAVKA_UVC_STATS_INTERVAL_MS` | UVC helper telemetry interval for queued/reloaded/rejected MJPEG frame counters; defaults to `5000`, `0` disables |
|
||||
| `LESAVKA_UVC_STATS_PATH` | UVC helper JSON stats snapshot path for queued/reloaded/rejected MJPEG frame counters; defaults to `/run/lesavka-uvc-video-stats.json`, set `0` or empty to disable file snapshots |
|
||||
| `LESAVKA_UVC_STRICT_VALIDATE_EVERY` | Sample interval for complete JPEG structure and Lesavka APP4 CRC validation before QBUF; defaults to every `30` accepted frames, `0` disables sampling |
|
||||
| `LESAVKA_UVC_STREAMING_INTERVAL` | server hardware/device override |
|
||||
| `LESAVKA_UVC_STREAM_INTF` | server hardware/device override |
|
||||
| `LESAVKA_UVC_WIDTH` | server hardware/device override |
|
||||
|
||||
@ -319,7 +319,7 @@ MAX_SPEED=${LESAVKA_MAX_SPEED:-high-speed}
|
||||
if [[ -z $UVC_INTERVAL ]]; then
|
||||
UVC_INTERVAL=$((10000000 / UVC_FPS))
|
||||
fi
|
||||
UVC_FRAME_SIZE=${LESAVKA_UVC_FRAME_SIZE:-$((UVC_WIDTH * UVC_HEIGHT * 2))}
|
||||
UVC_FRAME_SIZE=
|
||||
UVC_INTERVAL_30=${LESAVKA_UVC_INTERVAL_30:-333333}
|
||||
UVC_INTERVAL_20=${LESAVKA_UVC_INTERVAL_20:-500000}
|
||||
UVC_MJPEG_BUDGET_BYTES_PER_SEC=${LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC:-4500000}
|
||||
@ -350,8 +350,8 @@ uvc_mjpeg_frame_size_for_fps() {
|
||||
fi
|
||||
fi
|
||||
local per_frame=$((budget / fps))
|
||||
if ((per_frame < 65536)); then
|
||||
per_frame=65536
|
||||
if ((per_frame < 1)); then
|
||||
per_frame=1
|
||||
elif ((per_frame > 8388608)); then
|
||||
per_frame=8388608
|
||||
fi
|
||||
@ -367,8 +367,15 @@ uvc_fps_for_interval() {
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ -z ${LESAVKA_UVC_FRAME_SIZE:-} ]]; then
|
||||
UVC_FRAME_SIZE="$(uvc_mjpeg_frame_size_for_fps "$UVC_FPS")"
|
||||
UVC_DERIVED_FRAME_SIZE="$(uvc_mjpeg_frame_size_for_fps "$UVC_FPS")"
|
||||
if [[ -n ${LESAVKA_UVC_FRAME_SIZE:-} ]]; then
|
||||
UVC_FRAME_SIZE=$LESAVKA_UVC_FRAME_SIZE
|
||||
if ((UVC_FRAME_SIZE > UVC_DERIVED_FRAME_SIZE)); then
|
||||
log "clamping requested UVC frame size $UVC_FRAME_SIZE -> $UVC_DERIVED_FRAME_SIZE (wire budget)"
|
||||
UVC_FRAME_SIZE=$UVC_DERIVED_FRAME_SIZE
|
||||
fi
|
||||
else
|
||||
UVC_FRAME_SIZE=$UVC_DERIVED_FRAME_SIZE
|
||||
fi
|
||||
|
||||
uvc_selected_frame_index() {
|
||||
@ -400,10 +407,16 @@ write_active_mjpeg_frame_descriptor() {
|
||||
|
||||
uvc_frame_size_for() {
|
||||
local width=$1 height=$2
|
||||
local derived
|
||||
derived="$(uvc_mjpeg_frame_size_for_fps "$(uvc_fps_for_interval "$(uvc_default_interval_for "$width" "$height")")")"
|
||||
if [[ $width == "$UVC_WIDTH" && $height == "$UVC_HEIGHT" && -n ${LESAVKA_UVC_FRAME_SIZE:-} ]]; then
|
||||
echo "$LESAVKA_UVC_FRAME_SIZE"
|
||||
if ((LESAVKA_UVC_FRAME_SIZE < derived)); then
|
||||
echo "$LESAVKA_UVC_FRAME_SIZE"
|
||||
else
|
||||
echo "$derived"
|
||||
fi
|
||||
else
|
||||
uvc_mjpeg_frame_size_for_fps "$(uvc_fps_for_interval "$(uvc_default_interval_for "$width" "$height")")"
|
||||
echo "$derived"
|
||||
fi
|
||||
}
|
||||
|
||||
@ -640,7 +653,13 @@ if [[ -z $DISABLE_UVC ]]; then
|
||||
F="$G/functions/uvc.usb0"
|
||||
if [[ -n $UVC_BULK_REQUESTED ]]; then
|
||||
if [[ -e "$F/streaming_bulk" ]]; then
|
||||
UVC_BULK=1
|
||||
if echo 1 >"$F/streaming_bulk" 2>/dev/null && [[ $(cat "$F/streaming_bulk" 2>/dev/null) == 1 ]]; then
|
||||
UVC_BULK=1
|
||||
else
|
||||
log "UVC bulk request did not stick; using isochronous descriptors"
|
||||
UVC_BULK=
|
||||
UVC_MAXPACKET=${LESAVKA_UVC_MAXPACKET:-1024}
|
||||
fi
|
||||
else
|
||||
# Some kernels do not expose the patched bulk-transfer knob. Falling
|
||||
# back to isochronous must also avoid the 512-byte bulk packet clamp;
|
||||
@ -649,13 +668,22 @@ if [[ -z $DISABLE_UVC ]]; then
|
||||
UVC_BULK=
|
||||
UVC_MAXPACKET=${LESAVKA_UVC_MAXPACKET:-1024}
|
||||
fi
|
||||
elif [[ -e "$F/streaming_bulk" ]]; then
|
||||
if ! echo 0 >"$F/streaming_bulk" 2>/dev/null || [[ $(cat "$F/streaming_bulk" 2>/dev/null) != 0 ]]; then
|
||||
log "UVC streaming_bulk could not be disabled; refusing to infer transport from attribute existence"
|
||||
UVC_BULK=
|
||||
fi
|
||||
fi
|
||||
apply_uvc_payload_limits
|
||||
echo "$UVC_STREAMING_INTERVAL" >"$F/streaming_interval"
|
||||
echo "$UVC_MAXPACKET" >"$F/streaming_maxpacket"
|
||||
echo "$UVC_MAXBURST" >"$F/streaming_maxburst"
|
||||
if [[ -n $UVC_BULK ]]; then
|
||||
echo 1 >"$F/streaming_bulk" 2>/dev/null || true
|
||||
if [[ -n $UVC_BULK && $(cat "$F/streaming_bulk" 2>/dev/null) != 1 ]]; then
|
||||
log "UVC bulk readback diverged after descriptor setup; falling back to isochronous sizing"
|
||||
UVC_BULK=
|
||||
UVC_MAXPACKET=${LESAVKA_UVC_MAXPACKET:-1024}
|
||||
apply_uvc_payload_limits
|
||||
echo "$UVC_MAXPACKET" >"$F/streaming_maxpacket"
|
||||
fi
|
||||
|
||||
# ── 1. FORMAT DESCRIPTOR ──────────────────────────────────────────
|
||||
|
||||
162
scripts/daemon/lesavka-uvc-kernel-watch.py
Executable file
162
scripts/daemon/lesavka-uvc-kernel-watch.py
Executable 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())
|
||||
@ -428,6 +428,12 @@ LESAVKA_UVC_FRAME_MAX_BYTES=$(uvc_env_value LESAVKA_UVC_FRAME_MAX_BYTES 0)
|
||||
LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC=${LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC:-4500000}
|
||||
LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT=$(uvc_env_value LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT 85)
|
||||
LESAVKA_UVC_STATS_PATH=$(uvc_env_value LESAVKA_UVC_STATS_PATH /run/lesavka-uvc-video-stats.json)
|
||||
LESAVKA_UVC_CONTRACT_PATH=$(uvc_env_value LESAVKA_UVC_CONTRACT_PATH /run/lesavka-uvc-contract.json)
|
||||
LESAVKA_UVC_IDLE_AFTER_MS=$(uvc_env_value LESAVKA_UVC_IDLE_AFTER_MS 2000)
|
||||
LESAVKA_UVC_STRICT_VALIDATE_EVERY=$(uvc_env_value LESAVKA_UVC_STRICT_VALIDATE_EVERY 30)
|
||||
LESAVKA_UVC_HANDOFF_AUDIT=$(uvc_env_value LESAVKA_UVC_HANDOFF_AUDIT 0)
|
||||
LESAVKA_UVC_HANDOFF_AUDIT_PATH=$(uvc_env_value LESAVKA_UVC_HANDOFF_AUDIT_PATH /run/lesavka-uvc-handoff.jsonl)
|
||||
LESAVKA_UVC_HANDOFF_AUDIT_MAX_RECORDS=$(uvc_env_value LESAVKA_UVC_HANDOFF_AUDIT_MAX_RECORDS 1024)
|
||||
EOF
|
||||
}
|
||||
|
||||
@ -1040,6 +1046,26 @@ WantedBy=multi-user.target
|
||||
UNIT
|
||||
}
|
||||
|
||||
install_uvc_kernel_watch_unit() {
|
||||
cat <<'UNIT' | sudo tee /etc/systemd/system/lesavka-uvc-kernel-watch.service >/dev/null
|
||||
[Unit]
|
||||
Description=lesavka UVC/UDC kernel error watcher
|
||||
After=systemd-journald.service
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/local/lib/lesavka/lesavka-uvc-kernel-watch.py
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
User=root
|
||||
Environment=LESAVKA_UVC_KERNEL_STATS_PATH=/run/lesavka-uvc-kernel-stats.json
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now lesavka-uvc-kernel-watch
|
||||
}
|
||||
|
||||
restart_lesavka_server_only() {
|
||||
sudo truncate -s 0 /var/log/lesavka/server.stderr
|
||||
sudo systemctl stop lesavka-server >/dev/null 2>&1 || true
|
||||
@ -1599,6 +1625,8 @@ install_verified_executable "$SRC_DIR/target/release/lesavka-uvc" /usr/local/bin
|
||||
install_verified_executable "$SRC_DIR/target/release/lesavka-synthetic-uplink" /usr/local/bin/lesavka-synthetic-uplink "lesavka-synthetic-uplink"
|
||||
install_verified_executable "$SRC_DIR/scripts/daemon/lesavka-core.sh" /usr/local/bin/lesavka-core.sh "lesavka-core.sh"
|
||||
install_verified_executable "$SRC_DIR/scripts/daemon/lesavka-uvc.sh" /usr/local/bin/lesavka-uvc.sh "lesavka-uvc.sh"
|
||||
install_verified_executable "$SRC_DIR/scripts/daemon/lesavka-uvc-kernel-watch.py" /usr/local/lib/lesavka/lesavka-uvc-kernel-watch.py "lesavka-uvc-kernel-watch.py"
|
||||
install_uvc_kernel_watch_unit
|
||||
install_verified_executable "$SRC_DIR/scripts/daemon/lesavka-recovery-ladder.sh" /usr/local/bin/lesavka-recovery-ladder "lesavka-recovery-ladder"
|
||||
install_verified_executable "$SRC_DIR/scripts/manual/run_uac_output_sanity.sh" /usr/local/bin/lesavka-uac-sanity "lesavka-uac-sanity"
|
||||
install_recovery_ladder_units
|
||||
@ -1820,12 +1848,18 @@ if [[ "$ATTACHED_UVC_RESTART_DEFERRED" == "1" ]]; then
|
||||
fi
|
||||
|
||||
echo "✅ No active gRPC clients detected; updated UVC env matches the live descriptor, restarting lesavka-server only." >&2
|
||||
echo " Preserving lesavka-core and lesavka-uvc so the attached USB gadget is not cycled." >&2
|
||||
echo " Preserving lesavka-core so the attached USB gadget is not cycled." >&2
|
||||
sudo install -d -m 0755 /var/log/lesavka
|
||||
sudo truncate -s 0 /var/log/lesavka/server.log
|
||||
install_lesavka_server_unit_file
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable lesavka-server >/dev/null 2>&1 || true
|
||||
if [[ "${LESAVKA_INSTALL_RESTART_UVC_HELPER:-0}" == "1" ]]; then
|
||||
restart_lesavka_uvc_helper_only
|
||||
echo "✅ lesavka-uvc helper restarted in-place by explicit request; lesavka-core and the attached USB gadget were preserved."
|
||||
else
|
||||
echo " Preserving lesavka-uvc; set LESAVKA_INSTALL_RESTART_UVC_HELPER=1 when a helper-only refresh is required." >&2
|
||||
fi
|
||||
restart_lesavka_server_only
|
||||
sudo /usr/local/bin/lesavka-recovery-ladder snapshot || true
|
||||
INSTALLED_VERSION=$(manifest_package_version "$SRC_DIR/server/Cargo.toml" 2>/dev/null || true)
|
||||
|
||||
8
scripts/manual/analyze_marked_capture.py
Executable file
8
scripts/manual/analyze_marked_capture.py
Executable 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())
|
||||
@ -90,13 +90,11 @@ def inspect_jpeg(data: bytes) -> dict[str, object]:
|
||||
}
|
||||
|
||||
|
||||
def derived_cap(fps: int, budget: int, explicit: Optional[int], guard: bool) -> int:
|
||||
if not guard:
|
||||
return MAX_MJPEG_FRAME_BYTES
|
||||
def derived_cap(fps: int, budget: int, explicit: Optional[int], _guard: bool) -> int:
|
||||
physical_cap = min(max(1, budget // max(fps, 1)), MAX_MJPEG_FRAME_BYTES)
|
||||
if explicit and explicit > 0:
|
||||
return min(explicit, MAX_MJPEG_FRAME_BYTES)
|
||||
per_frame = max(budget // max(fps, 1), 64 * 1024)
|
||||
return min(per_frame, MAX_MJPEG_FRAME_BYTES)
|
||||
return min(explicit, physical_cap)
|
||||
return physical_cap
|
||||
|
||||
|
||||
def main() -> int:
|
||||
|
||||
402
scripts/manual/analyze_uvc_mjpeg_integrity.py
Executable file
402
scripts/manual/analyze_uvc_mjpeg_integrity.py
Executable 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())
|
||||
@ -25,6 +25,9 @@ DEFAULT_MEDIA_CONTROL_PATH = "/tmp/lesavka-media.control"
|
||||
DEFAULT_SERVER_UVC_AUDIT_CONTROL_PATH = "/tmp/lesavka-uvc-frame-audit.control"
|
||||
MARKER_BITS = 32
|
||||
MARKER_COLUMNS = 16
|
||||
BAND_MARKER_ROWS = 64
|
||||
BAND_MARKER_BITS = 32
|
||||
BAND_MARKER_CELL = 4
|
||||
CADENCE_REASONS = {"frame_repeat", "frame_gap", "frame_backwards"}
|
||||
NON_VISUAL_REASONS = CADENCE_REASONS | {"sequence_marker_mismatch"}
|
||||
|
||||
@ -267,6 +270,11 @@ def parse_args() -> argparse.Namespace:
|
||||
action="store_true",
|
||||
help="debug path: analyze ffmpeg stdout directly instead of spooling raw frames first",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--deep-capture",
|
||||
action="store_true",
|
||||
help="preserve native MJPEG plus UVC/kernel/USB power evidence; UVC source only",
|
||||
)
|
||||
parser.add_argument("--capture-only", action="store_true", help=argparse.SUPPRESS)
|
||||
parser.add_argument("--self-test", action="store_true")
|
||||
return parser.parse_args()
|
||||
@ -305,7 +313,7 @@ def default_inject_max_frame_bytes(fps: int) -> int:
|
||||
* DEFAULT_ISOCHRONOUS_LIMIT_PCT
|
||||
// 100
|
||||
)
|
||||
return max(64 * 1024, bytes_per_second // max(1, fps))
|
||||
return max(1, bytes_per_second // max(1, fps))
|
||||
|
||||
|
||||
def default_artifact_dir(mode: str) -> pathlib.Path:
|
||||
@ -791,6 +799,8 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int:
|
||||
]
|
||||
if args.stream_analyze:
|
||||
capture_cmd.append("--stream-analyze")
|
||||
if args.deep_capture:
|
||||
capture_cmd.append("--deep-capture")
|
||||
inject_cmd = [
|
||||
args.inject_binary,
|
||||
"--server",
|
||||
@ -929,6 +939,20 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int:
|
||||
local_server_audit = artifact_dir / "server-uvc-audit"
|
||||
if capture is not None:
|
||||
subprocess.run(["scp", "-r", f"{args.rct_host}:{remote_rct_dir}", str(local_capture)], check=False)
|
||||
if args.deep_capture:
|
||||
integrity_script = pathlib.Path(__file__).with_name("analyze_marked_capture.py")
|
||||
native_capture = local_capture / "capture.mjpg"
|
||||
if integrity_script.exists() and native_capture.exists():
|
||||
subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(integrity_script),
|
||||
str(native_capture),
|
||||
"--output-dir",
|
||||
str(local_capture / "mjpeg-integrity"),
|
||||
],
|
||||
check=False,
|
||||
)
|
||||
if args.local_inject:
|
||||
if pathlib.Path(remote_inject_dir).exists():
|
||||
if local_inject.exists():
|
||||
@ -1175,9 +1199,28 @@ def synthetic_luma(width: int, height: int, sequence: int, x: int, y: int) -> in
|
||||
marker = synthetic_marker_luma(width, height, sequence, x, y)
|
||||
if marker is not None:
|
||||
return marker
|
||||
band_marker = synthetic_band_marker_luma(width, height, sequence, x, y)
|
||||
if band_marker is not None:
|
||||
return band_marker
|
||||
return synthetic_base_luma(width, height, sequence, x, y)
|
||||
|
||||
|
||||
def synthetic_band_marker_luma(width: int, height: int, sequence: int, x: int, y: int) -> int | None:
|
||||
marker_width = BAND_MARKER_BITS * BAND_MARKER_CELL
|
||||
if width < marker_width + 8 or height < BAND_MARKER_ROWS:
|
||||
return None
|
||||
band = y // BAND_MARKER_ROWS
|
||||
band_y = band * BAND_MARKER_ROWS + 4
|
||||
if not band_y <= y < band_y + BAND_MARKER_CELL * 2:
|
||||
return None
|
||||
x0 = width - marker_width - 4
|
||||
if not x0 <= x < x0 + marker_width:
|
||||
return None
|
||||
bit = (x - x0) // BAND_MARKER_CELL
|
||||
marker = (sequence & 0x00FFFFFF) | ((band & 0xFF) << 24)
|
||||
return 245 if marker & (1 << bit) else 12
|
||||
|
||||
|
||||
def synthetic_gray(width: int, height: int, sequence: int) -> bytes:
|
||||
data = bytearray(width * height)
|
||||
for y in range(height):
|
||||
@ -1524,11 +1567,119 @@ def write_pgm(path: pathlib.Path, frame: bytes, width: int, height: int) -> None
|
||||
path.write_bytes(f"P5\n{width} {height}\n255\n".encode() + frame)
|
||||
|
||||
|
||||
def command_text(command: list[str]) -> str:
|
||||
try:
|
||||
return subprocess.run(command, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False).stdout
|
||||
except (FileNotFoundError, PermissionError, OSError) as error:
|
||||
return f"unavailable: {error}\n"
|
||||
|
||||
|
||||
def write_deep_capture_snapshot(artifact_dir: pathlib.Path, label: str, device: str) -> None:
|
||||
snapshot = artifact_dir / "deep-capture" / label
|
||||
snapshot.mkdir(parents=True, exist_ok=True)
|
||||
commands = {
|
||||
"uname.txt": ["uname", "-a"],
|
||||
"v4l2-all.txt": ["v4l2-ctl", "--device", device, "--all"],
|
||||
"v4l2-formats.txt": ["v4l2-ctl", "--device", device, "--list-formats-ext"],
|
||||
"udev.txt": ["udevadm", "info", "--query=all", "--name", device],
|
||||
"uvcvideo-parameters.txt": ["bash", "-lc", "for f in /sys/module/uvcvideo/parameters/*; do printf '%s=' \"$f\"; cat \"$f\" 2>&1; done"],
|
||||
"usb-power.txt": [
|
||||
"bash",
|
||||
"-lc",
|
||||
(
|
||||
f"p=$(udevadm info -q path -n {shlex.quote(device)} 2>/dev/null); "
|
||||
"d=/sys$p; while [[ $d != /sys && ! -e $d/idVendor ]]; do d=${d%/*}; done; "
|
||||
"echo sysfs=$d; for f in power/control power/autosuspend power/autosuspend_delay_ms "
|
||||
"power/runtime_status busnum devnum idVendor idProduct product serial; do "
|
||||
"printf '%s=' \"$f\"; cat \"$d/$f\" 2>&1; done"
|
||||
),
|
||||
],
|
||||
"kernel.txt": ["journalctl", "-k", "--no-pager", "-n", "1200"],
|
||||
}
|
||||
for name, command in commands.items():
|
||||
(snapshot / name).write_text(command_text(command))
|
||||
|
||||
|
||||
def usbmon_interface(device: str) -> str:
|
||||
try:
|
||||
sysfs = pathlib.Path("/sys" + subprocess.check_output(["udevadm", "info", "-q", "path", "-n", device], text=True).strip())
|
||||
for parent in [sysfs, *sysfs.parents]:
|
||||
busnum = parent / "busnum"
|
||||
if busnum.exists():
|
||||
return f"usbmon{int(busnum.read_text().strip())}"
|
||||
except (FileNotFoundError, OSError, ValueError, subprocess.CalledProcessError):
|
||||
pass
|
||||
return "usbmon0"
|
||||
|
||||
|
||||
def set_uvc_dynamic_debug(artifact_dir: pathlib.Path, enabled: bool) -> bool:
|
||||
action = "+p" if enabled else "-p"
|
||||
command = [
|
||||
"bash",
|
||||
"-lc",
|
||||
(
|
||||
"c=/sys/kernel/debug/dynamic_debug/control; "
|
||||
f"line='module uvcvideo {action}'; "
|
||||
"if [[ -w $c ]]; then echo \"$line\" >\"$c\"; "
|
||||
"elif sudo -n test -w \"$c\" 2>/dev/null; then echo \"$line\" | sudo -n tee \"$c\" >/dev/null; "
|
||||
"else echo 'dynamic debug control unavailable' >&2; exit 1; fi"
|
||||
),
|
||||
]
|
||||
result = subprocess.run(command, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False)
|
||||
(artifact_dir / f"uvcvideo-dynamic-debug-{'enable' if enabled else 'disable'}.txt").write_text(
|
||||
f"rc={result.returncode}\n{result.stdout}"
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def start_usbmon_capture(artifact_dir: pathlib.Path, device: str) -> subprocess.Popen[Any] | None:
|
||||
if not shutil.which("tshark"):
|
||||
(artifact_dir / "usbmon-status.txt").write_text("tshark unavailable\n")
|
||||
return None
|
||||
interface = usbmon_interface(device)
|
||||
output = artifact_dir / "usbmon.pcapng"
|
||||
stderr = (artifact_dir / "usbmon.stderr").open("wb")
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
["tshark", "-i", interface, "-w", str(output)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=stderr,
|
||||
)
|
||||
except (OSError, PermissionError) as error:
|
||||
stderr.close()
|
||||
(artifact_dir / "usbmon-status.txt").write_text(f"usbmon unavailable: {error}\n")
|
||||
return None
|
||||
setattr(process, "_lesavka_stderr", stderr)
|
||||
return process
|
||||
|
||||
|
||||
def stop_usbmon_capture(process: subprocess.Popen[Any] | None) -> None:
|
||||
if process is None:
|
||||
return
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
stderr = getattr(process, "_lesavka_stderr", None)
|
||||
if stderr is not None:
|
||||
stderr.close()
|
||||
|
||||
|
||||
def run_capture(args: argparse.Namespace) -> int:
|
||||
width, height, fps = mode_dimensions(args)
|
||||
command, capture_width, capture_height, device = ffmpeg_cmd(args, width, height)
|
||||
artifact_dir = pathlib.Path(args.artifact_dir) if args.artifact_dir else pathlib.Path("/tmp") / f"lesavka-synthetic-rct-capture-{timestamp()}"
|
||||
artifact_dir.mkdir(parents=True, exist_ok=True)
|
||||
if args.deep_capture and args.source != "uvc":
|
||||
raise SystemExit("--deep-capture requires --source uvc")
|
||||
if args.deep_capture:
|
||||
write_deep_capture_snapshot(artifact_dir, "before", device)
|
||||
dynamic_debug_enabled = (
|
||||
set_uvc_dynamic_debug(artifact_dir / "deep-capture", True) if args.deep_capture else False
|
||||
)
|
||||
usbmon = start_usbmon_capture(artifact_dir / "deep-capture", device) if args.deep_capture else None
|
||||
frame_size = capture_width * capture_height
|
||||
stderr_path = artifact_dir / "ffmpeg.stderr"
|
||||
metrics_path = artifact_dir / "frame-metrics.jsonl"
|
||||
@ -1610,8 +1761,9 @@ def run_capture(args: argparse.Namespace) -> int:
|
||||
if frame_index % args.progress_every == 0:
|
||||
print(f"frames={frame_index} suspicious={suspicious_count} latest={result}", file=sys.stderr)
|
||||
|
||||
with stderr_path.open("wb") as err, metrics_path.open("w") as metrics:
|
||||
if args.stream_analyze:
|
||||
try:
|
||||
with stderr_path.open("wb") as err, metrics_path.open("w") as metrics:
|
||||
if args.stream_analyze and not args.deep_capture:
|
||||
(artifact_dir / "command.txt").write_text(" ".join(shlex.quote(part) for part in command) + "\n")
|
||||
proc = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=err)
|
||||
assert proc.stdout is not None
|
||||
@ -1633,18 +1785,40 @@ def run_capture(args: argparse.Namespace) -> int:
|
||||
analysis_elapsed = capture_elapsed
|
||||
else:
|
||||
raw_path = artifact_dir / "capture.raw"
|
||||
capture_command = command[:]
|
||||
if "-an" in capture_command:
|
||||
capture_command[capture_command.index("-an") : capture_command.index("-an")] = ["-t", str(args.duration)]
|
||||
if args.deep_capture:
|
||||
native_mjpeg = artifact_dir / "capture.mjpg"
|
||||
input_end = command.index("-an")
|
||||
capture_command = command[:input_end] + [
|
||||
"-t",
|
||||
str(args.duration),
|
||||
"-an",
|
||||
"-c:v",
|
||||
"copy",
|
||||
"-f",
|
||||
"mjpeg",
|
||||
str(native_mjpeg),
|
||||
]
|
||||
else:
|
||||
capture_command[-1:-1] = ["-t", str(args.duration)]
|
||||
capture_command[-1] = str(raw_path)
|
||||
native_mjpeg = None
|
||||
capture_command = command[:]
|
||||
if "-an" in capture_command:
|
||||
capture_command[capture_command.index("-an") : capture_command.index("-an")] = ["-t", str(args.duration)]
|
||||
else:
|
||||
capture_command[-1:-1] = ["-t", str(args.duration)]
|
||||
capture_command[-1] = str(raw_path)
|
||||
(artifact_dir / "command.txt").write_text(" ".join(shlex.quote(part) for part in capture_command) + "\n")
|
||||
print(f"capturing raw RCT frames before analysis: {raw_path}", file=sys.stderr)
|
||||
capture_started = time.monotonic()
|
||||
proc = subprocess.run(capture_command, stdout=subprocess.DEVNULL, stderr=err, check=False)
|
||||
capture_elapsed = time.monotonic() - capture_started
|
||||
ffmpeg_rc = proc.returncode
|
||||
if args.deep_capture and native_mjpeg is not None and native_mjpeg.exists():
|
||||
decode_command = [
|
||||
"ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "warning",
|
||||
"-i", str(native_mjpeg), "-an", "-pix_fmt", "gray", "-f", "rawvideo", str(raw_path),
|
||||
]
|
||||
(artifact_dir / "decode-command.txt").write_text(" ".join(shlex.quote(part) for part in decode_command) + "\n")
|
||||
subprocess.run(decode_command, stdout=subprocess.DEVNULL, stderr=err, check=False)
|
||||
raw_capture_bytes = raw_path.stat().st_size if raw_path.exists() else 0
|
||||
print(
|
||||
f"analyzing captured raw RCT frames bytes={raw_capture_bytes} capture_s={capture_elapsed:.3f}",
|
||||
@ -1661,13 +1835,19 @@ def run_capture(args: argparse.Namespace) -> int:
|
||||
finally:
|
||||
raw_path.unlink(missing_ok=True)
|
||||
analysis_elapsed = time.monotonic() - analysis_started
|
||||
finally:
|
||||
stop_usbmon_capture(usbmon)
|
||||
if args.deep_capture:
|
||||
write_deep_capture_snapshot(artifact_dir, "after", device)
|
||||
if dynamic_debug_enabled:
|
||||
set_uvc_dynamic_debug(artifact_dir / "deep-capture", False)
|
||||
elapsed = max(0.001, capture_elapsed)
|
||||
summary = {
|
||||
"schema": "lesavka.synthetic-rct-capture.v1",
|
||||
"source": args.source,
|
||||
"device": device,
|
||||
"mode": args.mode,
|
||||
"capture_mode": "stream" if args.stream_analyze else "rawfile",
|
||||
"capture_mode": "deep-mjpeg" if args.deep_capture else ("stream" if args.stream_analyze else "rawfile"),
|
||||
"width": capture_width,
|
||||
"height": capture_height,
|
||||
"fps_requested": fps,
|
||||
|
||||
@ -16,7 +16,7 @@ bench = false
|
||||
|
||||
[package]
|
||||
name = "lesavka_server"
|
||||
version = "0.27.6"
|
||||
version = "0.27.7"
|
||||
edition = "2024"
|
||||
autobins = false
|
||||
|
||||
|
||||
@ -20,6 +20,9 @@ const DEFAULT_CHANNELS: u32 = 2;
|
||||
const DEFAULT_JPEG_QUALITY: i32 = 82;
|
||||
const MARKER_BITS: usize = 32;
|
||||
const MARKER_COLUMNS: usize = 16;
|
||||
const BAND_MARKER_ROWS: usize = 64;
|
||||
const BAND_MARKER_BITS: usize = 32;
|
||||
const BAND_MARKER_CELL: usize = 4;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Args {
|
||||
@ -258,7 +261,7 @@ impl MjpegEncoder {
|
||||
let map = buffer
|
||||
.map_readable()
|
||||
.context("mapping encoded synthetic frame")?;
|
||||
Ok(map.as_slice().to_vec())
|
||||
add_jpeg_app4_integrity(map.as_slice(), sequence)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
// lesavka-uvc - minimal UVC control handler for the gadget node.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use lesavka_server::uvc_contract::{self, UvcContractInput, UvcPayloadContract};
|
||||
use std::collections::VecDeque;
|
||||
use std::env;
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
@ -56,9 +58,13 @@ const DEFAULT_UVC_FRAME_MAX_AGE_MS: u64 = 1_000;
|
||||
const DEFAULT_UVC_QUEUE_PACING: bool = false;
|
||||
const DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC: u32 = 4_500_000;
|
||||
const DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT: u32 = 85;
|
||||
const HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC: u32 = 8_000;
|
||||
const DEFAULT_UVC_STATS_INTERVAL_MS: u64 = 5_000;
|
||||
const DEFAULT_UVC_STATS_PATH: &str = "/run/lesavka-uvc-video-stats.json";
|
||||
const DEFAULT_UVC_IDLE_AFTER_STALE_MS: u64 = 2_000;
|
||||
const DEFAULT_UVC_HANDOFF_AUDIT_PATH: &str = "/run/lesavka-uvc-handoff.jsonl";
|
||||
const DEFAULT_UVC_HANDOFF_AUDIT_MAX_RECORDS: usize = 1_024;
|
||||
const DEFAULT_UVC_KERNEL_STATS_PATH: &str = "/run/lesavka-uvc-kernel-stats.json";
|
||||
const V4L2_BUF_FLAG_ERROR: u32 = 0x0040;
|
||||
|
||||
#[repr(C)]
|
||||
struct V4l2EventSubscription {
|
||||
@ -214,6 +220,7 @@ struct UvcState {
|
||||
probe: [u8; STREAM_CTRL_SIZE_MAX],
|
||||
commit: [u8; STREAM_CTRL_SIZE_MAX],
|
||||
cfg_snapshot: Option<ConfigfsSnapshot>,
|
||||
commit_observed: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@ -235,8 +242,10 @@ struct ConfigfsSnapshot {
|
||||
height: u32,
|
||||
default_interval: u32,
|
||||
frame_interval: u32,
|
||||
frame_size: u32,
|
||||
maxpacket: u32,
|
||||
maxburst: u32,
|
||||
streaming_bulk: Option<u32>,
|
||||
}
|
||||
|
||||
struct MmapBuffer {
|
||||
@ -255,6 +264,12 @@ struct UvcVideoStream {
|
||||
streaming: bool,
|
||||
transport_bulk: bool,
|
||||
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,
|
||||
}
|
||||
|
||||
@ -266,6 +281,12 @@ struct UvcVideoStats {
|
||||
rejected_oversize: u64,
|
||||
rejected_invalid: u64,
|
||||
fallback_idle: u64,
|
||||
held_last_good: u64,
|
||||
read_errors: u64,
|
||||
strict_validation_failures: u64,
|
||||
dqbuf_ioctl_errors: u64,
|
||||
dqbuf_flag_errors: u64,
|
||||
qbuf_ioctl_errors: u64,
|
||||
latest_bytes: usize,
|
||||
last_rejected_oversize_bytes: usize,
|
||||
last_rejected_oversize_cap: usize,
|
||||
@ -287,6 +308,12 @@ impl UvcVideoStream {
|
||||
streaming: false,
|
||||
transport_bulk: false,
|
||||
max_packet: 0,
|
||||
has_verified_frame: false,
|
||||
last_verified_mtime: None,
|
||||
last_spool_mtime_ns: None,
|
||||
last_refresh_disposition: "idle_startup",
|
||||
handoff_sequence: 0,
|
||||
handoff_audit: VecDeque::new(),
|
||||
stats: UvcVideoStats::default(),
|
||||
}
|
||||
}
|
||||
@ -301,7 +328,7 @@ impl UvcVideoStream {
|
||||
self.set_format(cfg)?;
|
||||
self.request_buffers(uvc_buffer_count())?;
|
||||
for index in 0..self.buffers.len() {
|
||||
self.queue_buffer(index as u32)?;
|
||||
self.queue_buffer(index as u32, 0)?;
|
||||
}
|
||||
let req = ioctl_write::<libc::c_int>(b'V', 18);
|
||||
let mut type_ = V4L2_BUF_TYPE_VIDEO_OUTPUT as libc::c_int;
|
||||
@ -361,10 +388,16 @@ impl UvcVideoStream {
|
||||
let err = std::io::Error::last_os_error();
|
||||
match err.raw_os_error() {
|
||||
Some(libc::EAGAIN) | Some(libc::EINTR) => return Ok(()),
|
||||
_ => return Err(err).context("VIDIOC_DQBUF"),
|
||||
_ => {
|
||||
self.stats.dqbuf_ioctl_errors += 1;
|
||||
return Err(err).context("VIDIOC_DQBUF");
|
||||
}
|
||||
}
|
||||
}
|
||||
self.queue_buffer(buf.index)?;
|
||||
if buf.flags & V4L2_BUF_FLAG_ERROR != 0 {
|
||||
self.stats.dqbuf_flag_errors += 1;
|
||||
}
|
||||
self.queue_buffer(buf.index, buf.flags)?;
|
||||
}
|
||||
}
|
||||
|
||||
@ -446,7 +479,7 @@ impl UvcVideoStream {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn queue_buffer(&mut self, index: u32) -> Result<()> {
|
||||
fn queue_buffer(&mut self, index: u32, dqbuf_flags: u32) -> Result<()> {
|
||||
if self.streaming {
|
||||
self.pace_queue_if_needed();
|
||||
}
|
||||
@ -456,6 +489,8 @@ impl UvcVideoStream {
|
||||
};
|
||||
let frame = self.frame_for_buffer(buffer.len);
|
||||
let bytes = frame.len();
|
||||
let frame_hash = fnv1a64(frame);
|
||||
let frame_sequence = uvc_contract::lesavka_app4_sequence(frame);
|
||||
if bytes > 0 {
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(frame.as_ptr(), buffer.ptr, bytes);
|
||||
@ -478,10 +513,12 @@ impl UvcVideoStream {
|
||||
let req = ioctl_readwrite::<V4l2Buffer>(b'V', 15);
|
||||
let rc = unsafe { libc::ioctl(self.fd, req, &mut buf) };
|
||||
if rc < 0 {
|
||||
self.stats.qbuf_ioctl_errors += 1;
|
||||
return Err(std::io::Error::last_os_error()).context("VIDIOC_QBUF");
|
||||
}
|
||||
self.stats.queued += 1;
|
||||
self.stats.latest_bytes = bytes;
|
||||
self.record_handoff(index, dqbuf_flags, bytes, frame_hash, frame_sequence);
|
||||
self.report_stats_if_due();
|
||||
Ok(())
|
||||
}
|
||||
@ -503,35 +540,63 @@ impl UvcVideoStream {
|
||||
}
|
||||
|
||||
fn refresh_latest_frame(&mut self) {
|
||||
self.last_spool_mtime_ns = frame_spool_mtime_ns(&self.frame_path);
|
||||
let stale = frame_spool_is_stale(&self.frame_path, frame_spool_max_age());
|
||||
if stale {
|
||||
self.stats.replayed_stale += 1;
|
||||
self.replace_latest_with_idle();
|
||||
self.hold_last_good_or_idle("hold_stale", "idle_stale");
|
||||
return;
|
||||
}
|
||||
let max_frame_bytes = self.frame_payload_limit();
|
||||
match std::fs::read(&self.frame_path) {
|
||||
Ok(frame) if !looks_like_mjpeg_frame(&frame) => {
|
||||
self.stats.rejected_invalid += 1;
|
||||
self.replace_latest_with_idle();
|
||||
self.hold_last_good_or_idle("hold_invalid", "idle_invalid");
|
||||
}
|
||||
Ok(frame) if frame.len() > max_frame_bytes => {
|
||||
self.stats.rejected_oversize += 1;
|
||||
self.stats.last_rejected_oversize_bytes = frame.len();
|
||||
self.stats.last_rejected_oversize_cap = max_frame_bytes;
|
||||
self.replace_latest_with_idle();
|
||||
self.hold_last_good_or_idle("hold_oversize", "idle_oversize");
|
||||
}
|
||||
Ok(frame) => {
|
||||
if strict_validation_due(self.stats.reloaded)
|
||||
&& (!uvc_contract::validate_jpeg_structure(&frame)
|
||||
|| uvc_contract::lesavka_app4_integrity_valid(&frame) == Some(false))
|
||||
{
|
||||
self.stats.rejected_invalid += 1;
|
||||
self.stats.strict_validation_failures += 1;
|
||||
self.hold_last_good_or_idle("hold_strict_invalid", "idle_strict_invalid");
|
||||
return;
|
||||
}
|
||||
self.stats.reloaded += 1;
|
||||
self.stats.latest_bytes = frame.len();
|
||||
self.latest_frame = frame;
|
||||
self.has_verified_frame = true;
|
||||
self.last_verified_mtime = frame_spool_modified(&self.frame_path);
|
||||
self.last_refresh_disposition = "fresh";
|
||||
}
|
||||
Err(_) => {
|
||||
self.stats.read_errors += 1;
|
||||
self.hold_last_good_or_idle("hold_read_error", "idle_read_error");
|
||||
}
|
||||
Err(_) => self.replace_latest_with_idle(),
|
||||
}
|
||||
if !looks_like_mjpeg_frame(&self.latest_frame) {
|
||||
self.replace_latest_with_idle();
|
||||
self.last_refresh_disposition = "idle_invalid_cache";
|
||||
}
|
||||
}
|
||||
|
||||
fn hold_last_good_or_idle(&mut self, hold_reason: &'static str, idle_reason: &'static str) {
|
||||
if self.has_verified_frame && !verified_frame_idle_timeout_elapsed(self.last_verified_mtime) {
|
||||
self.stats.held_last_good += 1;
|
||||
self.last_refresh_disposition = hold_reason;
|
||||
return;
|
||||
}
|
||||
self.replace_latest_with_idle();
|
||||
self.last_refresh_disposition = idle_reason;
|
||||
}
|
||||
|
||||
fn replace_latest_with_idle(&mut self) {
|
||||
if self.latest_frame.as_slice() != IDLE_MJPEG_FRAME {
|
||||
self.stats.fallback_idle += 1;
|
||||
@ -539,6 +604,53 @@ impl UvcVideoStream {
|
||||
self.latest_frame = IDLE_MJPEG_FRAME.to_vec();
|
||||
}
|
||||
|
||||
fn record_handoff(
|
||||
&mut self,
|
||||
buffer_index: u32,
|
||||
dqbuf_flags: u32,
|
||||
bytes: usize,
|
||||
hash: u64,
|
||||
frame_sequence: Option<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 {
|
||||
self.buffers
|
||||
.iter()
|
||||
@ -572,13 +684,19 @@ impl UvcVideoStream {
|
||||
}
|
||||
self.stats.last_report = Some(now);
|
||||
eprintln!(
|
||||
"[lesavka-uvc] video stats queued={} reloaded={} stale_replay={} rejected_oversize={} rejected_invalid={} fallback_idle={} latest_bytes={} frame_cap={} last_rejected_oversize_bytes={} last_rejected_oversize_cap={} paced_sleeps={} paced_sleep_ms={}",
|
||||
"[lesavka-uvc] video stats queued={} reloaded={} stale_replay={} rejected_oversize={} rejected_invalid={} fallback_idle={} held_last_good={} read_errors={} strict_validation_failures={} dqbuf_ioctl_errors={} dqbuf_flag_errors={} qbuf_ioctl_errors={} latest_bytes={} frame_cap={} last_rejected_oversize_bytes={} last_rejected_oversize_cap={} paced_sleeps={} paced_sleep_ms={}",
|
||||
self.stats.queued,
|
||||
self.stats.reloaded,
|
||||
self.stats.replayed_stale,
|
||||
self.stats.rejected_oversize,
|
||||
self.stats.rejected_invalid,
|
||||
self.stats.fallback_idle,
|
||||
self.stats.held_last_good,
|
||||
self.stats.read_errors,
|
||||
self.stats.strict_validation_failures,
|
||||
self.stats.dqbuf_ioctl_errors,
|
||||
self.stats.dqbuf_flag_errors,
|
||||
self.stats.qbuf_ioctl_errors,
|
||||
self.stats.latest_bytes,
|
||||
self.frame_payload_limit(),
|
||||
self.stats.last_rejected_oversize_bytes,
|
||||
@ -683,18 +801,17 @@ fn uvc_queue_period(fps: u32) -> Option<Duration> {
|
||||
/// half-frame grey smears, and freezing the last good frame is preferable to
|
||||
/// queueing a frame that is likely to arrive incomplete.
|
||||
fn uvc_frame_max_bytes(cfg: UvcConfig) -> usize {
|
||||
if !uvc_frame_size_guard_enabled() {
|
||||
return MAX_MJPEG_FRAME_BYTES;
|
||||
if let Ok(contract) = uvc_contract::read_contract(&uvc_contract::contract_path()) {
|
||||
return contract
|
||||
.enforced_frame_cap_bytes
|
||||
.min(cfg.frame_size)
|
||||
.min(MAX_MJPEG_FRAME_BYTES as u32) as usize;
|
||||
}
|
||||
if let Some(limit) = env_u32_opt("LESAVKA_UVC_FRAME_MAX_BYTES") {
|
||||
return if limit == 0 {
|
||||
derived_uvc_frame_max_bytes(cfg)
|
||||
} else {
|
||||
(limit as usize).min(MAX_MJPEG_FRAME_BYTES)
|
||||
};
|
||||
}
|
||||
|
||||
derived_uvc_frame_max_bytes(cfg)
|
||||
let derived = derived_uvc_frame_max_bytes(cfg).min(cfg.frame_size as usize);
|
||||
env_u32_opt("LESAVKA_UVC_FRAME_MAX_BYTES")
|
||||
.filter(|limit| *limit > 0)
|
||||
.map(|limit| derived.min(limit as usize))
|
||||
.unwrap_or(derived)
|
||||
}
|
||||
|
||||
fn derived_uvc_frame_max_bytes(cfg: UvcConfig) -> usize {
|
||||
@ -702,10 +819,8 @@ fn derived_uvc_frame_max_bytes(cfg: UvcConfig) -> usize {
|
||||
}
|
||||
|
||||
fn derived_uvc_frame_max_bytes_for_transport(fps: u32, max_packet: u32, bulk: bool) -> usize {
|
||||
let fps = fps.max(1);
|
||||
let budget_per_sec = effective_uvc_mjpeg_budget_bytes_per_sec(max_packet, bulk);
|
||||
let per_frame = (budget_per_sec / fps).max(64 * 1024);
|
||||
per_frame.min(MAX_MJPEG_FRAME_BYTES as u32) as usize
|
||||
uvc_contract::frame_cap_from_budget(budget_per_sec, fps) as usize
|
||||
}
|
||||
|
||||
fn effective_uvc_mjpeg_budget_bytes_per_sec(max_packet: u32, bulk: bool) -> u32 {
|
||||
@ -714,26 +829,11 @@ fn effective_uvc_mjpeg_budget_bytes_per_sec(max_packet: u32, bulk: bool) -> u32
|
||||
DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC,
|
||||
)
|
||||
.max(1);
|
||||
if bulk {
|
||||
return configured;
|
||||
}
|
||||
|
||||
configured
|
||||
.min(uvc_isochronous_budget_bytes_per_sec(max_packet))
|
||||
.max(1)
|
||||
}
|
||||
|
||||
fn uvc_isochronous_budget_bytes_per_sec(max_packet: u32) -> u32 {
|
||||
let pct = env_u32(
|
||||
"LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT",
|
||||
DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT,
|
||||
)
|
||||
.clamp(1, 100);
|
||||
let bytes = u64::from(max_packet)
|
||||
.saturating_mul(u64::from(HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC))
|
||||
.saturating_mul(u64::from(pct))
|
||||
/ 100;
|
||||
bytes.min(u64::from(u32::MAX)) as u32
|
||||
);
|
||||
uvc_contract::effective_mjpeg_budget_bytes_per_sec(configured, max_packet, bulk, pct)
|
||||
}
|
||||
|
||||
fn env_flag_enabled(name: &str, default: bool) -> bool {
|
||||
@ -760,22 +860,19 @@ fn env_flag_enabled(name: &str, default: bool) -> bool {
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn uvc_frame_size_guard_enabled() -> bool {
|
||||
env_flag_enabled("LESAVKA_UVC_FRAME_SIZE_GUARD", true)
|
||||
fn uvc_bulk_transfer_enabled() -> bool {
|
||||
uvc_bulk_transfer_enabled_for_base(std::path::Path::new(CONFIGFS_UVC_BASE))
|
||||
}
|
||||
|
||||
fn uvc_bulk_transfer_enabled() -> bool {
|
||||
if !env_flag_enabled("LESAVKA_UVC_BULK", true) {
|
||||
return false;
|
||||
}
|
||||
let base = std::path::Path::new(CONFIGFS_UVC_BASE);
|
||||
if base.exists() && !base.join("streaming_bulk").exists() {
|
||||
fn uvc_bulk_transfer_enabled_for_base(base: &std::path::Path) -> bool {
|
||||
let requested = env_flag_enabled("LESAVKA_UVC_BULK", true);
|
||||
let bulk = uvc_contract::bulk_enabled_from_configfs(base, requested);
|
||||
if requested && !bulk {
|
||||
eprintln!(
|
||||
"[lesavka-uvc] UVC bulk requested but live configfs has no streaming_bulk; using isochronous payload sizing"
|
||||
"[lesavka-uvc] UVC bulk requested but live configfs streaming_bulk is not 1; using isochronous payload sizing"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
bulk
|
||||
}
|
||||
|
||||
fn uvc_frame_size_for_active_mode(
|
||||
@ -785,12 +882,12 @@ fn uvc_frame_size_for_active_mode(
|
||||
max_packet: u32,
|
||||
bulk: bool,
|
||||
) -> u32 {
|
||||
env_u32_opt("LESAVKA_UVC_FRAME_SIZE")
|
||||
.unwrap_or_else(|| {
|
||||
derived_uvc_frame_max_bytes_for_transport(fps, max_packet, bulk)
|
||||
.min(u32::MAX as usize) as u32
|
||||
})
|
||||
.max((width.saturating_mul(height) / 32).max(64 * 1024))
|
||||
let derived = derived_uvc_frame_max_bytes_for_transport(fps, max_packet, bulk)
|
||||
.min(u32::MAX as usize) as u32;
|
||||
let requested = env_u32_opt("LESAVKA_UVC_FRAME_SIZE").filter(|value| *value > 0);
|
||||
let frame_size = requested.unwrap_or(derived).min(derived);
|
||||
let minimum = (width.saturating_mul(height) / 32).max(64 * 1024);
|
||||
frame_size.max(minimum.min(derived))
|
||||
}
|
||||
|
||||
fn write_atomic_text(path: &std::path::Path, text: &str) -> Result<()> {
|
||||
@ -804,23 +901,43 @@ fn write_atomic_text(path: &std::path::Path, text: &str) -> Result<()> {
|
||||
}
|
||||
|
||||
fn uvc_stats_snapshot_json(stats: &UvcVideoStats, frame_cap: usize) -> String {
|
||||
let kernel = uvc_kernel_stats_json();
|
||||
format!(
|
||||
"{{\"queued\":{},\"reloaded\":{},\"stale_replay\":{},\"rejected_oversize\":{},\"rejected_invalid\":{},\"fallback_idle\":{},\"latest_bytes\":{},\"frame_cap\":{},\"last_rejected_oversize_bytes\":{},\"last_rejected_oversize_cap\":{},\"paced_sleeps\":{},\"paced_sleep_ms\":{}}}\n",
|
||||
"{{\"generated_unix_ms\":{},\"queued\":{},\"reloaded\":{},\"stale_replay\":{},\"rejected_oversize\":{},\"rejected_invalid\":{},\"fallback_idle\":{},\"held_last_good\":{},\"read_errors\":{},\"strict_validation_failures\":{},\"dqbuf_ioctl_errors\":{},\"dqbuf_flag_errors\":{},\"qbuf_ioctl_errors\":{},\"latest_bytes\":{},\"frame_cap\":{},\"last_rejected_oversize_bytes\":{},\"last_rejected_oversize_cap\":{},\"paced_sleeps\":{},\"paced_sleep_ms\":{},\"kernel\":{}}}\n",
|
||||
unix_now_ns() / 1_000_000,
|
||||
stats.queued,
|
||||
stats.reloaded,
|
||||
stats.replayed_stale,
|
||||
stats.rejected_oversize,
|
||||
stats.rejected_invalid,
|
||||
stats.fallback_idle,
|
||||
stats.held_last_good,
|
||||
stats.read_errors,
|
||||
stats.strict_validation_failures,
|
||||
stats.dqbuf_ioctl_errors,
|
||||
stats.dqbuf_flag_errors,
|
||||
stats.qbuf_ioctl_errors,
|
||||
stats.latest_bytes,
|
||||
frame_cap,
|
||||
stats.last_rejected_oversize_bytes,
|
||||
stats.last_rejected_oversize_cap,
|
||||
stats.paced_sleeps,
|
||||
stats.paced_sleep_ms
|
||||
stats.paced_sleep_ms,
|
||||
kernel
|
||||
)
|
||||
}
|
||||
|
||||
fn uvc_kernel_stats_json() -> String {
|
||||
let path = env::var("LESAVKA_UVC_KERNEL_STATS_PATH")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|_| std::path::PathBuf::from(DEFAULT_UVC_KERNEL_STATS_PATH));
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|value| serde_json::from_str::<serde_json::Value>(&value).ok())
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_else(|| "null".to_string())
|
||||
}
|
||||
|
||||
fn uvc_stats_interval() -> Option<Duration> {
|
||||
match env_u64("LESAVKA_UVC_STATS_INTERVAL_MS", DEFAULT_UVC_STATS_INTERVAL_MS) {
|
||||
0 => None,
|
||||
@ -838,6 +955,80 @@ fn frame_spool_max_age() -> Option<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 {
|
||||
let Some(max_age) = max_age else {
|
||||
return false;
|
||||
@ -1069,7 +1260,8 @@ impl UvcConfig {
|
||||
} else {
|
||||
requested_interval
|
||||
};
|
||||
if let Some(snapshot) = read_configfs_snapshot() {
|
||||
let live_snapshot = read_configfs_snapshot();
|
||||
if let Some(snapshot) = live_snapshot {
|
||||
let live_interval = if snapshot.default_interval == 0 {
|
||||
snapshot.frame_interval
|
||||
} else {
|
||||
@ -1149,7 +1341,13 @@ impl UvcConfig {
|
||||
} else {
|
||||
max_packet = max_packet.min(1024);
|
||||
}
|
||||
let frame_size = uvc_frame_size_for_active_mode(width, height, fps, max_packet, bulk);
|
||||
let frame_size = uvc_frame_size_for_active_mode(width, height, fps, max_packet, bulk)
|
||||
.min(
|
||||
live_snapshot
|
||||
.map(|snapshot| snapshot.frame_size)
|
||||
.filter(|frame_size| *frame_size > 0)
|
||||
.unwrap_or(u32::MAX),
|
||||
);
|
||||
|
||||
Self {
|
||||
width,
|
||||
@ -1168,14 +1366,77 @@ impl UvcState {
|
||||
let _profile_hint = (cfg.width, cfg.height, cfg.fps);
|
||||
let ctrl_len = stream_ctrl_len();
|
||||
let default = build_streaming_control(&cfg, ctrl_len);
|
||||
Self {
|
||||
let state = Self {
|
||||
cfg,
|
||||
ctrl_len,
|
||||
default,
|
||||
probe: default,
|
||||
commit: default,
|
||||
cfg_snapshot: None,
|
||||
commit_observed: false,
|
||||
};
|
||||
state.publish_payload_contract("startup");
|
||||
state
|
||||
}
|
||||
|
||||
fn payload_contract(&self) -> UvcPayloadContract {
|
||||
let snapshot = read_configfs_snapshot();
|
||||
let streaming_bulk_value = snapshot
|
||||
.and_then(|snapshot| snapshot.streaming_bulk)
|
||||
.or_else(|| {
|
||||
uvc_contract::read_streaming_bulk_value(std::path::Path::new(CONFIGFS_UVC_BASE))
|
||||
});
|
||||
UvcPayloadContract::from_input(UvcContractInput {
|
||||
configfs_available: snapshot.is_some(),
|
||||
commit_observed: self.commit_observed,
|
||||
bulk: self.cfg.bulk,
|
||||
streaming_bulk_value,
|
||||
width: self.cfg.width,
|
||||
height: self.cfg.height,
|
||||
fps: self.cfg.fps,
|
||||
frame_interval_100ns: read_le32(&self.commit, 4).max(self.cfg.interval),
|
||||
streaming_maxpacket: snapshot
|
||||
.map(|value| value.maxpacket)
|
||||
.unwrap_or(self.cfg.max_packet),
|
||||
streaming_maxburst: snapshot.map(|value| value.maxburst).unwrap_or(0),
|
||||
configured_budget_bytes_per_sec: env_u32(
|
||||
"LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC",
|
||||
DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC,
|
||||
),
|
||||
isochronous_limit_pct: env_u32(
|
||||
"LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT",
|
||||
DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT,
|
||||
),
|
||||
advertised_frame_bytes: snapshot
|
||||
.map(|value| value.frame_size)
|
||||
.unwrap_or(self.cfg.frame_size),
|
||||
committed_frame_bytes: read_le32(&self.commit, 18),
|
||||
committed_payload_bytes: read_le32(&self.commit, 22),
|
||||
requested_frame_cap_bytes: env_u32_opt("LESAVKA_UVC_FRAME_MAX_BYTES"),
|
||||
})
|
||||
}
|
||||
|
||||
fn publish_payload_contract(&self, reason: &str) {
|
||||
let contract = self.payload_contract();
|
||||
let path = uvc_contract::contract_path();
|
||||
if let Err(err) = uvc_contract::write_contract(&path, &contract) {
|
||||
eprintln!(
|
||||
"[lesavka-uvc] failed to publish UVC payload contract {}: {err:#}",
|
||||
path.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
eprintln!(
|
||||
"[lesavka-uvc] payload contract reason={} transport={} frame_cap={} advertised={} committed={} payload={} divergent={} path={}",
|
||||
reason,
|
||||
contract.transport,
|
||||
contract.enforced_frame_cap_bytes,
|
||||
contract.advertised_frame_bytes,
|
||||
contract.committed_frame_bytes,
|
||||
contract.committed_payload_bytes,
|
||||
contract.divergent,
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1456,6 +1717,7 @@ fn handle_data(
|
||||
let sanitized = sanitize_streaming_control(slice, state);
|
||||
if p.selector == UVC_VS_PROBE_CONTROL {
|
||||
state.probe = sanitized;
|
||||
state.publish_payload_contract("probe");
|
||||
if debug {
|
||||
let interval = read_le32(&state.probe, 4);
|
||||
let payload = read_le32(&state.probe, 22);
|
||||
@ -1467,6 +1729,8 @@ fn handle_data(
|
||||
}
|
||||
} else {
|
||||
state.commit = sanitized;
|
||||
state.commit_observed = true;
|
||||
state.publish_payload_contract("commit");
|
||||
if debug {
|
||||
let interval = read_le32(&state.commit, 4);
|
||||
let payload = read_le32(&state.commit, 22);
|
||||
@ -1716,15 +1980,20 @@ fn read_configfs_snapshot() -> Option<ConfigfsSnapshot> {
|
||||
"{frame_root}/dwDefaultFrameInterval"
|
||||
))?;
|
||||
let frame_interval = read_u32_first(&format!("{frame_root}/dwFrameInterval")).unwrap_or(0);
|
||||
let frame_size = read_u32_file(&format!("{frame_root}/dwMaxVideoFrameBufferSize"))?;
|
||||
let maxpacket = read_u32_file(&format!("{CONFIGFS_UVC_BASE}/streaming_maxpacket"))?;
|
||||
let maxburst = read_u32_file(&format!("{CONFIGFS_UVC_BASE}/streaming_maxburst")).unwrap_or(0);
|
||||
let streaming_bulk =
|
||||
uvc_contract::read_streaming_bulk_value(std::path::Path::new(CONFIGFS_UVC_BASE));
|
||||
Some(ConfigfsSnapshot {
|
||||
width,
|
||||
height,
|
||||
default_interval,
|
||||
frame_interval,
|
||||
frame_size,
|
||||
maxpacket,
|
||||
maxburst,
|
||||
streaming_bulk,
|
||||
})
|
||||
}
|
||||
|
||||
@ -1747,13 +2016,15 @@ fn log_configfs_snapshot(state: &mut UvcState, label: &str) {
|
||||
return;
|
||||
}
|
||||
eprintln!(
|
||||
"[lesavka-uvc] configfs {label}: {}x{} default_interval={} frame_interval={} maxpacket={} maxburst={}",
|
||||
"[lesavka-uvc] configfs {label}: {}x{} default_interval={} frame_interval={} frame_size={} maxpacket={} maxburst={} streaming_bulk={:?}",
|
||||
current.width,
|
||||
current.height,
|
||||
current.default_interval,
|
||||
current.frame_interval,
|
||||
current.frame_size,
|
||||
current.maxpacket,
|
||||
current.maxburst
|
||||
current.maxburst,
|
||||
current.streaming_bulk
|
||||
);
|
||||
state.cfg_snapshot = Some(current);
|
||||
}
|
||||
|
||||
@ -81,6 +81,9 @@ fn synthetic_luma(
|
||||
let (moving_width, moving_offset) = moving_bar;
|
||||
let width = width.max(1);
|
||||
let height = height.max(1);
|
||||
if let Some(value) = synthetic_band_marker_luma(width, height, x, y, sequence) {
|
||||
return value;
|
||||
}
|
||||
let block_w = (width / 24).max(24);
|
||||
let block_h = (height / 18).max(18);
|
||||
let base = 44
|
||||
@ -107,6 +110,36 @@ fn synthetic_luma(
|
||||
value
|
||||
}
|
||||
|
||||
fn synthetic_band_marker_luma(
|
||||
width: usize,
|
||||
height: usize,
|
||||
x: usize,
|
||||
y: usize,
|
||||
sequence: u64,
|
||||
) -> Option<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 {
|
||||
(width.min(height) / 80).clamp(6, 16)
|
||||
}
|
||||
|
||||
@ -88,6 +88,27 @@ fn synthetic_frame_helpers_handle_markers_bounds_and_silence() {
|
||||
fill_rect(&mut frame, 6, 4, 2, 8, 8, 77);
|
||||
assert_eq!(&frame[(2 * 6 + 4) * 3..(2 * 6 + 5) * 3], &[77, 77, 77]);
|
||||
assert_eq!(&frame[(3 * 6 + 5) * 3..(3 * 6 + 6) * 3], &[77, 77, 77]);
|
||||
|
||||
let first_band = synthetic_band_marker_luma(1280, 720, 1244, 4, 0x55aa);
|
||||
let second_band = synthetic_band_marker_luma(1280, 720, 1244, 68, 0x55aa);
|
||||
assert!(first_band.is_some());
|
||||
assert_ne!(first_band, second_band);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app4_integrity_marker_carries_sequence_and_original_jpeg_crc() {
|
||||
let jpeg = [0xff, 0xd8, 0xff, 0xda, 0x00, 0x02, 0x11, 0xff, 0xd9];
|
||||
let marked = add_jpeg_app4_integrity(&jpeg, 0x0102_0304_0506_0708).expect("APP4 marker");
|
||||
|
||||
assert_eq!(&marked[..4], &[0xff, 0xd8, 0xff, 0xe4]);
|
||||
assert_eq!(&marked[6..10], b"LSVK");
|
||||
assert_eq!(marked[10], 1);
|
||||
assert_eq!(&marked[11..19], &0x0102_0304_0506_0708u64.to_be_bytes());
|
||||
assert_eq!(
|
||||
&marked[19..23],
|
||||
&lesavka_server::uvc_contract::crc32_ieee(&jpeg).to_be_bytes()
|
||||
);
|
||||
assert_eq!(&marked[23..], &jpeg[2..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -100,6 +100,7 @@ fn handle_data(
|
||||
state.probe = sanitized;
|
||||
} else {
|
||||
state.commit = sanitized;
|
||||
state.commit_observed = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -57,6 +57,7 @@ impl UvcState {
|
||||
probe: default,
|
||||
commit: default,
|
||||
cfg_snapshot: None,
|
||||
commit_observed: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -122,10 +123,10 @@ fn uvc_bulk_transfer_enabled() -> bool {
|
||||
/// Inputs: configfs function root. Output: effective bulk capability. Why:
|
||||
/// tests need this branch without depending on the host's real gadget tree.
|
||||
fn uvc_bulk_transfer_enabled_for_base(base: &std::path::Path) -> bool {
|
||||
if base.exists() && !base.join("streaming_bulk").exists() {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
lesavka_server::uvc_contract::bulk_enabled_from_configfs(
|
||||
base,
|
||||
env_flag_enabled("LESAVKA_UVC_BULK", true),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
@ -136,10 +137,11 @@ fn uvc_frame_size_for_active_mode(
|
||||
max_packet: u32,
|
||||
bulk: bool,
|
||||
) -> u32 {
|
||||
let derived = derived_uvc_frame_max_bytes_for_transport(fps, max_packet, bulk)
|
||||
.min(u32::MAX as usize) as u32;
|
||||
env_u32_opt("LESAVKA_UVC_FRAME_SIZE")
|
||||
.unwrap_or_else(|| {
|
||||
derived_uvc_frame_max_bytes_for_transport(fps, max_packet, bulk)
|
||||
.min(u32::MAX as usize) as u32
|
||||
})
|
||||
.max((width.saturating_mul(height) / 32).max(64 * 1024))
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or(derived)
|
||||
.min(derived)
|
||||
.max((width.saturating_mul(height) / 32).max(64 * 1024).min(derived))
|
||||
}
|
||||
|
||||
@ -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
|
||||
/// that turns oversized UVC frames into freezes instead of grey half-frames.
|
||||
fn uvc_frame_max_bytes(cfg: UvcConfig) -> usize {
|
||||
if !uvc_frame_size_guard_enabled() {
|
||||
return MAX_MJPEG_FRAME_BYTES;
|
||||
if let Ok(contract) = lesavka_server::uvc_contract::read_contract(
|
||||
&lesavka_server::uvc_contract::contract_path(),
|
||||
) {
|
||||
return contract
|
||||
.enforced_frame_cap_bytes
|
||||
.min(cfg.frame_size)
|
||||
.min(MAX_MJPEG_FRAME_BYTES as u32) as usize;
|
||||
}
|
||||
if let Some(limit) = env_u32_opt("LESAVKA_UVC_FRAME_MAX_BYTES") {
|
||||
return if limit == 0 {
|
||||
derived_uvc_frame_max_bytes(cfg)
|
||||
} else {
|
||||
(limit as usize).min(MAX_MJPEG_FRAME_BYTES)
|
||||
};
|
||||
}
|
||||
|
||||
derived_uvc_frame_max_bytes(cfg)
|
||||
let derived = derived_uvc_frame_max_bytes(cfg).min(cfg.frame_size as usize);
|
||||
env_u32_opt("LESAVKA_UVC_FRAME_MAX_BYTES")
|
||||
.filter(|limit| *limit > 0)
|
||||
.map(|limit| derived.min(limit as usize))
|
||||
.unwrap_or(derived)
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
@ -64,10 +65,8 @@ fn derived_uvc_frame_max_bytes(cfg: UvcConfig) -> usize {
|
||||
|
||||
#[cfg(coverage)]
|
||||
fn derived_uvc_frame_max_bytes_for_transport(fps: u32, max_packet: u32, bulk: bool) -> usize {
|
||||
let fps = fps.max(1);
|
||||
let budget_per_sec = effective_uvc_mjpeg_budget_bytes_per_sec(max_packet, bulk);
|
||||
let per_frame = (budget_per_sec / fps).max(64 * 1024);
|
||||
per_frame.min(MAX_MJPEG_FRAME_BYTES as u32) as usize
|
||||
lesavka_server::uvc_contract::frame_cap_from_budget(budget_per_sec, fps) as usize
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
@ -81,31 +80,16 @@ fn effective_uvc_mjpeg_budget_bytes_per_sec(max_packet: u32, bulk: bool) -> u32
|
||||
DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC,
|
||||
)
|
||||
.max(1);
|
||||
if bulk {
|
||||
return configured;
|
||||
}
|
||||
|
||||
configured
|
||||
.min(uvc_isochronous_budget_bytes_per_sec(max_packet))
|
||||
.max(1)
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
/// Computes the high-speed isochronous payload budget.
|
||||
///
|
||||
/// Inputs: endpoint packet size and `LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT`.
|
||||
/// Output: capped bytes per second. Why: coverage locks the safety margin.
|
||||
fn uvc_isochronous_budget_bytes_per_sec(max_packet: u32) -> u32 {
|
||||
let pct = env_u32(
|
||||
"LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT",
|
||||
DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT,
|
||||
);
|
||||
lesavka_server::uvc_contract::effective_mjpeg_budget_bytes_per_sec(
|
||||
configured,
|
||||
max_packet,
|
||||
bulk,
|
||||
pct,
|
||||
)
|
||||
.clamp(1, 100);
|
||||
let bytes = u64::from(max_packet)
|
||||
.saturating_mul(u64::from(HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC))
|
||||
.saturating_mul(u64::from(pct))
|
||||
/ 100;
|
||||
bytes.min(u64::from(u32::MAX)) as u32
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
use anyhow::{Context, Result};
|
||||
#[cfg(coverage)]
|
||||
use lesavka_server::uvc_contract;
|
||||
#[cfg(coverage)]
|
||||
use std::env;
|
||||
#[cfg(coverage)]
|
||||
use std::fs::OpenOptions;
|
||||
@ -63,12 +65,14 @@ const DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC: u32 = 4_500_000;
|
||||
#[cfg(coverage)]
|
||||
const DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT: u32 = 85;
|
||||
#[cfg(coverage)]
|
||||
const HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC: u32 = 8_000;
|
||||
#[cfg(coverage)]
|
||||
const DEFAULT_UVC_STATS_INTERVAL_MS: u64 = 5_000;
|
||||
#[cfg(coverage)]
|
||||
const DEFAULT_UVC_STATS_PATH: &str = "/run/lesavka-uvc-video-stats.json";
|
||||
#[cfg(coverage)]
|
||||
const DEFAULT_UVC_IDLE_AFTER_STALE_MS: u64 = 2_000;
|
||||
#[cfg(coverage)]
|
||||
const DEFAULT_UVC_KERNEL_STATS_PATH: &str = "/run/lesavka-uvc-kernel-stats.json";
|
||||
#[cfg(coverage)]
|
||||
const MAX_MJPEG_FRAME_BYTES: usize = 8 * 1024 * 1024;
|
||||
#[cfg(coverage)]
|
||||
const MINIMAL_MJPEG_FRAME: &[u8] = &[0xff, 0xd8, 0xff, 0xd9];
|
||||
@ -130,6 +134,7 @@ struct UvcState {
|
||||
probe: [u8; STREAM_CTRL_SIZE_MAX],
|
||||
commit: [u8; STREAM_CTRL_SIZE_MAX],
|
||||
cfg_snapshot: Option<ConfigfsSnapshot>,
|
||||
commit_observed: bool,
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
@ -154,8 +159,10 @@ struct ConfigfsSnapshot {
|
||||
height: u32,
|
||||
default_interval: u32,
|
||||
frame_interval: u32,
|
||||
frame_size: u32,
|
||||
maxpacket: u32,
|
||||
maxburst: u32,
|
||||
streaming_bulk: Option<u32>,
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
@ -175,6 +182,8 @@ struct UvcVideoStream {
|
||||
next_queue_at: Option<std::time::Instant>,
|
||||
transport_bulk: bool,
|
||||
max_packet: u32,
|
||||
has_verified_frame: bool,
|
||||
last_verified_mtime: Option<std::time::SystemTime>,
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
@ -186,6 +195,12 @@ struct UvcVideoStats {
|
||||
rejected_oversize: u64,
|
||||
rejected_invalid: u64,
|
||||
fallback_idle: u64,
|
||||
held_last_good: u64,
|
||||
read_errors: u64,
|
||||
strict_validation_failures: u64,
|
||||
dqbuf_ioctl_errors: u64,
|
||||
dqbuf_flag_errors: u64,
|
||||
qbuf_ioctl_errors: u64,
|
||||
latest_bytes: usize,
|
||||
last_rejected_oversize_bytes: usize,
|
||||
last_rejected_oversize_cap: usize,
|
||||
@ -212,6 +227,8 @@ impl UvcVideoStream {
|
||||
next_queue_at: None,
|
||||
transport_bulk: false,
|
||||
max_packet: 0,
|
||||
has_verified_frame: false,
|
||||
last_verified_mtime: None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -224,7 +241,7 @@ impl UvcVideoStream {
|
||||
let stale = frame_spool_is_stale(&self.frame_path, frame_spool_max_age());
|
||||
if stale {
|
||||
self.stats.replayed_stale += 1;
|
||||
self.replace_latest_with_idle();
|
||||
self.hold_last_good_or_idle();
|
||||
return;
|
||||
}
|
||||
let max_frame_bytes = self.frame_payload_limit();
|
||||
@ -233,6 +250,8 @@ impl UvcVideoStream {
|
||||
self.stats.reloaded += 1;
|
||||
self.stats.latest_bytes = frame.len();
|
||||
self.latest_frame = frame;
|
||||
self.has_verified_frame = true;
|
||||
self.last_verified_mtime = frame_spool_modified(&self.frame_path);
|
||||
} else {
|
||||
if frame.len() > max_frame_bytes && looks_like_mjpeg_frame(&frame) {
|
||||
self.stats.rejected_oversize += 1;
|
||||
@ -241,16 +260,25 @@ impl UvcVideoStream {
|
||||
} else {
|
||||
self.stats.rejected_invalid += 1;
|
||||
}
|
||||
self.replace_latest_with_idle();
|
||||
self.hold_last_good_or_idle();
|
||||
}
|
||||
} else {
|
||||
self.replace_latest_with_idle();
|
||||
self.stats.read_errors += 1;
|
||||
self.hold_last_good_or_idle();
|
||||
}
|
||||
if !looks_like_mjpeg_frame(&self.latest_frame) {
|
||||
self.replace_latest_with_idle();
|
||||
}
|
||||
}
|
||||
|
||||
fn hold_last_good_or_idle(&mut self) {
|
||||
if self.has_verified_frame && !verified_frame_idle_timeout_elapsed(self.last_verified_mtime) {
|
||||
self.stats.held_last_good += 1;
|
||||
} else {
|
||||
self.replace_latest_with_idle();
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_latest_with_idle(&mut self) {
|
||||
if self.latest_frame.as_slice() != IDLE_MJPEG_FRAME {
|
||||
self.stats.fallback_idle += 1;
|
||||
|
||||
@ -38,23 +38,43 @@ fn write_atomic_text(path: &std::path::Path, text: &str) -> Result<()> {
|
||||
/// Inputs: current counters and frame cap. Output: one JSON object string. Why:
|
||||
/// shell probes need stable fields without depending on Rust serialization.
|
||||
fn uvc_stats_snapshot_json(stats: &UvcVideoStats, frame_cap: usize) -> String {
|
||||
let kernel = uvc_kernel_stats_json();
|
||||
format!(
|
||||
"{{\"queued\":{},\"reloaded\":{},\"stale_replay\":{},\"rejected_oversize\":{},\"rejected_invalid\":{},\"fallback_idle\":{},\"latest_bytes\":{},\"frame_cap\":{},\"last_rejected_oversize_bytes\":{},\"last_rejected_oversize_cap\":{},\"paced_sleeps\":{},\"paced_sleep_ms\":{}}}\n",
|
||||
"{{\"generated_unix_ms\":0,\"queued\":{},\"reloaded\":{},\"stale_replay\":{},\"rejected_oversize\":{},\"rejected_invalid\":{},\"fallback_idle\":{},\"held_last_good\":{},\"read_errors\":{},\"strict_validation_failures\":{},\"dqbuf_ioctl_errors\":{},\"dqbuf_flag_errors\":{},\"qbuf_ioctl_errors\":{},\"latest_bytes\":{},\"frame_cap\":{},\"last_rejected_oversize_bytes\":{},\"last_rejected_oversize_cap\":{},\"paced_sleeps\":{},\"paced_sleep_ms\":{},\"kernel\":{}}}\n",
|
||||
stats.queued,
|
||||
stats.reloaded,
|
||||
stats.replayed_stale,
|
||||
stats.rejected_oversize,
|
||||
stats.rejected_invalid,
|
||||
stats.fallback_idle,
|
||||
stats.held_last_good,
|
||||
stats.read_errors,
|
||||
stats.strict_validation_failures,
|
||||
stats.dqbuf_ioctl_errors,
|
||||
stats.dqbuf_flag_errors,
|
||||
stats.qbuf_ioctl_errors,
|
||||
stats.latest_bytes,
|
||||
frame_cap,
|
||||
stats.last_rejected_oversize_bytes,
|
||||
stats.last_rejected_oversize_cap,
|
||||
stats.paced_sleeps,
|
||||
stats.paced_sleep_ms
|
||||
stats.paced_sleep_ms,
|
||||
kernel
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
fn uvc_kernel_stats_json() -> String {
|
||||
let path = std::env::var("LESAVKA_UVC_KERNEL_STATS_PATH")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|_| std::path::PathBuf::from(DEFAULT_UVC_KERNEL_STATS_PATH));
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|value| serde_json::from_str::<serde_json::Value>(&value).ok())
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_else(|| "null".to_string())
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
/// Returns the optional periodic stats write interval.
|
||||
///
|
||||
@ -104,3 +124,26 @@ fn frame_spool_is_stale(path: &std::path::Path, max_age: Option<std::time::Durat
|
||||
.map(|age| age > max_age)
|
||||
.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)
|
||||
}
|
||||
|
||||
@ -91,7 +91,7 @@ fn uvc_frame_max_bytes_defaults_to_freshness_budget_and_allows_override() {
|
||||
("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", Some("1")),
|
||||
],
|
||||
|| {
|
||||
assert_eq!(uvc_frame_max_bytes(sample_cfg()), 123_456);
|
||||
assert_eq!(uvc_frame_max_bytes(sample_cfg()), 65_536);
|
||||
},
|
||||
);
|
||||
|
||||
@ -105,7 +105,7 @@ fn uvc_frame_max_bytes_defaults_to_freshness_budget_and_allows_override() {
|
||||
("LESAVKA_UVC_FRAME_MAX_BYTES", Some("123456")),
|
||||
],
|
||||
|| {
|
||||
assert_eq!(uvc_frame_max_bytes(sample_cfg()), MAX_MJPEG_FRAME_BYTES);
|
||||
assert_eq!(uvc_frame_max_bytes(sample_cfg()), 123_456);
|
||||
},
|
||||
);
|
||||
|
||||
@ -159,7 +159,9 @@ fn uvc_env_flags_and_bulk_configfs_edges_are_covered() {
|
||||
|
||||
let configfs = tempfile::tempdir().expect("tempdir");
|
||||
assert!(!uvc_bulk_transfer_enabled_for_base(configfs.path()));
|
||||
fs::create_dir(configfs.path().join("streaming_bulk")).expect("streaming_bulk dir");
|
||||
fs::write(configfs.path().join("streaming_bulk"), "0\n").expect("streaming_bulk zero");
|
||||
assert!(!uvc_bulk_transfer_enabled_for_base(configfs.path()));
|
||||
fs::write(configfs.path().join("streaming_bulk"), "1\n").expect("streaming_bulk one");
|
||||
assert!(uvc_bulk_transfer_enabled_for_base(configfs.path()));
|
||||
}
|
||||
|
||||
@ -243,6 +245,50 @@ fn uvc_video_stream_refresh_covers_fresh_invalid_and_missing_frames() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uvc_video_stream_holds_last_good_frame_during_transient_rejection() {
|
||||
let frame = NamedTempFile::new().expect("frame");
|
||||
let good = vec![0xff, 0xd8, 1, 2, 3, 0xff, 0xd9];
|
||||
fs::write(frame.path(), &good).expect("write good frame");
|
||||
let mut stream = UvcVideoStream::new(-1);
|
||||
stream.frame_path = frame.path().to_path_buf();
|
||||
stream.frame_max_bytes = 8;
|
||||
stream.refresh_latest_frame();
|
||||
assert_eq!(stream.latest_frame, good);
|
||||
|
||||
fs::write(
|
||||
frame.path(),
|
||||
[0xff, 0xd8, 1, 2, 3, 4, 5, 6, 7, 8, 0xff, 0xd9],
|
||||
)
|
||||
.expect("write oversized frame");
|
||||
stream.refresh_latest_frame();
|
||||
|
||||
assert_eq!(stream.latest_frame, good);
|
||||
assert_eq!(stream.stats.rejected_oversize, 1);
|
||||
assert_eq!(stream.stats.held_last_good, 1);
|
||||
assert_eq!(stream.stats.fallback_idle, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uvc_video_stream_uses_idle_only_after_last_good_timeout() {
|
||||
let frame = NamedTempFile::new().expect("frame");
|
||||
let good = vec![0xff, 0xd8, 1, 2, 3, 0xff, 0xd9];
|
||||
fs::write(frame.path(), &good).expect("write good frame");
|
||||
let mut stream = UvcVideoStream::new(-1);
|
||||
stream.frame_path = frame.path().to_path_buf();
|
||||
stream.refresh_latest_frame();
|
||||
assert_eq!(stream.latest_frame, good);
|
||||
|
||||
stream.last_verified_mtime = Some(std::time::SystemTime::UNIX_EPOCH);
|
||||
fs::write(frame.path(), b"truncated").expect("write invalid frame");
|
||||
stream.refresh_latest_frame();
|
||||
|
||||
assert_eq!(stream.stats.rejected_invalid, 1);
|
||||
assert_eq!(stream.stats.held_last_good, 0);
|
||||
assert_eq!(stream.stats.fallback_idle, 1);
|
||||
assert_eq!(stream.latest_frame, IDLE_MJPEG_FRAME);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn main_coverage_mode_returns_error_for_non_uvc_node() {
|
||||
|
||||
@ -20,6 +20,7 @@ pub mod paste;
|
||||
pub mod runtime_support;
|
||||
pub mod security;
|
||||
pub mod upstream_media_runtime;
|
||||
pub mod uvc_contract;
|
||||
pub mod uvc_runtime;
|
||||
pub mod video;
|
||||
pub(crate) mod video_sinks;
|
||||
|
||||
@ -39,7 +39,7 @@ use lesavka_server::{
|
||||
PlannedUpstreamPacket, UpstreamClientTiming, UpstreamMediaKind, UpstreamMediaRuntime,
|
||||
UpstreamPlanDecision,
|
||||
},
|
||||
uvc_runtime, video,
|
||||
uvc_contract, uvc_runtime, video,
|
||||
};
|
||||
|
||||
/*──────────────── constants ────────────────*/
|
||||
|
||||
@ -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 {
|
||||
/// Reopen HID handles and verify enumeration without cycling the USB gadget.
|
||||
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.
|
||||
async fn get_upstream_sync_reply(&self) -> Result<Response<UpstreamSyncState>, Status> {
|
||||
let snapshot = self.upstream_media_rt.snapshot();
|
||||
let contract_path = uvc_contract::contract_path();
|
||||
let contract = uvc_contract::read_contract(&contract_path);
|
||||
let (
|
||||
uvc_contract_status,
|
||||
uvc_transport,
|
||||
uvc_enforced_frame_cap_bytes,
|
||||
uvc_advertised_frame_bytes,
|
||||
uvc_committed_frame_bytes,
|
||||
uvc_committed_payload_bytes,
|
||||
uvc_contract_divergent,
|
||||
uvc_contract_detail,
|
||||
uvc_contract_generated_unix_ms,
|
||||
) = match contract {
|
||||
Ok(contract) => (
|
||||
if !contract.configfs_available {
|
||||
"missing_configfs"
|
||||
} else if !contract.commit_observed {
|
||||
"unverified"
|
||||
} else if contract.divergent {
|
||||
"divergent"
|
||||
} else {
|
||||
"ready"
|
||||
}
|
||||
.to_string(),
|
||||
contract.transport,
|
||||
Some(contract.enforced_frame_cap_bytes),
|
||||
Some(contract.advertised_frame_bytes),
|
||||
Some(contract.committed_frame_bytes),
|
||||
Some(contract.committed_payload_bytes),
|
||||
contract.divergent,
|
||||
contract.detail,
|
||||
Some(contract.generated_unix_ms),
|
||||
),
|
||||
Err(err) => (
|
||||
"missing_or_invalid".to_string(),
|
||||
"unknown".to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
format!("{}: {err}", contract_path.display()),
|
||||
None,
|
||||
),
|
||||
};
|
||||
let uvc_contract_read_stats = uvc_contract::contract_read_stats();
|
||||
let uvc_integrity = read_uvc_integrity_snapshot();
|
||||
Ok(Response::new(UpstreamSyncState {
|
||||
session_id: snapshot.session_id,
|
||||
phase: snapshot.phase.to_string(),
|
||||
@ -237,6 +415,30 @@ impl Handler {
|
||||
.map(|value| value as f32),
|
||||
client_timing_window_samples: snapshot.client_timing_window_samples,
|
||||
sink_handoff_window_samples: snapshot.sink_handoff_window_samples,
|
||||
uvc_contract_status,
|
||||
uvc_transport,
|
||||
uvc_enforced_frame_cap_bytes,
|
||||
uvc_advertised_frame_bytes,
|
||||
uvc_committed_frame_bytes,
|
||||
uvc_committed_payload_bytes,
|
||||
uvc_contract_divergent,
|
||||
uvc_contract_detail,
|
||||
uvc_contract_generated_unix_ms,
|
||||
uvc_contract_missing_reads: uvc_contract_read_stats.missing_or_invalid,
|
||||
uvc_contract_divergent_reads: uvc_contract_read_stats.divergent,
|
||||
uvc_integrity_status: uvc_integrity.status,
|
||||
uvc_integrity_detail: uvc_integrity.detail,
|
||||
uvc_rejected_oversize: uvc_integrity.rejected_oversize,
|
||||
uvc_rejected_invalid: uvc_integrity.rejected_invalid,
|
||||
uvc_fallback_idle: uvc_integrity.fallback_idle,
|
||||
uvc_held_last_good: uvc_integrity.held_last_good,
|
||||
uvc_strict_validation_failures: uvc_integrity.strict_validation_failures,
|
||||
uvc_dqbuf_errors: uvc_integrity.dqbuf_errors,
|
||||
uvc_qbuf_errors: uvc_integrity.qbuf_errors,
|
||||
uvc_kernel_errors: uvc_integrity.kernel_errors,
|
||||
uvc_integrity_generated_unix_ms: uvc_integrity.generated_unix_ms,
|
||||
uvc_stale_replay: uvc_integrity.stale_replay,
|
||||
uvc_read_errors: uvc_integrity.read_errors,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
566
server/src/uvc_contract.rs
Normal file
566
server/src/uvc_contract.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
@ -6,16 +6,20 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use gstreamer as gst;
|
||||
use gstreamer_app as gst_app;
|
||||
#[cfg(not(coverage))]
|
||||
use crate::uvc_contract::{
|
||||
self, DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT, DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC,
|
||||
};
|
||||
#[cfg(coverage)]
|
||||
use lesavka_server::uvc_contract::{
|
||||
self, DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT, DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC,
|
||||
};
|
||||
|
||||
#[path = "mjpeg_spool/audit.rs"]
|
||||
mod audit;
|
||||
|
||||
static SPOOL_SEQUENCE: AtomicU64 = AtomicU64::new(1);
|
||||
static SPOOL_TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(1);
|
||||
const MAX_MJPEG_FRAME_BYTES: usize = 8 * 1024 * 1024;
|
||||
const DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC: u32 = 4_500_000;
|
||||
const DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT: u32 = 85;
|
||||
const HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC: u32 = 8_000;
|
||||
const CONFIGFS_UVC_BASE: &str = "/sys/kernel/config/usb_gadget/lesavka/functions/uvc.usb0";
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@ -173,11 +177,11 @@ fn read_u32_file(path: impl AsRef<Path>) -> Option<u32> {
|
||||
}
|
||||
|
||||
fn uvc_bulk_transfer_enabled() -> bool {
|
||||
if !env_flag_enabled("LESAVKA_UVC_BULK", true) {
|
||||
return false;
|
||||
}
|
||||
let base = Path::new(CONFIGFS_UVC_BASE);
|
||||
!base.exists() || base.join("streaming_bulk").exists()
|
||||
uvc_contract::bulk_enabled_from_configfs(
|
||||
base,
|
||||
env_flag_enabled("LESAVKA_UVC_BULK", true),
|
||||
)
|
||||
}
|
||||
|
||||
fn uvc_streaming_maxpacket(bulk: bool) -> u32 {
|
||||
@ -192,28 +196,15 @@ fn uvc_streaming_maxpacket(bulk: bool) -> u32 {
|
||||
}
|
||||
}
|
||||
|
||||
fn uvc_isochronous_budget_bytes_per_sec(maxpacket: u32) -> u32 {
|
||||
let pct = env_u32_opt("LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT")
|
||||
.unwrap_or(DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT)
|
||||
.clamp(1, 100);
|
||||
let bytes = u64::from(maxpacket)
|
||||
.saturating_mul(u64::from(HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC))
|
||||
.saturating_mul(u64::from(pct))
|
||||
/ 100;
|
||||
bytes.min(u64::from(u32::MAX)) as u32
|
||||
}
|
||||
|
||||
fn effective_mjpeg_budget_bytes_per_sec() -> u32 {
|
||||
let configured = env_u32_opt("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC")
|
||||
.unwrap_or(DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC)
|
||||
.max(1);
|
||||
if uvc_bulk_transfer_enabled() {
|
||||
configured
|
||||
} else {
|
||||
configured
|
||||
.min(uvc_isochronous_budget_bytes_per_sec(uvc_streaming_maxpacket(false)))
|
||||
.max(1)
|
||||
}
|
||||
let bulk = uvc_bulk_transfer_enabled();
|
||||
let maxpacket = uvc_streaming_maxpacket(bulk);
|
||||
let pct = env_u32_opt("LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT")
|
||||
.unwrap_or(DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT);
|
||||
uvc_contract::effective_mjpeg_budget_bytes_per_sec(configured, maxpacket, bulk, pct)
|
||||
}
|
||||
|
||||
/// Resolve the MJPEG byte budget used before publishing to the helper.
|
||||
@ -224,19 +215,62 @@ fn effective_mjpeg_budget_bytes_per_sec() -> u32 {
|
||||
/// Why: oversized MJPEG frames are a common source of host-visible UVC tearing;
|
||||
/// a short freeze is better than letting the USB gadget emit partial pictures.
|
||||
pub(super) fn mjpeg_spool_frame_max_bytes(fps: u32) -> usize {
|
||||
if !env_flag_enabled("LESAVKA_UVC_FRAME_SIZE_GUARD", true) {
|
||||
return MAX_MJPEG_FRAME_BYTES;
|
||||
}
|
||||
if let Some(limit) = env_u32_opt("LESAVKA_UVC_FRAME_MAX_BYTES")
|
||||
&& limit > 0
|
||||
{
|
||||
return (limit as usize).min(MAX_MJPEG_FRAME_BYTES);
|
||||
let path = uvc_contract::contract_path();
|
||||
match uvc_contract::read_contract(&path) {
|
||||
Ok(contract) => {
|
||||
if contract.divergent {
|
||||
let count = uvc_contract::record_divergent_contract_read();
|
||||
warn_contract_issue(
|
||||
count,
|
||||
"divergent",
|
||||
&format!("{} ({})", path.display(), contract.detail),
|
||||
);
|
||||
}
|
||||
return contract.enforced_frame_cap_bytes as usize;
|
||||
}
|
||||
Err(err) => {
|
||||
let count = uvc_contract::record_missing_contract_read();
|
||||
warn_contract_issue(
|
||||
count,
|
||||
"missing_or_invalid",
|
||||
&format!("{} ({err})", path.display()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let fps = fps.max(1);
|
||||
let budget_per_sec = effective_mjpeg_budget_bytes_per_sec();
|
||||
let per_frame = (budget_per_sec / fps).max(64 * 1024);
|
||||
per_frame.min(MAX_MJPEG_FRAME_BYTES as u32) as usize
|
||||
let requested = env_u32_opt("LESAVKA_UVC_FRAME_MAX_BYTES");
|
||||
let advertised = live_advertised_frame_bytes().unwrap_or(0);
|
||||
uvc_contract::clamp_frame_cap(requested, advertised, budget_per_sec, fps) as usize
|
||||
}
|
||||
|
||||
fn live_advertised_frame_bytes() -> Option<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.
|
||||
|
||||
@ -55,7 +55,7 @@ fn mjpeg_spool_frame_budget_uses_live_budget_when_zero_or_unset() {
|
||||
("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", Some("9")),
|
||||
],
|
||||
|| {
|
||||
assert_eq!(super::mjpeg_spool_frame_max_bytes(30), 65_536);
|
||||
assert_eq!(super::mjpeg_spool_frame_max_bytes(30), 1);
|
||||
},
|
||||
);
|
||||
|
||||
@ -75,7 +75,7 @@ fn mjpeg_spool_frame_budget_uses_live_budget_when_zero_or_unset() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mjpeg_spool_frame_budget_allows_explicit_limit_or_diagnostic_disable() {
|
||||
fn mjpeg_spool_frame_budget_clamps_explicit_limit_and_diagnostic_flags() {
|
||||
temp_env::with_vars(
|
||||
[
|
||||
("LESAVKA_UVC_FRAME_SIZE_GUARD", Some("1")),
|
||||
@ -83,7 +83,7 @@ fn mjpeg_spool_frame_budget_allows_explicit_limit_or_diagnostic_disable() {
|
||||
("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", Some("1")),
|
||||
],
|
||||
|| {
|
||||
assert_eq!(super::mjpeg_spool_frame_max_bytes(30), 123_456);
|
||||
assert_eq!(super::mjpeg_spool_frame_max_bytes(30), 1);
|
||||
},
|
||||
);
|
||||
|
||||
@ -94,10 +94,7 @@ fn mjpeg_spool_frame_budget_allows_explicit_limit_or_diagnostic_disable() {
|
||||
("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", Some("1")),
|
||||
],
|
||||
|| {
|
||||
assert_eq!(
|
||||
super::mjpeg_spool_frame_max_bytes(30),
|
||||
super::MAX_MJPEG_FRAME_BYTES
|
||||
);
|
||||
assert_eq!(super::mjpeg_spool_frame_max_bytes(30), 1);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -10,6 +10,8 @@ use super::mjpeg_spool::freshest_mjpeg_sample;
|
||||
use super::mjpeg_spool::{MjpegSpoolTiming, spool_mjpeg_frame_with_timing};
|
||||
use super::*;
|
||||
use crate::video_support::{contains_hevc_irap, reserve_local_pts};
|
||||
#[cfg(not(coverage))]
|
||||
use crate::uvc_contract::{add_lesavka_app4_integrity, lesavka_app4_sequence};
|
||||
|
||||
impl WebcamSink {
|
||||
/// Push one client frame into the UVC pipeline.
|
||||
@ -344,7 +346,9 @@ impl WebcamSink {
|
||||
self.spool_guarded_passthrough_direct_mjpeg_frame(path, pkt);
|
||||
return;
|
||||
};
|
||||
let normalized = map.as_slice();
|
||||
let normalized_marked = lesavka_app4_sequence(&pkt.data)
|
||||
.and_then(|sequence| add_lesavka_app4_integrity(map.as_slice(), sequence));
|
||||
let normalized = normalized_marked.as_deref().unwrap_or_else(|| map.as_slice());
|
||||
let previous_bytes = self.last_mjpeg_passthrough_bytes.load(Ordering::Relaxed);
|
||||
if let Some(reason) = hevc_mjpeg_guard::direct_mjpeg_reject_reason(
|
||||
previous_bytes,
|
||||
|
||||
@ -110,7 +110,10 @@ fn core_script_keeps_uvc_output_on_supported_mjpeg_descriptor() {
|
||||
"UVC_ISOCHRONOUS_LIMIT_PCT=${LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT:-85}",
|
||||
"uvc_isochronous_budget_bytes_per_sec()",
|
||||
"isoch_budget=\"$(uvc_isochronous_budget_bytes_per_sec)\"",
|
||||
"UVC_FRAME_SIZE=\"$(uvc_mjpeg_frame_size_for_fps \"$UVC_FPS\")\"",
|
||||
"UVC_DERIVED_FRAME_SIZE=\"$(uvc_mjpeg_frame_size_for_fps \"$UVC_FPS\")\"",
|
||||
"clamping requested UVC frame size $UVC_FRAME_SIZE -> $UVC_DERIVED_FRAME_SIZE (wire budget)",
|
||||
"UVC bulk request did not stick; using isochronous descriptors",
|
||||
"UVC bulk readback diverged after descriptor setup; falling back to isochronous sizing",
|
||||
"write_active_mjpeg_frame_descriptor()",
|
||||
"if flag_enabled \"$UVC_ADVERTISE_EXTRA_MODES\"; then",
|
||||
"write_mjpeg_frame_descriptor 1080p 1920 1080",
|
||||
|
||||
@ -431,10 +431,12 @@ fn server_install_pins_hdmi_camera_and_display_defaults() {
|
||||
assert!(
|
||||
SERVER_INSTALL.contains(
|
||||
"updated UVC env matches the live descriptor, restarting lesavka-server only"
|
||||
) && SERVER_INSTALL.contains(
|
||||
"Preserving lesavka-core and lesavka-uvc so the attached USB gadget is not cycled"
|
||||
),
|
||||
"safe env repairs should restart only the server process, not the attached gadget path"
|
||||
) && SERVER_INSTALL
|
||||
.contains("Preserving lesavka-core so the attached USB gadget is not cycled")
|
||||
&& SERVER_INSTALL.contains(
|
||||
"set LESAVKA_INSTALL_RESTART_UVC_HELPER=1 when a helper-only refresh is required"
|
||||
),
|
||||
"safe env repairs should preserve the gadget and require an explicit helper-only refresh"
|
||||
);
|
||||
assert!(
|
||||
SERVER_INSTALL.contains("dwDefaultFrameInterval"),
|
||||
@ -470,7 +472,17 @@ fn server_install_pins_hdmi_camera_and_display_defaults() {
|
||||
< SERVER_INSTALL
|
||||
.rfind("restart_lesavka_uvc_helper_only")
|
||||
.unwrap(),
|
||||
"safe env repair must exit before the later version-update helper refresh path"
|
||||
"safe env repair must remain separate from the later version-update helper refresh path"
|
||||
);
|
||||
assert!(
|
||||
SERVER_INSTALL.contains("install_uvc_kernel_watch_unit")
|
||||
&& SERVER_INSTALL
|
||||
.find("\ninstall_uvc_kernel_watch_unit\n")
|
||||
.unwrap()
|
||||
< SERVER_INSTALL
|
||||
.find("if [[ \"$ATTACHED_UVC_RESTART_DEFERRED\" == \"1\" ]]")
|
||||
.unwrap(),
|
||||
"kernel UVC telemetry must be installed even when an attached-gadget branch exits early"
|
||||
);
|
||||
assert!(
|
||||
SERVER_INSTALL
|
||||
|
||||
@ -315,7 +315,7 @@ mod uvc_binary {
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn uvc_defaults_to_reliable_bulk_and_budgeted_mjpeg_frame_size() {
|
||||
fn uvc_missing_configfs_falls_back_to_isochronous_budget() {
|
||||
with_vars(
|
||||
[
|
||||
("LESAVKA_UVC_WIDTH", Some("1280")),
|
||||
@ -331,10 +331,10 @@ mod uvc_binary {
|
||||
|| {
|
||||
let cfg = UvcConfig::from_env();
|
||||
assert_eq!(cfg.interval, 10_000_000 / 30);
|
||||
assert_eq!(cfg.frame_size, 300_000);
|
||||
assert_eq!(cfg.max_packet, 512);
|
||||
assert!(cfg.bulk);
|
||||
assert!(uvc_bulk_transfer_enabled());
|
||||
assert_eq!(cfg.frame_size, 232_106);
|
||||
assert_eq!(cfg.max_packet, 1024);
|
||||
assert!(!cfg.bulk);
|
||||
assert!(!uvc_bulk_transfer_enabled());
|
||||
},
|
||||
);
|
||||
|
||||
@ -372,8 +372,8 @@ mod uvc_binary {
|
||||
fn uvc_bulk_mode_is_tied_to_live_configfs_support() {
|
||||
let source = include_str!("../../../../server/src/bin/lesavka-uvc.real.inc");
|
||||
|
||||
assert!(source.contains("base.exists() && !base.join(\"streaming_bulk\").exists()"));
|
||||
assert!(source.contains("using isochronous payload sizing"));
|
||||
assert!(source.contains("bulk_enabled_from_configfs"));
|
||||
assert!(source.contains("streaming_bulk is not 1; using isochronous payload sizing"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -386,6 +386,12 @@ mod uvc_binary {
|
||||
rejected_oversize: 1,
|
||||
rejected_invalid: 3,
|
||||
fallback_idle: 4,
|
||||
held_last_good: 5,
|
||||
read_errors: 6,
|
||||
strict_validation_failures: 7,
|
||||
dqbuf_ioctl_errors: 8,
|
||||
dqbuf_flag_errors: 9,
|
||||
qbuf_ioctl_errors: 10,
|
||||
latest_bytes: 77_036,
|
||||
last_rejected_oversize_bytes: 300_001,
|
||||
last_rejected_oversize_cap: 300_000,
|
||||
@ -402,6 +408,21 @@ mod uvc_binary {
|
||||
assert!(json.contains("\"paced_sleeps\":5"));
|
||||
assert!(json.contains("\"paced_sleep_ms\":123"));
|
||||
|
||||
let kernel_path = tempfile::NamedTempFile::new().expect("kernel stats");
|
||||
fs::write(
|
||||
kernel_path.path(),
|
||||
r#"{"schema":"lesavka.uvc-kernel-watch.v1","counters":{"dwc2_errors":2}}"#,
|
||||
)
|
||||
.expect("write kernel stats");
|
||||
with_var(
|
||||
"LESAVKA_UVC_KERNEL_STATS_PATH",
|
||||
Some(kernel_path.path().to_str().expect("kernel stats path")),
|
||||
|| {
|
||||
let json = uvc_stats_snapshot_json(&stats, 333_333);
|
||||
assert!(json.contains("\"kernel\":{\"counters\":{\"dwc2_errors\":2}"));
|
||||
},
|
||||
);
|
||||
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("uvc").join("stats.json");
|
||||
write_atomic_text(&path, &json).expect("write stats");
|
||||
@ -454,8 +475,10 @@ mod uvc_binary {
|
||||
height: 480,
|
||||
default_interval: 333_333,
|
||||
frame_interval: 333_333,
|
||||
frame_size: 150_000,
|
||||
maxpacket: 1024,
|
||||
maxburst: 0,
|
||||
streaming_bulk: Some(0),
|
||||
});
|
||||
log_configfs_snapshot(&mut state, "contract");
|
||||
assert!(state.cfg_snapshot.is_some());
|
||||
|
||||
@ -15,6 +15,10 @@ mod video_support {
|
||||
pub use lesavka_server::video_support::*;
|
||||
}
|
||||
|
||||
mod uvc_contract {
|
||||
pub use lesavka_server::uvc_contract::*;
|
||||
}
|
||||
|
||||
#[path = "../../../../server/src/media_timing.rs"]
|
||||
#[allow(warnings)]
|
||||
mod media_timing;
|
||||
|
||||
@ -10,6 +10,10 @@ use std::fs;
|
||||
|
||||
use serial_test::serial;
|
||||
|
||||
mod uvc_contract {
|
||||
pub use lesavka_server::uvc_contract::*;
|
||||
}
|
||||
|
||||
mod video_support {
|
||||
pub fn env_u32(name: &str, default: u32) -> u32 {
|
||||
std::env::var(name)
|
||||
|
||||
@ -58,6 +58,7 @@ fn synthetic_probe_keeps_bundled_network_ingress_and_rct_comparison_markers() {
|
||||
"--server-uvc-audit-dir",
|
||||
"--server-uvc-audit-sample-frames",
|
||||
"--stream-analyze",
|
||||
"--deep-capture",
|
||||
"--sequence-window",
|
||||
"--mix-mae-threshold",
|
||||
"--mix-improvement",
|
||||
@ -138,6 +139,11 @@ fn synthetic_probe_keeps_bundled_network_ingress_and_rct_comparison_markers() {
|
||||
"ffmpeg",
|
||||
"v4l2",
|
||||
"x11grab",
|
||||
"capture.mjpg",
|
||||
"usbmon.pcapng",
|
||||
"uvcvideo-dynamic-debug",
|
||||
"power/autosuspend",
|
||||
"analyze_marked_capture.py",
|
||||
] {
|
||||
assert!(
|
||||
PROBE_SRC.contains(expected),
|
||||
@ -157,6 +163,8 @@ fn synthetic_injector_enters_the_public_bundled_media_rpc() {
|
||||
"silence_pcm(args.frame_step_us())",
|
||||
"synthetic_rgb_frame",
|
||||
"draw_sequence_marker",
|
||||
"synthetic_band_marker_luma",
|
||||
"add_jpeg_app4_integrity",
|
||||
"image/jpeg",
|
||||
"jpegenc",
|
||||
"client_capture_pts_us: pts_us",
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user