ci(lesavka): split uvc coverage startup ratchet

This commit is contained in:
Brad Stein 2026-06-05 03:46:48 -03:00
parent 92b9aeecbd
commit 0d5284c9a2
8 changed files with 513 additions and 377 deletions

View File

@ -963,7 +963,7 @@
"server/src/bin/lesavka-uvc.rs": { "server/src/bin/lesavka-uvc.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
"doc_debt": 0, "doc_debt": 0,
"loc": 19 "loc": 25
}, },
"server/src/bin/lesavka_synthetic_uplink/support.rs": { "server/src/bin/lesavka_synthetic_uplink/support.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
@ -985,10 +985,25 @@
"doc_debt": 0, "doc_debt": 0,
"loc": 289 "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": { "server/src/bin/lesavka_uvc/coverage_startup.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,
"doc_debt": 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": { "server/src/bin/lesavka_uvc/payload_limits.rs": {
"clippy_warnings": 0, "clippy_warnings": 0,

View File

@ -360,9 +360,25 @@
"line_percent": 100.0, "line_percent": 100.0,
"loc": 162 "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": { "server/src/bin/lesavka_uvc/coverage_startup.rs": {
"line_percent": 98.48, "line_percent": 100.0,
"loc": 203 "loc": 74
},
"server/src/bin/lesavka_uvc/coverage_stats.rs": {
"line_percent": 95.65,
"loc": 106
}, },
"server/src/bin/lesavka_uvc/payload_limits.rs": { "server/src/bin/lesavka_uvc/payload_limits.rs": {
"line_percent": 100.0, "line_percent": 100.0,

View File

@ -8,6 +8,12 @@ include!("lesavka_uvc/coverage_model.rs");
#[cfg(coverage)] #[cfg(coverage)]
include!("lesavka_uvc/coverage_startup.rs"); include!("lesavka_uvc/coverage_startup.rs");
#[cfg(coverage)] #[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"); include!("lesavka_uvc/control_requests.rs");
#[cfg(coverage)] #[cfg(coverage)]
include!("lesavka_uvc/control_payloads.rs"); include!("lesavka_uvc/control_payloads.rs");

View 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))
}

View 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)
}

View File

@ -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)] #[cfg(coverage)]
/// Opens the UVC control device with safe coverage defaults. /// Opens the UVC control device with safe coverage defaults.
/// ///
@ -149,299 +72,3 @@ fn uvc_control_read_only() -> bool {
}) })
.unwrap_or(true) .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)
}

View 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)
}

View File

@ -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] #[test]
#[serial] #[serial]
fn main_coverage_mode_returns_error_for_non_uvc_node() { fn main_coverage_mode_returns_error_for_non_uvc_node() {