ci(lesavka): split uvc coverage startup ratchet
This commit is contained in:
parent
92b9aeecbd
commit
0d5284c9a2
@ -963,7 +963,7 @@
|
||||
"server/src/bin/lesavka-uvc.rs": {
|
||||
"clippy_warnings": 0,
|
||||
"doc_debt": 0,
|
||||
"loc": 19
|
||||
"loc": 25
|
||||
},
|
||||
"server/src/bin/lesavka_synthetic_uplink/support.rs": {
|
||||
"clippy_warnings": 0,
|
||||
@ -985,10 +985,25 @@
|
||||
"doc_debt": 0,
|
||||
"loc": 289
|
||||
},
|
||||
"server/src/bin/lesavka_uvc/coverage_config.rs": {
|
||||
"clippy_warnings": 0,
|
||||
"doc_debt": 0,
|
||||
"loc": 145
|
||||
},
|
||||
"server/src/bin/lesavka_uvc/coverage_frame_budget.rs": {
|
||||
"clippy_warnings": 0,
|
||||
"doc_debt": 0,
|
||||
"loc": 127
|
||||
},
|
||||
"server/src/bin/lesavka_uvc/coverage_startup.rs": {
|
||||
"clippy_warnings": 0,
|
||||
"doc_debt": 0,
|
||||
"loc": 447
|
||||
"loc": 74
|
||||
},
|
||||
"server/src/bin/lesavka_uvc/coverage_stats.rs": {
|
||||
"clippy_warnings": 0,
|
||||
"doc_debt": 0,
|
||||
"loc": 106
|
||||
},
|
||||
"server/src/bin/lesavka_uvc/payload_limits.rs": {
|
||||
"clippy_warnings": 0,
|
||||
|
||||
@ -360,9 +360,25 @@
|
||||
"line_percent": 100.0,
|
||||
"loc": 162
|
||||
},
|
||||
"server/src/bin/lesavka_uvc/coverage_config.rs": {
|
||||
"line_percent": 100.0,
|
||||
"loc": 145
|
||||
},
|
||||
"server/src/bin/lesavka_uvc/coverage_frame_budget.rs": {
|
||||
"line_percent": 100.0,
|
||||
"loc": 127
|
||||
},
|
||||
"server/src/bin/lesavka_uvc/coverage_model.rs": {
|
||||
"line_percent": 96.97,
|
||||
"loc": 289
|
||||
},
|
||||
"server/src/bin/lesavka_uvc/coverage_startup.rs": {
|
||||
"line_percent": 98.48,
|
||||
"loc": 203
|
||||
"line_percent": 100.0,
|
||||
"loc": 74
|
||||
},
|
||||
"server/src/bin/lesavka_uvc/coverage_stats.rs": {
|
||||
"line_percent": 95.65,
|
||||
"loc": 106
|
||||
},
|
||||
"server/src/bin/lesavka_uvc/payload_limits.rs": {
|
||||
"line_percent": 100.0,
|
||||
|
||||
@ -8,6 +8,12 @@ include!("lesavka_uvc/coverage_model.rs");
|
||||
#[cfg(coverage)]
|
||||
include!("lesavka_uvc/coverage_startup.rs");
|
||||
#[cfg(coverage)]
|
||||
include!("lesavka_uvc/coverage_config.rs");
|
||||
#[cfg(coverage)]
|
||||
include!("lesavka_uvc/coverage_frame_budget.rs");
|
||||
#[cfg(coverage)]
|
||||
include!("lesavka_uvc/coverage_stats.rs");
|
||||
#[cfg(coverage)]
|
||||
include!("lesavka_uvc/control_requests.rs");
|
||||
#[cfg(coverage)]
|
||||
include!("lesavka_uvc/control_payloads.rs");
|
||||
|
||||
145
server/src/bin/lesavka_uvc/coverage_config.rs
Normal file
145
server/src/bin/lesavka_uvc/coverage_config.rs
Normal file
@ -0,0 +1,145 @@
|
||||
#[cfg(coverage)]
|
||||
impl UvcConfig {
|
||||
/// Builds a bounded UVC mode from environment overrides.
|
||||
///
|
||||
/// Inputs: `LESAVKA_UVC_*` mode variables. Output: a packet-safe mode
|
||||
/// snapshot. Why: coverage contracts must pin descriptor math without
|
||||
/// requiring the real kernel gadget.
|
||||
fn from_env() -> Self {
|
||||
let width = env_u32("LESAVKA_UVC_WIDTH", 1280);
|
||||
let height = env_u32("LESAVKA_UVC_HEIGHT", 720);
|
||||
let fps = env_u32("LESAVKA_UVC_FPS", 30).max(1);
|
||||
let interval = env_u32("LESAVKA_UVC_INTERVAL", 0);
|
||||
let bulk = uvc_bulk_transfer_enabled();
|
||||
let mut max_packet = env_u32("LESAVKA_UVC_MAXPACKET", 1024);
|
||||
|
||||
if let Some(limit) = compute_payload_cap(bulk).map(|cap| cap.limit) {
|
||||
max_packet = max_packet.min(limit);
|
||||
}
|
||||
max_packet = if bulk {
|
||||
max_packet.min(512)
|
||||
} else {
|
||||
max_packet.min(1024)
|
||||
};
|
||||
let frame_size = uvc_frame_size_for_active_mode(width, height, fps, max_packet, bulk);
|
||||
|
||||
let interval = if interval == 0 {
|
||||
10_000_000 / fps
|
||||
} else {
|
||||
interval
|
||||
};
|
||||
|
||||
Self {
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
interval,
|
||||
max_packet,
|
||||
frame_size,
|
||||
bulk,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
impl UvcState {
|
||||
/// Creates the control-state mirrors used by UVC request handling tests.
|
||||
///
|
||||
/// Inputs: one validated UVC mode. Output: default/probe/commit buffers.
|
||||
/// Why: tests need deterministic state transitions for host negotiation.
|
||||
fn new(cfg: UvcConfig) -> Self {
|
||||
let ctrl_len = stream_ctrl_len();
|
||||
let default = build_streaming_control(&cfg, ctrl_len);
|
||||
Self {
|
||||
cfg,
|
||||
ctrl_len,
|
||||
default,
|
||||
probe: default,
|
||||
commit: default,
|
||||
cfg_snapshot: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
fn load_interfaces() -> UvcInterfaces {
|
||||
let control = env_u8("LESAVKA_UVC_CTRL_INTF").unwrap_or(UVC_STRING_CONTROL_IDX);
|
||||
let streaming = env_u8("LESAVKA_UVC_STREAM_INTF").unwrap_or(UVC_STRING_STREAMING_IDX);
|
||||
UvcInterfaces { control, streaming }
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
fn read_interface(path: &str) -> Option<u8> {
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<u8>().ok())
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
/// Parses a boolean environment flag with a default fallback.
|
||||
///
|
||||
/// Inputs: variable name and default value. Output: parsed boolean. Why:
|
||||
/// UVC safety switches must handle common true/false spellings consistently.
|
||||
fn env_flag_enabled(name: &str, default: bool) -> bool {
|
||||
env::var(name)
|
||||
.ok()
|
||||
.map(|value| {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.eq_ignore_ascii_case("0")
|
||||
|| trimmed.eq_ignore_ascii_case("false")
|
||||
|| trimmed.eq_ignore_ascii_case("no")
|
||||
|| trimmed.eq_ignore_ascii_case("off")
|
||||
{
|
||||
false
|
||||
} else if trimmed.eq_ignore_ascii_case("1")
|
||||
|| trimmed.eq_ignore_ascii_case("true")
|
||||
|| trimmed.eq_ignore_ascii_case("yes")
|
||||
|| trimmed.eq_ignore_ascii_case("on")
|
||||
{
|
||||
true
|
||||
} else {
|
||||
default
|
||||
}
|
||||
})
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
/// Decides whether the coverage helper may use bulk transport.
|
||||
///
|
||||
/// Inputs: env override plus configfs capabilities. Output: effective mode.
|
||||
/// Why: tests should not advertise bulk if the gadget tree lacks support.
|
||||
fn uvc_bulk_transfer_enabled() -> bool {
|
||||
if !env_flag_enabled("LESAVKA_UVC_BULK", true) {
|
||||
return false;
|
||||
}
|
||||
uvc_bulk_transfer_enabled_for_base(std::path::Path::new(CONFIGFS_UVC_BASE))
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
/// Checks whether a configfs UVC function exposes the bulk-streaming knob.
|
||||
///
|
||||
/// 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
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
fn uvc_frame_size_for_active_mode(
|
||||
width: u32,
|
||||
height: u32,
|
||||
fps: u32,
|
||||
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))
|
||||
}
|
||||
127
server/src/bin/lesavka_uvc/coverage_frame_budget.rs
Normal file
127
server/src/bin/lesavka_uvc/coverage_frame_budget.rs
Normal file
@ -0,0 +1,127 @@
|
||||
#[cfg(coverage)]
|
||||
/// Returns the bounded UVC request-buffer count used by the helper.
|
||||
///
|
||||
/// Inputs: `LESAVKA_UVC_BUFFER_COUNT`. Outputs: a value clamped to the safe
|
||||
/// range accepted by the helper. Why: coverage contracts need the same backlog
|
||||
/// bound as the real UVC binary without opening a gadget device.
|
||||
fn uvc_buffer_count() -> u32 {
|
||||
env_u32("LESAVKA_UVC_BUFFER_COUNT", DEFAULT_UVC_BUFFER_COUNT).clamp(1, 8)
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
/// Returns the idle frame-pump sleep interval used when no fresh frame is ready.
|
||||
///
|
||||
/// Inputs: `LESAVKA_UVC_IDLE_PUMP_MS`. Outputs: a duration in milliseconds.
|
||||
/// Why: this value controls how quickly the UVC helper retries the freshness
|
||||
/// spool when browsers are already streaming.
|
||||
fn uvc_idle_pump_sleep() -> std::time::Duration {
|
||||
std::time::Duration::from_millis(env_u64(
|
||||
"LESAVKA_UVC_IDLE_PUMP_MS",
|
||||
DEFAULT_UVC_IDLE_PUMP_MS,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
/// Returns the frame queue pacing period for the negotiated UVC frame rate.
|
||||
///
|
||||
/// Inputs: `LESAVKA_UVC_QUEUE_PACING` plus the active frame rate. Output:
|
||||
/// `None` when pacing is explicitly disabled. Why: the RCT-facing host must
|
||||
/// not be overfed faster than the advertised descriptor cadence.
|
||||
fn uvc_queue_period(fps: u32) -> Option<std::time::Duration> {
|
||||
if !env_flag_enabled("LESAVKA_UVC_QUEUE_PACING", DEFAULT_UVC_QUEUE_PACING) {
|
||||
return None;
|
||||
}
|
||||
let fps = fps.max(1);
|
||||
Some(std::time::Duration::from_nanos(1_000_000_000 / u64::from(fps)))
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
/// Bound MJPEG frames before they enter the USB UVC helper.
|
||||
///
|
||||
/// Inputs: UVC mode plus optional `LESAVKA_UVC_FRAME_MAX_BYTES` or
|
||||
/// `LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC`. Output: maximum accepted JPEG
|
||||
/// 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 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)
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
fn derived_uvc_frame_max_bytes(cfg: UvcConfig) -> usize {
|
||||
derived_uvc_frame_max_bytes_for_transport(cfg.fps, cfg.max_packet, cfg.bulk)
|
||||
}
|
||||
|
||||
#[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
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
/// Returns the effective MJPEG byte budget for the selected USB transport.
|
||||
///
|
||||
/// Inputs: negotiated max packet size and bulk/isochronous mode. Output: bytes
|
||||
/// per second. Why: frame-size checks should mirror the transport budget.
|
||||
fn effective_uvc_mjpeg_budget_bytes_per_sec(max_packet: u32, bulk: bool) -> u32 {
|
||||
let configured = env_u32(
|
||||
"LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC",
|
||||
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,
|
||||
)
|
||||
.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)]
|
||||
fn uvc_frame_size_guard_enabled() -> bool {
|
||||
env_flag_enabled("LESAVKA_UVC_FRAME_SIZE_GUARD", true)
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
/// Parses an unsigned 64-bit environment value with a fallback.
|
||||
///
|
||||
/// Inputs: variable name and default value. Outputs: parsed value or default.
|
||||
/// Why: UVC freshness settings use millisecond durations that should not be
|
||||
/// truncated to 32-bit just because the coverage harness is lightweight.
|
||||
fn env_u64(name: &str, default: u64) -> u64 {
|
||||
env::var(name)
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
@ -40,83 +40,6 @@ fn parse_device_arg(args: &[String]) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
impl UvcConfig {
|
||||
/// Builds a bounded UVC mode from environment overrides.
|
||||
///
|
||||
/// Inputs: `LESAVKA_UVC_*` mode variables. Output: a packet-safe mode
|
||||
/// snapshot. Why: coverage contracts must pin descriptor math without
|
||||
/// requiring the real kernel gadget.
|
||||
fn from_env() -> Self {
|
||||
let width = env_u32("LESAVKA_UVC_WIDTH", 1280);
|
||||
let height = env_u32("LESAVKA_UVC_HEIGHT", 720);
|
||||
let fps = env_u32("LESAVKA_UVC_FPS", 30).max(1);
|
||||
let interval = env_u32("LESAVKA_UVC_INTERVAL", 0);
|
||||
let bulk = uvc_bulk_transfer_enabled();
|
||||
let mut max_packet = env_u32("LESAVKA_UVC_MAXPACKET", 1024);
|
||||
|
||||
if let Some(limit) = compute_payload_cap(bulk).map(|cap| cap.limit) {
|
||||
max_packet = max_packet.min(limit);
|
||||
}
|
||||
max_packet = if bulk {
|
||||
max_packet.min(512)
|
||||
} else {
|
||||
max_packet.min(1024)
|
||||
};
|
||||
let frame_size = uvc_frame_size_for_active_mode(width, height, fps, max_packet, bulk);
|
||||
|
||||
let interval = if interval == 0 {
|
||||
10_000_000 / fps
|
||||
} else {
|
||||
interval
|
||||
};
|
||||
|
||||
Self {
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
interval,
|
||||
max_packet,
|
||||
frame_size,
|
||||
bulk,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
impl UvcState {
|
||||
/// Creates the control-state mirrors used by UVC request handling tests.
|
||||
///
|
||||
/// Inputs: one validated UVC mode. Output: default/probe/commit buffers.
|
||||
/// Why: tests need deterministic state transitions for host negotiation.
|
||||
fn new(cfg: UvcConfig) -> Self {
|
||||
let ctrl_len = stream_ctrl_len();
|
||||
let default = build_streaming_control(&cfg, ctrl_len);
|
||||
Self {
|
||||
cfg,
|
||||
ctrl_len,
|
||||
default,
|
||||
probe: default,
|
||||
commit: default,
|
||||
cfg_snapshot: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
fn load_interfaces() -> UvcInterfaces {
|
||||
let control = env_u8("LESAVKA_UVC_CTRL_INTF").unwrap_or(UVC_STRING_CONTROL_IDX);
|
||||
let streaming = env_u8("LESAVKA_UVC_STREAM_INTF").unwrap_or(UVC_STRING_STREAMING_IDX);
|
||||
UvcInterfaces { control, streaming }
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
fn read_interface(path: &str) -> Option<u8> {
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<u8>().ok())
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
/// Opens the UVC control device with safe coverage defaults.
|
||||
///
|
||||
@ -149,299 +72,3 @@ fn uvc_control_read_only() -> bool {
|
||||
})
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
/// Returns the bounded UVC request-buffer count used by the helper.
|
||||
///
|
||||
/// Inputs: `LESAVKA_UVC_BUFFER_COUNT`. Outputs: a value clamped to the safe
|
||||
/// range accepted by the helper. Why: coverage contracts need the same backlog
|
||||
/// bound as the real UVC binary without opening a gadget device.
|
||||
fn uvc_buffer_count() -> u32 {
|
||||
env_u32("LESAVKA_UVC_BUFFER_COUNT", DEFAULT_UVC_BUFFER_COUNT).clamp(1, 8)
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
/// Returns the idle frame-pump sleep interval used when no fresh frame is ready.
|
||||
///
|
||||
/// Inputs: `LESAVKA_UVC_IDLE_PUMP_MS`. Outputs: a duration in milliseconds.
|
||||
/// Why: this value controls how quickly the UVC helper retries the freshness
|
||||
/// spool when browsers are already streaming.
|
||||
fn uvc_idle_pump_sleep() -> std::time::Duration {
|
||||
std::time::Duration::from_millis(env_u64(
|
||||
"LESAVKA_UVC_IDLE_PUMP_MS",
|
||||
DEFAULT_UVC_IDLE_PUMP_MS,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
/// Returns the frame queue pacing period for the negotiated UVC frame rate.
|
||||
///
|
||||
/// Inputs: `LESAVKA_UVC_QUEUE_PACING` plus the active frame rate. Output:
|
||||
/// `None` when pacing is explicitly disabled. Why: the RCT-facing host must
|
||||
/// not be overfed faster than the advertised descriptor cadence.
|
||||
fn uvc_queue_period(fps: u32) -> Option<std::time::Duration> {
|
||||
if !env_flag_enabled("LESAVKA_UVC_QUEUE_PACING", DEFAULT_UVC_QUEUE_PACING) {
|
||||
return None;
|
||||
}
|
||||
let fps = fps.max(1);
|
||||
Some(std::time::Duration::from_nanos(1_000_000_000 / u64::from(fps)))
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
/// Bound MJPEG frames before they enter the USB UVC helper.
|
||||
///
|
||||
/// Inputs: UVC mode plus optional `LESAVKA_UVC_FRAME_MAX_BYTES` or
|
||||
/// `LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC`. Output: maximum accepted JPEG
|
||||
/// 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 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)
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
fn derived_uvc_frame_max_bytes(cfg: UvcConfig) -> usize {
|
||||
derived_uvc_frame_max_bytes_for_transport(cfg.fps, cfg.max_packet, cfg.bulk)
|
||||
}
|
||||
|
||||
#[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
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
/// Returns the effective MJPEG byte budget for the selected USB transport.
|
||||
///
|
||||
/// Inputs: negotiated max packet size and bulk/isochronous mode. Output: bytes
|
||||
/// per second. Why: frame-size checks should mirror the transport budget.
|
||||
fn effective_uvc_mjpeg_budget_bytes_per_sec(max_packet: u32, bulk: bool) -> u32 {
|
||||
let configured = env_u32(
|
||||
"LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC",
|
||||
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,
|
||||
)
|
||||
.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)]
|
||||
/// Parses a boolean environment flag with a default fallback.
|
||||
///
|
||||
/// Inputs: variable name and default value. Output: parsed boolean. Why:
|
||||
/// UVC safety switches must handle common true/false spellings consistently.
|
||||
fn env_flag_enabled(name: &str, default: bool) -> bool {
|
||||
env::var(name)
|
||||
.ok()
|
||||
.map(|value| {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.eq_ignore_ascii_case("0")
|
||||
|| trimmed.eq_ignore_ascii_case("false")
|
||||
|| trimmed.eq_ignore_ascii_case("no")
|
||||
|| trimmed.eq_ignore_ascii_case("off")
|
||||
{
|
||||
false
|
||||
} else if trimmed.eq_ignore_ascii_case("1")
|
||||
|| trimmed.eq_ignore_ascii_case("true")
|
||||
|| trimmed.eq_ignore_ascii_case("yes")
|
||||
|| trimmed.eq_ignore_ascii_case("on")
|
||||
{
|
||||
true
|
||||
} else {
|
||||
default
|
||||
}
|
||||
})
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
fn uvc_frame_size_guard_enabled() -> bool {
|
||||
env_flag_enabled("LESAVKA_UVC_FRAME_SIZE_GUARD", true)
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
/// Decides whether the coverage helper may use bulk transport.
|
||||
///
|
||||
/// Inputs: env override plus configfs capabilities. Output: effective mode.
|
||||
/// Why: tests should not advertise bulk if the gadget tree lacks support.
|
||||
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() {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
fn uvc_frame_size_for_active_mode(
|
||||
width: u32,
|
||||
height: u32,
|
||||
fps: u32,
|
||||
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))
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
/// Returns the optional JSON stats path for the UVC coverage helper.
|
||||
///
|
||||
/// Inputs: `LESAVKA_UVC_STATS_PATH`. Output: path or disabled state. Why:
|
||||
/// tests and probes need a cheap health artifact without forcing writes.
|
||||
fn uvc_stats_path() -> Option<std::path::PathBuf> {
|
||||
match std::env::var("LESAVKA_UVC_STATS_PATH") {
|
||||
Ok(value) => {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() || trimmed == "0" {
|
||||
None
|
||||
} else {
|
||||
Some(std::path::PathBuf::from(trimmed))
|
||||
}
|
||||
}
|
||||
Err(_) => Some(std::path::PathBuf::from(DEFAULT_UVC_STATS_PATH)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
/// Writes a text artifact through a temporary file and atomic rename.
|
||||
///
|
||||
/// Inputs: target path and payload. Output: persisted file. Why: readers should
|
||||
/// never observe partially-written UVC stats JSON.
|
||||
fn write_atomic_text(path: &std::path::Path, text: &str) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let tmp = path.with_extension(format!("tmp.{}", std::process::id()));
|
||||
std::fs::write(&tmp, text)?;
|
||||
std::fs::rename(&tmp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
/// Serializes the compact UVC video-stat snapshot consumed by probes.
|
||||
///
|
||||
/// 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 {
|
||||
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",
|
||||
stats.queued,
|
||||
stats.reloaded,
|
||||
stats.replayed_stale,
|
||||
stats.rejected_oversize,
|
||||
stats.rejected_invalid,
|
||||
stats.fallback_idle,
|
||||
stats.latest_bytes,
|
||||
frame_cap,
|
||||
stats.last_rejected_oversize_bytes,
|
||||
stats.last_rejected_oversize_cap,
|
||||
stats.paced_sleeps,
|
||||
stats.paced_sleep_ms
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
/// Returns the optional periodic stats write interval.
|
||||
///
|
||||
/// Inputs: `LESAVKA_UVC_STATS_INTERVAL_MS`. Output: duration or disabled state.
|
||||
/// Why: probes can ask for telemetry without slowing every coverage test.
|
||||
fn uvc_stats_interval() -> Option<std::time::Duration> {
|
||||
match env_u64("LESAVKA_UVC_STATS_INTERVAL_MS", DEFAULT_UVC_STATS_INTERVAL_MS) {
|
||||
0 => None,
|
||||
value => Some(std::time::Duration::from_millis(value)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
/// Returns the optional maximum age for a spooled MJPEG frame.
|
||||
///
|
||||
/// Inputs: `LESAVKA_UVC_FRAME_MAX_AGE_MS`. Outputs: `None` when the TTL is
|
||||
/// disabled. Why: stale-frame replay must be explicit because it trades
|
||||
/// smoothness against freshness during browser stream recovery.
|
||||
fn frame_spool_max_age() -> Option<std::time::Duration> {
|
||||
match env_u64(
|
||||
"LESAVKA_UVC_FRAME_MAX_AGE_MS",
|
||||
DEFAULT_UVC_FRAME_MAX_AGE_MS,
|
||||
) {
|
||||
0 => None,
|
||||
value => Some(std::time::Duration::from_millis(value)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
/// Determines whether a spooled MJPEG frame is too old to replay.
|
||||
///
|
||||
/// Inputs: frame path and optional age limit. Outputs: true when the frame is
|
||||
/// missing or older than the limit. Why: the coverage harness should guard the
|
||||
/// same freshness cut-off that prevents seconds-old video from re-entering UVC.
|
||||
fn frame_spool_is_stale(path: &std::path::Path, max_age: Option<std::time::Duration>) -> bool {
|
||||
let Some(max_age) = max_age else {
|
||||
return false;
|
||||
};
|
||||
let Ok(metadata) = std::fs::metadata(path) else {
|
||||
return true;
|
||||
};
|
||||
let Ok(modified) = metadata.modified() else {
|
||||
return false;
|
||||
};
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(modified)
|
||||
.map(|age| age > max_age)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
/// Parses an unsigned 64-bit environment value with a fallback.
|
||||
///
|
||||
/// Inputs: variable name and default value. Outputs: parsed value or default.
|
||||
/// Why: UVC freshness settings use millisecond durations that should not be
|
||||
/// truncated to 32-bit just because the coverage harness is lightweight.
|
||||
fn env_u64(name: &str, default: u64) -> u64 {
|
||||
env::var(name)
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
106
server/src/bin/lesavka_uvc/coverage_stats.rs
Normal file
106
server/src/bin/lesavka_uvc/coverage_stats.rs
Normal file
@ -0,0 +1,106 @@
|
||||
#[cfg(coverage)]
|
||||
/// Returns the optional JSON stats path for the UVC coverage helper.
|
||||
///
|
||||
/// Inputs: `LESAVKA_UVC_STATS_PATH`. Output: path or disabled state. Why:
|
||||
/// tests and probes need a cheap health artifact without forcing writes.
|
||||
fn uvc_stats_path() -> Option<std::path::PathBuf> {
|
||||
match std::env::var("LESAVKA_UVC_STATS_PATH") {
|
||||
Ok(value) => {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() || trimmed == "0" {
|
||||
None
|
||||
} else {
|
||||
Some(std::path::PathBuf::from(trimmed))
|
||||
}
|
||||
}
|
||||
Err(_) => Some(std::path::PathBuf::from(DEFAULT_UVC_STATS_PATH)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
/// Writes a text artifact through a temporary file and atomic rename.
|
||||
///
|
||||
/// Inputs: target path and payload. Output: persisted file. Why: readers should
|
||||
/// never observe partially-written UVC stats JSON.
|
||||
fn write_atomic_text(path: &std::path::Path, text: &str) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let tmp = path.with_extension(format!("tmp.{}", std::process::id()));
|
||||
std::fs::write(&tmp, text)?;
|
||||
std::fs::rename(&tmp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
/// Serializes the compact UVC video-stat snapshot consumed by probes.
|
||||
///
|
||||
/// 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 {
|
||||
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",
|
||||
stats.queued,
|
||||
stats.reloaded,
|
||||
stats.replayed_stale,
|
||||
stats.rejected_oversize,
|
||||
stats.rejected_invalid,
|
||||
stats.fallback_idle,
|
||||
stats.latest_bytes,
|
||||
frame_cap,
|
||||
stats.last_rejected_oversize_bytes,
|
||||
stats.last_rejected_oversize_cap,
|
||||
stats.paced_sleeps,
|
||||
stats.paced_sleep_ms
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
/// Returns the optional periodic stats write interval.
|
||||
///
|
||||
/// Inputs: `LESAVKA_UVC_STATS_INTERVAL_MS`. Output: duration or disabled state.
|
||||
/// Why: probes can ask for telemetry without slowing every coverage test.
|
||||
fn uvc_stats_interval() -> Option<std::time::Duration> {
|
||||
match env_u64("LESAVKA_UVC_STATS_INTERVAL_MS", DEFAULT_UVC_STATS_INTERVAL_MS) {
|
||||
0 => None,
|
||||
value => Some(std::time::Duration::from_millis(value)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
/// Returns the optional maximum age for a spooled MJPEG frame.
|
||||
///
|
||||
/// Inputs: `LESAVKA_UVC_FRAME_MAX_AGE_MS`. Outputs: `None` when the TTL is
|
||||
/// disabled. Why: stale-frame replay must be explicit because it trades
|
||||
/// smoothness against freshness during browser stream recovery.
|
||||
fn frame_spool_max_age() -> Option<std::time::Duration> {
|
||||
match env_u64(
|
||||
"LESAVKA_UVC_FRAME_MAX_AGE_MS",
|
||||
DEFAULT_UVC_FRAME_MAX_AGE_MS,
|
||||
) {
|
||||
0 => None,
|
||||
value => Some(std::time::Duration::from_millis(value)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(coverage)]
|
||||
/// Determines whether a spooled MJPEG frame is too old to replay.
|
||||
///
|
||||
/// Inputs: frame path and optional age limit. Outputs: true when the frame is
|
||||
/// missing or older than the limit. Why: the coverage harness should guard the
|
||||
/// same freshness cut-off that prevents seconds-old video from re-entering UVC.
|
||||
fn frame_spool_is_stale(path: &std::path::Path, max_age: Option<std::time::Duration>) -> bool {
|
||||
let Some(max_age) = max_age else {
|
||||
return false;
|
||||
};
|
||||
let Ok(metadata) = std::fs::metadata(path) else {
|
||||
return true;
|
||||
};
|
||||
let Ok(modified) = metadata.modified() else {
|
||||
return false;
|
||||
};
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(modified)
|
||||
.map(|age| age > max_age)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
@ -149,6 +149,100 @@ fn uvc_queue_pacing_defaults_on_but_can_be_disabled() {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn uvc_env_flags_and_bulk_configfs_edges_are_covered() {
|
||||
with_var("LESAVKA_UVC_QUEUE_PACING", Some("maybe"), || {
|
||||
assert!(env_flag_enabled("LESAVKA_UVC_QUEUE_PACING", true));
|
||||
assert!(!env_flag_enabled("LESAVKA_UVC_QUEUE_PACING", false));
|
||||
});
|
||||
|
||||
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");
|
||||
assert!(uvc_bulk_transfer_enabled_for_base(configfs.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn uvc_startup_open_can_opt_into_write_mode() {
|
||||
let device = NamedTempFile::new().expect("device");
|
||||
with_vars(
|
||||
[
|
||||
("LESAVKA_UVC_CONTROL_READ_ONLY", Some("0")),
|
||||
("LESAVKA_UVC_BLOCKING", Some("1")),
|
||||
],
|
||||
|| {
|
||||
let opened = open_with_retry(device.path().to_str().expect("path"));
|
||||
assert!(opened.is_ok());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn uvc_stats_paths_and_intervals_cover_operator_overrides() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let stats_path = dir.path().join("video-stats.json");
|
||||
with_var(
|
||||
"LESAVKA_UVC_STATS_PATH",
|
||||
Some(stats_path.to_str().expect("stats path")),
|
||||
|| {
|
||||
assert_eq!(uvc_stats_path(), Some(stats_path.clone()));
|
||||
},
|
||||
);
|
||||
|
||||
with_var("LESAVKA_UVC_STATS_INTERVAL_MS", Some("25"), || {
|
||||
assert_eq!(
|
||||
uvc_stats_interval(),
|
||||
Some(std::time::Duration::from_millis(25))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uvc_video_stream_refresh_covers_fresh_invalid_and_missing_frames() {
|
||||
let valid = NamedTempFile::new().expect("valid frame");
|
||||
fs::write(valid.path(), [0xff, 0xd8, 1, 2, 3, 0xff, 0xd9]).expect("write valid");
|
||||
let mut stream = UvcVideoStream::new(-1);
|
||||
stream.frame_path = valid.path().to_path_buf();
|
||||
stream.refresh_latest_frame();
|
||||
assert_eq!(stream.stats.reloaded, 1);
|
||||
assert_eq!(stream.stats.latest_bytes, 7);
|
||||
assert_eq!(stream.latest_frame, vec![0xff, 0xd8, 1, 2, 3, 0xff, 0xd9]);
|
||||
|
||||
let invalid = NamedTempFile::new().expect("invalid frame");
|
||||
fs::write(invalid.path(), b"not-jpeg").expect("write invalid");
|
||||
let mut stream = UvcVideoStream::new(-1);
|
||||
stream.frame_path = invalid.path().to_path_buf();
|
||||
stream.latest_frame = b"bad-cache".to_vec();
|
||||
stream.refresh_latest_frame();
|
||||
assert_eq!(stream.stats.rejected_invalid, 1);
|
||||
assert_eq!(stream.stats.fallback_idle, 1);
|
||||
assert_eq!(stream.latest_frame, IDLE_MJPEG_FRAME);
|
||||
|
||||
let missing = tempfile::tempdir()
|
||||
.expect("tempdir")
|
||||
.path()
|
||||
.join("missing-frame.mjpg");
|
||||
let mut stream = UvcVideoStream::new(-1);
|
||||
stream.frame_path = missing;
|
||||
stream.latest_frame = b"bad-cache".to_vec();
|
||||
stream.refresh_latest_frame();
|
||||
assert_eq!(stream.stats.fallback_idle, 1);
|
||||
assert_eq!(stream.latest_frame, IDLE_MJPEG_FRAME);
|
||||
|
||||
let tiny_buffer_stream = UvcVideoStream::new(-1);
|
||||
assert_eq!(
|
||||
tiny_buffer_stream.frame_for_buffer(MINIMAL_MJPEG_FRAME.len()),
|
||||
MINIMAL_MJPEG_FRAME
|
||||
);
|
||||
assert_eq!(
|
||||
tiny_buffer_stream.frame_for_buffer(IDLE_MJPEG_FRAME.len()),
|
||||
IDLE_MJPEG_FRAME
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn main_coverage_mode_returns_error_for_non_uvc_node() {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user