fix: normalize UVC MJPEG before gadget handoff

This commit is contained in:
Brad Stein 2026-05-19 11:41:30 -03:00
parent 3c7ccfdbcc
commit 29a565f9e2
17 changed files with 150 additions and 52 deletions

6
Cargo.lock generated
View File

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

View File

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

View File

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

View File

@ -321,7 +321,7 @@ fi
UVC_FRAME_SIZE=${LESAVKA_UVC_FRAME_SIZE:-$((UVC_WIDTH * UVC_HEIGHT * 2))} UVC_FRAME_SIZE=${LESAVKA_UVC_FRAME_SIZE:-$((UVC_WIDTH * UVC_HEIGHT * 2))}
UVC_INTERVAL_30=${LESAVKA_UVC_INTERVAL_30:-333333} UVC_INTERVAL_30=${LESAVKA_UVC_INTERVAL_30:-333333}
UVC_INTERVAL_20=${LESAVKA_UVC_INTERVAL_20:-500000} UVC_INTERVAL_20=${LESAVKA_UVC_INTERVAL_20:-500000}
UVC_MJPEG_BUDGET_BYTES_PER_SEC=${LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC:-10000000} UVC_MJPEG_BUDGET_BYTES_PER_SEC=${LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC:-4500000}
UVC_ISOCHRONOUS_LIMIT_PCT=${LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT:-85} UVC_ISOCHRONOUS_LIMIT_PCT=${LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT:-85}
uvc_isochronous_budget_bytes_per_sec() { uvc_isochronous_budget_bytes_per_sec() {

View File

@ -388,7 +388,7 @@ LESAVKA_UVC_MAXBURST=$(uvc_env_value LESAVKA_UVC_MAXBURST 0)
LESAVKA_UVC_BULK=$(uvc_env_value LESAVKA_UVC_BULK 1) LESAVKA_UVC_BULK=$(uvc_env_value LESAVKA_UVC_BULK 1)
LESAVKA_UVC_FRAME_SIZE_GUARD=$(uvc_env_value LESAVKA_UVC_FRAME_SIZE_GUARD 1) LESAVKA_UVC_FRAME_SIZE_GUARD=$(uvc_env_value LESAVKA_UVC_FRAME_SIZE_GUARD 1)
LESAVKA_UVC_FRAME_MAX_BYTES=$(uvc_env_value LESAVKA_UVC_FRAME_MAX_BYTES 0) LESAVKA_UVC_FRAME_MAX_BYTES=$(uvc_env_value LESAVKA_UVC_FRAME_MAX_BYTES 0)
LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC=$(uvc_env_value LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC 10000000) LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC=$(uvc_env_value LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC 4500000)
LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT=$(uvc_env_value LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT 85) LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT=$(uvc_env_value LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT 85)
LESAVKA_UVC_STATS_PATH=$(uvc_env_value LESAVKA_UVC_STATS_PATH /run/lesavka-uvc-video-stats.json) LESAVKA_UVC_STATS_PATH=$(uvc_env_value LESAVKA_UVC_STATS_PATH /run/lesavka-uvc-video-stats.json)
EOF EOF
@ -1645,11 +1645,11 @@ SERVER_ENV_TMP=$(mktemp)
printf 'LESAVKA_UVC_HEVC_SPOOL_PULL_TIMEOUT_MS=%s\n' "${LESAVKA_UVC_HEVC_SPOOL_PULL_TIMEOUT_MS:-20}" printf 'LESAVKA_UVC_HEVC_SPOOL_PULL_TIMEOUT_MS=%s\n' "${LESAVKA_UVC_HEVC_SPOOL_PULL_TIMEOUT_MS:-20}"
printf 'LESAVKA_UVC_HEVC_FRESHNESS_QUEUE_BUFFERS=%s\n' "${LESAVKA_UVC_HEVC_FRESHNESS_QUEUE_BUFFERS:-2}" printf 'LESAVKA_UVC_HEVC_FRESHNESS_QUEUE_BUFFERS=%s\n' "${LESAVKA_UVC_HEVC_FRESHNESS_QUEUE_BUFFERS:-2}"
printf 'LESAVKA_UVC_HEVC_DECODE_MISS_LIMIT=%s\n' "${LESAVKA_UVC_HEVC_DECODE_MISS_LIMIT:-15}" printf 'LESAVKA_UVC_HEVC_DECODE_MISS_LIMIT=%s\n' "${LESAVKA_UVC_HEVC_DECODE_MISS_LIMIT:-15}"
printf 'LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE=%s\n' "${LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE:-0}" printf 'LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE=%s\n' "${LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE:-1}"
printf 'LESAVKA_UVC_DIRECT_MJPEG_JPEG_QUALITY=%s\n' "${LESAVKA_UVC_DIRECT_MJPEG_JPEG_QUALITY:-72}" printf 'LESAVKA_UVC_DIRECT_MJPEG_JPEG_QUALITY=%s\n' "${LESAVKA_UVC_DIRECT_MJPEG_JPEG_QUALITY:-60}"
printf 'LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_PULL_TIMEOUT_MS=%s\n' "${LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_PULL_TIMEOUT_MS:-25}" printf 'LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_PULL_TIMEOUT_MS=%s\n' "${LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_PULL_TIMEOUT_MS:-25}"
printf 'LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_MISS_LIMIT=%s\n' "${LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_MISS_LIMIT:-30}" printf 'LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_MISS_LIMIT=%s\n' "${LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_MISS_LIMIT:-30}"
printf 'LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_RSS_LIMIT_MB=%s\n' "${LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_RSS_LIMIT_MB:-768}" printf 'LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_RSS_LIMIT_MB=%s\n' "${LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_RSS_LIMIT_MB:-384}"
printf 'LESAVKA_UVC_DIRECT_MJPEG_VISUAL_GUARD=%s\n' "${LESAVKA_UVC_DIRECT_MJPEG_VISUAL_GUARD:-1}" printf 'LESAVKA_UVC_DIRECT_MJPEG_VISUAL_GUARD=%s\n' "${LESAVKA_UVC_DIRECT_MJPEG_VISUAL_GUARD:-1}"
printf 'LESAVKA_UVC_DIRECT_MJPEG_SIZE_DROP_PCT=%s\n' "${LESAVKA_UVC_DIRECT_MJPEG_SIZE_DROP_PCT:-18}" printf 'LESAVKA_UVC_DIRECT_MJPEG_SIZE_DROP_PCT=%s\n' "${LESAVKA_UVC_DIRECT_MJPEG_SIZE_DROP_PCT:-18}"
printf 'LESAVKA_UVC_DIRECT_MJPEG_MIN_REFERENCE_BYTES=%s\n' "${LESAVKA_UVC_DIRECT_MJPEG_MIN_REFERENCE_BYTES:-49152}" printf 'LESAVKA_UVC_DIRECT_MJPEG_MIN_REFERENCE_BYTES=%s\n' "${LESAVKA_UVC_DIRECT_MJPEG_MIN_REFERENCE_BYTES:-49152}"

View File

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

View File

@ -54,7 +54,7 @@ const DEFAULT_UVC_BUFFER_COUNT: u32 = 1;
const DEFAULT_UVC_IDLE_PUMP_MS: u64 = 2; const DEFAULT_UVC_IDLE_PUMP_MS: u64 = 2;
const DEFAULT_UVC_FRAME_MAX_AGE_MS: u64 = 1_000; const DEFAULT_UVC_FRAME_MAX_AGE_MS: u64 = 1_000;
const DEFAULT_UVC_QUEUE_PACING: bool = true; const DEFAULT_UVC_QUEUE_PACING: bool = true;
const DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC: u32 = 10_000_000; const DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC: u32 = 4_500_000;
const DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT: u32 = 85; const DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT: u32 = 85;
const HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC: u32 = 8_000; const HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC: u32 = 8_000;
const DEFAULT_UVC_STATS_INTERVAL_MS: u64 = 5_000; const DEFAULT_UVC_STATS_INTERVAL_MS: u64 = 5_000;

View File

@ -59,7 +59,7 @@ const DEFAULT_UVC_FRAME_MAX_AGE_MS: u64 = 1_000;
#[cfg(coverage)] #[cfg(coverage)]
const DEFAULT_UVC_QUEUE_PACING: bool = true; const DEFAULT_UVC_QUEUE_PACING: bool = true;
#[cfg(coverage)] #[cfg(coverage)]
const DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC: u32 = 10_000_000; const DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC: u32 = 4_500_000;
#[cfg(coverage)] #[cfg(coverage)]
const DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT: u32 = 85; const DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT: u32 = 85;
#[cfg(coverage)] #[cfg(coverage)]

View File

@ -80,7 +80,7 @@ fn uvc_frame_max_bytes_defaults_to_freshness_budget_and_allows_override() {
], ],
|| { || {
let cfg = sample_cfg(); let cfg = sample_cfg();
assert_eq!(uvc_frame_max_bytes(cfg), 400_000); assert_eq!(uvc_frame_max_bytes(cfg), 180_000);
}, },
); );
@ -96,7 +96,7 @@ fn uvc_frame_max_bytes_defaults_to_freshness_budget_and_allows_override() {
); );
with_var("LESAVKA_UVC_FRAME_MAX_BYTES", Some("0"), || { with_var("LESAVKA_UVC_FRAME_MAX_BYTES", Some("0"), || {
assert_eq!(uvc_frame_max_bytes(sample_cfg()), 400_000); assert_eq!(uvc_frame_max_bytes(sample_cfg()), 180_000);
}); });
with_vars( with_vars(

View File

@ -16,11 +16,11 @@ const DEFAULT_HEVC_DOMINANT_BYTE_PCT: u32 = 92;
const DEFAULT_DIRECT_MJPEG_SIZE_DROP_PCT: u32 = 18; const DEFAULT_DIRECT_MJPEG_SIZE_DROP_PCT: u32 = 18;
const DEFAULT_DIRECT_MJPEG_MIN_REFERENCE_BYTES: u32 = 48 * 1024; const DEFAULT_DIRECT_MJPEG_MIN_REFERENCE_BYTES: u32 = 48 * 1024;
const DEFAULT_DIRECT_MJPEG_PROFILE_MISMATCH_REJECT: bool = false; const DEFAULT_DIRECT_MJPEG_PROFILE_MISMATCH_REJECT: bool = false;
const DEFAULT_DIRECT_MJPEG_NORMALIZE: bool = false; const DEFAULT_DIRECT_MJPEG_NORMALIZE: bool = true;
const DEFAULT_DIRECT_MJPEG_JPEG_QUALITY: u32 = 72; const DEFAULT_DIRECT_MJPEG_JPEG_QUALITY: u32 = 60;
const DEFAULT_DIRECT_MJPEG_NORMALIZE_PULL_TIMEOUT_MS: u32 = 25; const DEFAULT_DIRECT_MJPEG_NORMALIZE_PULL_TIMEOUT_MS: u32 = 25;
const DEFAULT_DIRECT_MJPEG_NORMALIZE_MISS_LIMIT: u32 = 30; const DEFAULT_DIRECT_MJPEG_NORMALIZE_MISS_LIMIT: u32 = 30;
const DEFAULT_DIRECT_MJPEG_NORMALIZE_RSS_LIMIT_MB: u32 = 768; const DEFAULT_DIRECT_MJPEG_NORMALIZE_RSS_LIMIT_MB: u32 = 384;
/// Explains why a direct MJPEG frame was frozen before UVC handoff. /// Explains why a direct MJPEG frame was frozen before UVC handoff.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
@ -191,10 +191,9 @@ pub(super) fn direct_mjpeg_reject_profile_mismatch_enabled() -> bool {
/// Decide whether direct MJPEG should be normalized before UVC spool. /// Decide whether direct MJPEG should be normalized before UVC spool.
/// ///
/// Inputs: optional `LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE`. Output: true unless /// Inputs: optional `LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE`. Output: true unless
/// explicitly enabled. Why: this path runs native GStreamer JPEG decode/encode /// explicitly disabled. Why: direct MJPEG camera frames can still stress the
/// inside `lesavka-server`; it is useful for lab comparisons, but field /// high-speed isochronous UVC link; normalizing them to a simpler, smaller
/// evidence showed long-running RSS growth from the native allocator/buffer /// JPEG bitstream before the gadget is safer than trusting passthrough bytes.
/// pools, so guarded passthrough is the production-safe default.
pub(super) fn direct_mjpeg_normalize_enabled() -> bool { pub(super) fn direct_mjpeg_normalize_enabled() -> bool {
std::env::var("LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE") std::env::var("LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE")
.ok() .ok()

View File

@ -14,7 +14,7 @@ fn hevc_jpeg_quality_defaults_to_moderate_transport_pressure() {
} }
#[test] #[test]
fn direct_mjpeg_normalization_defaults_off_and_clamps_tuning() { fn direct_mjpeg_normalization_defaults_on_and_clamps_tuning() {
temp_env::with_vars( temp_env::with_vars(
[ [
("LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE", None::<&str>), ("LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE", None::<&str>),
@ -33,13 +33,13 @@ fn direct_mjpeg_normalization_defaults_off_and_clamps_tuning() {
), ),
], ],
|| { || {
assert!(!super::direct_mjpeg_normalize_enabled()); assert!(super::direct_mjpeg_normalize_enabled());
assert_eq!(super::direct_mjpeg_jpeg_quality(), 72); assert_eq!(super::direct_mjpeg_jpeg_quality(), 60);
assert_eq!(super::direct_mjpeg_normalize_pull_timeout_ms(), 25); assert_eq!(super::direct_mjpeg_normalize_pull_timeout_ms(), 25);
assert_eq!(super::direct_mjpeg_normalize_miss_limit(), 30); assert_eq!(super::direct_mjpeg_normalize_miss_limit(), 30);
assert_eq!( assert_eq!(
super::direct_mjpeg_normalize_rss_limit_kb(), super::direct_mjpeg_normalize_rss_limit_kb(),
Some(768 * 1024) Some(384 * 1024)
); );
}, },
); );

View File

@ -13,7 +13,7 @@ mod audit;
static SPOOL_SEQUENCE: AtomicU64 = AtomicU64::new(1); static SPOOL_SEQUENCE: AtomicU64 = AtomicU64::new(1);
static SPOOL_TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(1); static SPOOL_TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(1);
const MAX_MJPEG_FRAME_BYTES: usize = 8 * 1024 * 1024; const MAX_MJPEG_FRAME_BYTES: usize = 8 * 1024 * 1024;
const DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC: u32 = 10_000_000; const DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC: u32 = 4_500_000;
const DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT: u32 = 85; const DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT: u32 = 85;
const HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC: u32 = 8_000; const HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC: u32 = 8_000;
const CONFIGFS_UVC_BASE: &str = "/sys/kernel/config/usb_gadget/lesavka/functions/uvc.usb0"; const CONFIGFS_UVC_BASE: &str = "/sys/kernel/config/usb_gadget/lesavka/functions/uvc.usb0";
@ -377,8 +377,8 @@ pub(super) fn spool_mjpeg_frame_with_timing(
} }
if let (Some(dir), Some(sequence)) = (audit_dir, sequence) if let (Some(dir), Some(sequence)) = (audit_dir, sequence)
&& audit::claim_spool_audit_frame(sequence) && let Some(slot) = audit::claim_spool_audit_frame(sequence)
&& let Err(err) = audit::write_mjpeg_spool_audit_frame(&dir, sequence, data, timing) && let Err(err) = audit::write_mjpeg_spool_audit_frame(&dir, sequence, slot, data, timing)
{ {
tracing::warn!( tracing::warn!(
target: "lesavka_server::video", target: "lesavka_server::video",

View File

@ -10,9 +10,15 @@ static AUDIT_TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(1);
static AUDIT_SAVED_FRAMES: AtomicU64 = AtomicU64::new(0); static AUDIT_SAVED_FRAMES: AtomicU64 = AtomicU64::new(0);
const DEFAULT_UVC_FRAME_AUDIT_EVERY: u32 = 1; const DEFAULT_UVC_FRAME_AUDIT_EVERY: u32 = 1;
const DEFAULT_UVC_FRAME_AUDIT_MAX_FRAMES: u32 = 1800; const DEFAULT_UVC_FRAME_AUDIT_MAX_FRAMES: u32 = 1800;
const DEFAULT_UVC_FRAME_AUDIT_ROLLING: bool = true;
const FNV1A64_OFFSET: u64 = 0xcbf29ce484222325; const FNV1A64_OFFSET: u64 = 0xcbf29ce484222325;
const FNV1A64_PRIME: u64 = 0x100000001b3; const FNV1A64_PRIME: u64 = 0x100000001b3;
#[cfg(test)]
pub(super) fn reset_spool_audit_counter_for_test() {
AUDIT_SAVED_FRAMES.store(0, Ordering::Relaxed);
}
/// Parse an optional unsigned tuning value. /// Parse an optional unsigned tuning value.
/// ///
/// Inputs: environment variable name. Output: parsed `u32` when present. /// Inputs: environment variable name. Output: parsed `u32` when present.
@ -90,6 +96,24 @@ pub(super) fn mjpeg_spool_audit_max_frames() -> u64 {
) )
} }
/// Resolve whether bounded audits keep the latest frames instead of stopping.
///
/// Inputs: `LESAVKA_UVC_FRAME_AUDIT_ROLLING`. Output: true unless explicitly
/// disabled. Why: long live-debug sessions need the frames around the latest
/// recording, not stale startup evidence from hours earlier.
pub(super) fn mjpeg_spool_audit_rolling_enabled() -> bool {
std::env::var("LESAVKA_UVC_FRAME_AUDIT_ROLLING")
.ok()
.map(|value| {
let trimmed = value.trim();
!(trimmed.eq_ignore_ascii_case("0")
|| trimmed.eq_ignore_ascii_case("false")
|| trimmed.eq_ignore_ascii_case("no")
|| trimmed.eq_ignore_ascii_case("off"))
})
.unwrap_or(DEFAULT_UVC_FRAME_AUDIT_ROLLING)
}
/// Resolve the audit JSONL path. /// Resolve the audit JSONL path.
/// ///
/// Inputs: audit directory plus `LESAVKA_UVC_FRAME_AUDIT_LOG_PATH`. Output: /// Inputs: audit directory plus `LESAVKA_UVC_FRAME_AUDIT_LOG_PATH`. Output:
@ -126,17 +150,24 @@ pub(super) fn should_sample_spool_audit_frame(sequence: u64, every: u64) -> bool
offset.is_multiple_of(every.max(1)) offset.is_multiple_of(every.max(1))
} }
/// Decide whether the current spooled frame should be preserved. /// Decide where the current spooled frame should be preserved.
/// ///
/// Inputs: global spool sequence. Output: true if sampling and max-frame budget /// Inputs: global spool sequence. Output: the bounded audit file slot, if
/// allow a save. Why: the max cap must count saved audit frames, not every /// sampling and retention allow a save. Why: rolling audits must stay disk
/// frame the long-running server emitted before the audit started. /// bounded while still keeping the frames nearest the user's recording.
pub(super) fn claim_spool_audit_frame(sequence: u64) -> bool { pub(super) fn claim_spool_audit_frame(sequence: u64) -> Option<u64> {
if !should_sample_spool_audit_frame(sequence, mjpeg_spool_audit_every()) { if !should_sample_spool_audit_frame(sequence, mjpeg_spool_audit_every()) {
return false; return None;
} }
let max_frames = mjpeg_spool_audit_max_frames(); let max_frames = mjpeg_spool_audit_max_frames();
max_frames == 0 || AUDIT_SAVED_FRAMES.fetch_add(1, Ordering::Relaxed) < max_frames let saved = AUDIT_SAVED_FRAMES.fetch_add(1, Ordering::Relaxed);
if max_frames == 0 {
return Some(sequence);
}
if mjpeg_spool_audit_rolling_enabled() {
return Some(saved % max_frames + 1);
}
(saved < max_frames).then_some(sequence)
} }
/// Format one exact-frame audit index record. /// Format one exact-frame audit index record.
@ -146,6 +177,7 @@ pub(super) fn claim_spool_audit_frame(sequence: u64) -> bool {
/// RCT frame with the server-boundary payload that preceded it. /// RCT frame with the server-boundary payload that preceded it.
fn format_audit_record( fn format_audit_record(
sequence: u64, sequence: u64,
slot: u64,
data: &[u8], data: &[u8],
timing: Option<MjpegSpoolTiming>, timing: Option<MjpegSpoolTiming>,
frame_file: &str, frame_file: &str,
@ -154,8 +186,9 @@ fn format_audit_record(
let source_pts_us = timing.and_then(|value| value.source_pts_us); let source_pts_us = timing.and_then(|value| value.source_pts_us);
let decoded_pts_us = timing.and_then(|value| value.decoded_pts_us); let decoded_pts_us = timing.and_then(|value| value.decoded_pts_us);
format!( format!(
"{{\"schema\":\"lesavka.uvc-mjpeg-spool-audit.v1\",\"sequence\":{},\"profile\":\"{}\",\"bytes\":{},\"source_pts_us\":{},\"decoded_pts_us\":{},\"spool_unix_ns\":{},\"fnv1a64\":\"{}\",\"file\":\"{}\"}}\n", "{{\"schema\":\"lesavka.uvc-mjpeg-spool-audit.v1\",\"sequence\":{},\"slot\":{},\"profile\":\"{}\",\"bytes\":{},\"source_pts_us\":{},\"decoded_pts_us\":{},\"spool_unix_ns\":{},\"fnv1a64\":\"{}\",\"file\":\"{}\"}}\n",
sequence, sequence,
slot,
json_escape(profile), json_escape(profile),
data.len(), data.len(),
json_number_or_null(source_pts_us), json_number_or_null(source_pts_us),
@ -174,11 +207,12 @@ fn format_audit_record(
pub(super) fn write_mjpeg_spool_audit_frame( pub(super) fn write_mjpeg_spool_audit_frame(
dir: &Path, dir: &Path,
sequence: u64, sequence: u64,
slot: u64,
data: &[u8], data: &[u8],
timing: Option<MjpegSpoolTiming>, timing: Option<MjpegSpoolTiming>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
fs::create_dir_all(dir)?; fs::create_dir_all(dir)?;
let frame_file = format!("frame-{sequence:012}.mjpg"); let frame_file = format!("frame-{slot:012}.mjpg");
let tmp = dir.join(format!( let tmp = dir.join(format!(
".{frame_file}.{}.{}.tmp", ".{frame_file}.{}.{}.tmp",
std::process::id(), std::process::id(),
@ -187,6 +221,6 @@ pub(super) fn write_mjpeg_spool_audit_frame(
fs::write(&tmp, data)?; fs::write(&tmp, data)?;
fs::rename(&tmp, dir.join(&frame_file))?; fs::rename(&tmp, dir.join(&frame_file))?;
let record = format_audit_record(sequence, data, timing, &frame_file); let record = format_audit_record(sequence, slot, data, timing, &frame_file);
append_record(&mjpeg_spool_audit_log_path(dir), &record) append_record(&mjpeg_spool_audit_log_path(dir), &record)
} }

View File

@ -44,7 +44,7 @@ fn mjpeg_spool_frame_budget_uses_live_budget_when_zero_or_unset() {
("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", None::<&str>), ("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", None::<&str>),
], ],
|| { || {
assert_eq!(super::mjpeg_spool_frame_max_bytes(30), 333_333); assert_eq!(super::mjpeg_spool_frame_max_bytes(30), 150_000);
}, },
); );
@ -151,11 +151,13 @@ fn mjpeg_spool_audit_knobs_are_opt_in_and_bounded() {
("LESAVKA_UVC_FRAME_AUDIT_DIR", None::<&str>), ("LESAVKA_UVC_FRAME_AUDIT_DIR", None::<&str>),
("LESAVKA_UVC_FRAME_AUDIT_EVERY", None::<&str>), ("LESAVKA_UVC_FRAME_AUDIT_EVERY", None::<&str>),
("LESAVKA_UVC_FRAME_AUDIT_MAX_FRAMES", None::<&str>), ("LESAVKA_UVC_FRAME_AUDIT_MAX_FRAMES", None::<&str>),
("LESAVKA_UVC_FRAME_AUDIT_ROLLING", None::<&str>),
], ],
|| { || {
assert_eq!(super::audit::mjpeg_spool_audit_dir(), None); assert_eq!(super::audit::mjpeg_spool_audit_dir(), None);
assert_eq!(super::audit::mjpeg_spool_audit_every(), 1); assert_eq!(super::audit::mjpeg_spool_audit_every(), 1);
assert_eq!(super::audit::mjpeg_spool_audit_max_frames(), 1800); assert_eq!(super::audit::mjpeg_spool_audit_max_frames(), 1800);
assert!(super::audit::mjpeg_spool_audit_rolling_enabled());
}, },
); );
@ -164,6 +166,7 @@ fn mjpeg_spool_audit_knobs_are_opt_in_and_bounded() {
("LESAVKA_UVC_FRAME_AUDIT_DIR", Some("/tmp/uvc-audit")), ("LESAVKA_UVC_FRAME_AUDIT_DIR", Some("/tmp/uvc-audit")),
("LESAVKA_UVC_FRAME_AUDIT_EVERY", Some("0")), ("LESAVKA_UVC_FRAME_AUDIT_EVERY", Some("0")),
("LESAVKA_UVC_FRAME_AUDIT_MAX_FRAMES", Some("0")), ("LESAVKA_UVC_FRAME_AUDIT_MAX_FRAMES", Some("0")),
("LESAVKA_UVC_FRAME_AUDIT_ROLLING", Some("off")),
], ],
|| { || {
assert_eq!( assert_eq!(
@ -172,6 +175,7 @@ fn mjpeg_spool_audit_knobs_are_opt_in_and_bounded() {
); );
assert_eq!(super::audit::mjpeg_spool_audit_every(), 1); assert_eq!(super::audit::mjpeg_spool_audit_every(), 1);
assert_eq!(super::audit::mjpeg_spool_audit_max_frames(), 0); assert_eq!(super::audit::mjpeg_spool_audit_max_frames(), 0);
assert!(!super::audit::mjpeg_spool_audit_rolling_enabled());
}, },
); );
@ -324,7 +328,9 @@ fn spool_mjpeg_frame_writes_enabled_sidecar_with_timing() {
/// JSONL timing/hash record. Why: this is the evidence needed to decide /// JSONL timing/hash record. Why: this is the evidence needed to decide
/// whether corruption exists before or after the server-to-UVC boundary. /// whether corruption exists before or after the server-to-UVC boundary.
#[test] #[test]
#[serial_test::serial(audit)]
fn spool_mjpeg_frame_writes_enabled_boundary_audit() { fn spool_mjpeg_frame_writes_enabled_boundary_audit() {
super::audit::reset_spool_audit_counter_for_test();
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let frame = dir.path().join("frame.mjpg"); let frame = dir.path().join("frame.mjpg");
let audit = dir.path().join("audit"); let audit = dir.path().join("audit");
@ -338,6 +344,7 @@ fn spool_mjpeg_frame_writes_enabled_boundary_audit() {
), ),
("LESAVKA_UVC_FRAME_AUDIT_EVERY", Some("1")), ("LESAVKA_UVC_FRAME_AUDIT_EVERY", Some("1")),
("LESAVKA_UVC_FRAME_AUDIT_MAX_FRAMES", Some("1")), ("LESAVKA_UVC_FRAME_AUDIT_MAX_FRAMES", Some("1")),
("LESAVKA_UVC_FRAME_AUDIT_ROLLING", Some("0")),
], ],
|| { || {
super::spool_mjpeg_frame_with_timing( super::spool_mjpeg_frame_with_timing(
@ -372,3 +379,61 @@ fn spool_mjpeg_frame_writes_enabled_boundary_audit() {
assert!(index.contains("\"source_pts_us\":222")); assert!(index.contains("\"source_pts_us\":222"));
assert!(index.contains("\"file\":\"frame-")); assert!(index.contains("\"file\":\"frame-"));
} }
/// Verifies rolling audit keeps the latest bounded frame evidence.
///
/// Input: a one-frame rolling audit and two spooled MJPEG payloads. Output:
/// one overwritten frame file plus two index rows with distinct source
/// sequences. Why: long live repro sessions must not leave only stale startup
/// frames when the user records a later artifact.
#[test]
#[serial_test::serial(audit)]
fn spool_mjpeg_frame_rolls_enabled_boundary_audit() {
super::audit::reset_spool_audit_counter_for_test();
let dir = tempfile::tempdir().expect("tempdir");
let frame = dir.path().join("frame.mjpg");
let audit = dir.path().join("audit");
temp_env::with_vars(
[
("LESAVKA_UVC_FRAME_META", None::<&str>),
(
"LESAVKA_UVC_FRAME_AUDIT_DIR",
Some(audit.to_str().expect("utf8 path")),
),
("LESAVKA_UVC_FRAME_AUDIT_EVERY", Some("1")),
("LESAVKA_UVC_FRAME_AUDIT_MAX_FRAMES", Some("1")),
("LESAVKA_UVC_FRAME_AUDIT_ROLLING", Some("1")),
],
|| {
super::spool_mjpeg_frame_with_timing(
&frame,
b"first-jpeg",
Some(super::MjpegSpoolTiming::mjpeg_passthrough(111)),
)
.expect("spool first audited frame");
super::spool_mjpeg_frame_with_timing(
&frame,
b"latest-jpeg",
Some(super::MjpegSpoolTiming::mjpeg_passthrough(222)),
)
.expect("spool rolling audited frame");
},
);
let audited_frames = std::fs::read_dir(&audit)
.expect("audit dir")
.filter_map(Result::ok)
.filter(|entry| entry.file_name().to_string_lossy().ends_with(".mjpg"))
.collect::<Vec<_>>();
assert_eq!(audited_frames.len(), 1);
assert_eq!(
std::fs::read(audited_frames[0].path()).expect("audit frame"),
b"latest-jpeg"
);
let index = std::fs::read_to_string(audit.join("spool-audit.jsonl")).expect("audit index");
assert_eq!(index.lines().count(), 2);
assert!(index.contains("\"slot\":1"));
assert!(index.contains("\"source_pts_us\":111"));
assert!(index.contains("\"source_pts_us\":222"));
}

View File

@ -101,7 +101,7 @@ fn core_script_keeps_uvc_output_on_supported_mjpeg_descriptor() {
"apply_uvc_payload_limits", "apply_uvc_payload_limits",
"UVC_MAXPACKET=${LESAVKA_UVC_MAXPACKET:-1024}", "UVC_MAXPACKET=${LESAVKA_UVC_MAXPACKET:-1024}",
"uvc_mjpeg_frame_size_for_fps()", "uvc_mjpeg_frame_size_for_fps()",
"UVC_MJPEG_BUDGET_BYTES_PER_SEC=${LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC:-10000000}", "UVC_MJPEG_BUDGET_BYTES_PER_SEC=${LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC:-4500000}",
"UVC_ISOCHRONOUS_LIMIT_PCT=${LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT:-85}", "UVC_ISOCHRONOUS_LIMIT_PCT=${LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT:-85}",
"uvc_isochronous_budget_bytes_per_sec()", "uvc_isochronous_budget_bytes_per_sec()",
"isoch_budget=\"$(uvc_isochronous_budget_bytes_per_sec)\"", "isoch_budget=\"$(uvc_isochronous_budget_bytes_per_sec)\"",

View File

@ -192,11 +192,11 @@ fn server_install_pins_hdmi_camera_and_display_defaults() {
assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_HEVC_SPOOL_PULL_TIMEOUT_MS:-20}")); assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_HEVC_SPOOL_PULL_TIMEOUT_MS:-20}"));
assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_HEVC_FRESHNESS_QUEUE_BUFFERS:-2}")); assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_HEVC_FRESHNESS_QUEUE_BUFFERS:-2}"));
assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_HEVC_DECODE_MISS_LIMIT:-15}")); assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_HEVC_DECODE_MISS_LIMIT:-15}"));
assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE:-0}")); assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE:-1}"));
assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_DIRECT_MJPEG_JPEG_QUALITY:-72}")); assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_DIRECT_MJPEG_JPEG_QUALITY:-60}"));
assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_PULL_TIMEOUT_MS:-25}")); assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_PULL_TIMEOUT_MS:-25}"));
assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_MISS_LIMIT:-30}")); assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_MISS_LIMIT:-30}"));
assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_RSS_LIMIT_MB:-768}")); assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_RSS_LIMIT_MB:-384}"));
assert!( assert!(
SERVER_INSTALL.contains("lesavka_server::video=info"), SERVER_INSTALL.contains("lesavka_server::video=info"),
"server installs should not leave the hot webcam frame path at debug logging by default" "server installs should not leave the hot webcam frame path at debug logging by default"

View File

@ -76,7 +76,7 @@ fn assert_ordered(haystack: &str, earlier: &str, later: &str) {
} }
#[test] #[test]
fn direct_mjpeg_normalizer_is_opt_in_after_native_rss_leak() { fn direct_mjpeg_normalizer_defaults_on_with_rss_fuse_after_native_rss_leak() {
temp_env::with_vars( temp_env::with_vars(
[ [
("LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE", None::<&str>), ("LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE", None::<&str>),
@ -87,10 +87,10 @@ fn direct_mjpeg_normalizer_is_opt_in_after_native_rss_leak() {
], ],
|| { || {
assert!( assert!(
!guard::normalizer_enabled(), guard::normalizer_enabled(),
"direct MJPEG normalization must stay opt-in after the native RSS leak" "direct MJPEG normalization should be active but RSS-fused while chasing UVC artifacts"
); );
assert_eq!(guard::normalizer_rss_limit_kb(), Some(768 * 1024)); assert_eq!(guard::normalizer_rss_limit_kb(), Some(384 * 1024));
}, },
); );
@ -107,10 +107,10 @@ fn direct_mjpeg_normalizer_is_opt_in_after_native_rss_leak() {
} }
#[test] #[test]
fn installer_keeps_the_leaking_native_normalizer_disabled_by_default() { fn installer_keeps_the_native_normalizer_memory_bounded_by_default() {
assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE:-0}")); assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE:-1}"));
assert!(!SERVER_INSTALL.contains("${LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE:-1}")); assert!(!SERVER_INSTALL.contains("${LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE:-0}"));
assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_RSS_LIMIT_MB:-768}")); assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_RSS_LIMIT_MB:-384}"));
} }
#[test] #[test]