diff --git a/scripts/ci/hygiene_gate_baseline.json b/scripts/ci/hygiene_gate_baseline.json index 5db629e..7c3a95d 100644 --- a/scripts/ci/hygiene_gate_baseline.json +++ b/scripts/ci/hygiene_gate_baseline.json @@ -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, diff --git a/scripts/ci/quality_gate_baseline.json b/scripts/ci/quality_gate_baseline.json index d401808..cd9fcd9 100644 --- a/scripts/ci/quality_gate_baseline.json +++ b/scripts/ci/quality_gate_baseline.json @@ -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, diff --git a/server/src/bin/lesavka-uvc.rs b/server/src/bin/lesavka-uvc.rs index 327de5d..abc3a2e 100644 --- a/server/src/bin/lesavka-uvc.rs +++ b/server/src/bin/lesavka-uvc.rs @@ -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"); diff --git a/server/src/bin/lesavka_uvc/coverage_config.rs b/server/src/bin/lesavka_uvc/coverage_config.rs new file mode 100644 index 0000000..692aa8d --- /dev/null +++ b/server/src/bin/lesavka_uvc/coverage_config.rs @@ -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 { + std::fs::read_to_string(path) + .ok() + .and_then(|v| v.trim().parse::().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)) +} diff --git a/server/src/bin/lesavka_uvc/coverage_frame_budget.rs b/server/src/bin/lesavka_uvc/coverage_frame_budget.rs new file mode 100644 index 0000000..41a7575 --- /dev/null +++ b/server/src/bin/lesavka_uvc/coverage_frame_budget.rs @@ -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 { + 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::().ok()) + .unwrap_or(default) +} diff --git a/server/src/bin/lesavka_uvc/coverage_startup.rs b/server/src/bin/lesavka_uvc/coverage_startup.rs index a52b774..812b3ac 100644 --- a/server/src/bin/lesavka_uvc/coverage_startup.rs +++ b/server/src/bin/lesavka_uvc/coverage_startup.rs @@ -40,83 +40,6 @@ fn parse_device_arg(args: &[String]) -> Option { }) } -#[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 { - std::fs::read_to_string(path) - .ok() - .and_then(|v| v.trim().parse::().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 { - 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 { - 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 { - 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 { - 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) -> 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::().ok()) - .unwrap_or(default) -} diff --git a/server/src/bin/lesavka_uvc/coverage_stats.rs b/server/src/bin/lesavka_uvc/coverage_stats.rs new file mode 100644 index 0000000..f809876 --- /dev/null +++ b/server/src/bin/lesavka_uvc/coverage_stats.rs @@ -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 { + 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 { + 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 { + 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) -> 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) +} diff --git a/server/src/bin/tests/lesavka_uvc.rs b/server/src/bin/tests/lesavka_uvc.rs index 8d9203f..080d47f 100644 --- a/server/src/bin/tests/lesavka_uvc.rs +++ b/server/src/bin/tests/lesavka_uvc.rs @@ -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() {