diff --git a/Cargo.lock b/Cargo.lock index 15ba0ba..bc2b7fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1658,7 +1658,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.25.3" +version = "0.25.4" dependencies = [ "anyhow", "async-stream", @@ -1692,7 +1692,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.25.3" +version = "0.25.4" dependencies = [ "anyhow", "base64", @@ -1704,7 +1704,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.25.3" +version = "0.25.4" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index e88e5fe..4be7043 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.25.3" +version = "0.25.4" edition = "2024" [dependencies] diff --git a/common/Cargo.toml b/common/Cargo.toml index 28f7147..35fe8ca 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.25.3" +version = "0.25.4" edition = "2024" build = "build.rs" diff --git a/scripts/daemon/lesavka-core.sh b/scripts/daemon/lesavka-core.sh index 9d7c69d..c2bbac2 100755 --- a/scripts/daemon/lesavka-core.sh +++ b/scripts/daemon/lesavka-core.sh @@ -321,7 +321,7 @@ fi UVC_FRAME_SIZE=${LESAVKA_UVC_FRAME_SIZE:-$((UVC_WIDTH * UVC_HEIGHT * 2))} UVC_INTERVAL_30=${LESAVKA_UVC_INTERVAL_30:-333333} UVC_INTERVAL_20=${LESAVKA_UVC_INTERVAL_20:-500000} -UVC_MJPEG_BUDGET_BYTES_PER_SEC=${LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC:-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_budget_bytes_per_sec() { diff --git a/scripts/install/server.sh b/scripts/install/server.sh index a5c3660..fcf515c 100755 --- a/scripts/install/server.sh +++ b/scripts/install/server.sh @@ -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_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_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_STATS_PATH=$(uvc_env_value LESAVKA_UVC_STATS_PATH /run/lesavka-uvc-video-stats.json) 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_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_DIRECT_MJPEG_NORMALIZE=%s\n' "${LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE:-0}" - printf 'LESAVKA_UVC_DIRECT_MJPEG_JPEG_QUALITY=%s\n' "${LESAVKA_UVC_DIRECT_MJPEG_JPEG_QUALITY:-72}" + 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:-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_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_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}" diff --git a/server/Cargo.toml b/server/Cargo.toml index 3d23d93..d1ba5d7 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -16,7 +16,7 @@ bench = false [package] name = "lesavka_server" -version = "0.25.3" +version = "0.25.4" edition = "2024" autobins = false diff --git a/server/src/bin/lesavka-uvc.real.inc b/server/src/bin/lesavka-uvc.real.inc index ee60a37..790fde5 100644 --- a/server/src/bin/lesavka-uvc.real.inc +++ b/server/src/bin/lesavka-uvc.real.inc @@ -54,7 +54,7 @@ const DEFAULT_UVC_BUFFER_COUNT: u32 = 1; const DEFAULT_UVC_IDLE_PUMP_MS: u64 = 2; const DEFAULT_UVC_FRAME_MAX_AGE_MS: u64 = 1_000; 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 HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC: u32 = 8_000; const DEFAULT_UVC_STATS_INTERVAL_MS: u64 = 5_000; diff --git a/server/src/bin/lesavka_uvc/coverage_model.rs b/server/src/bin/lesavka_uvc/coverage_model.rs index de4414e..c98f0b1 100644 --- a/server/src/bin/lesavka_uvc/coverage_model.rs +++ b/server/src/bin/lesavka_uvc/coverage_model.rs @@ -59,7 +59,7 @@ const DEFAULT_UVC_FRAME_MAX_AGE_MS: u64 = 1_000; #[cfg(coverage)] const DEFAULT_UVC_QUEUE_PACING: bool = true; #[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)] const DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT: u32 = 85; #[cfg(coverage)] diff --git a/server/src/bin/tests/lesavka_uvc.rs b/server/src/bin/tests/lesavka_uvc.rs index a770a66..8d9203f 100644 --- a/server/src/bin/tests/lesavka_uvc.rs +++ b/server/src/bin/tests/lesavka_uvc.rs @@ -80,7 +80,7 @@ fn uvc_frame_max_bytes_defaults_to_freshness_budget_and_allows_override() { ], || { 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"), || { - assert_eq!(uvc_frame_max_bytes(sample_cfg()), 400_000); + assert_eq!(uvc_frame_max_bytes(sample_cfg()), 180_000); }); with_vars( diff --git a/server/src/video_sinks/hevc_mjpeg_guard.rs b/server/src/video_sinks/hevc_mjpeg_guard.rs index e012611..fe7b1af 100644 --- a/server/src/video_sinks/hevc_mjpeg_guard.rs +++ b/server/src/video_sinks/hevc_mjpeg_guard.rs @@ -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_MIN_REFERENCE_BYTES: u32 = 48 * 1024; const DEFAULT_DIRECT_MJPEG_PROFILE_MISMATCH_REJECT: bool = false; -const DEFAULT_DIRECT_MJPEG_NORMALIZE: bool = false; -const DEFAULT_DIRECT_MJPEG_JPEG_QUALITY: u32 = 72; +const DEFAULT_DIRECT_MJPEG_NORMALIZE: bool = true; +const DEFAULT_DIRECT_MJPEG_JPEG_QUALITY: u32 = 60; const DEFAULT_DIRECT_MJPEG_NORMALIZE_PULL_TIMEOUT_MS: u32 = 25; 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. #[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. /// /// Inputs: optional `LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE`. Output: true unless -/// explicitly enabled. Why: this path runs native GStreamer JPEG decode/encode -/// inside `lesavka-server`; it is useful for lab comparisons, but field -/// evidence showed long-running RSS growth from the native allocator/buffer -/// pools, so guarded passthrough is the production-safe default. +/// explicitly disabled. Why: direct MJPEG camera frames can still stress the +/// high-speed isochronous UVC link; normalizing them to a simpler, smaller +/// JPEG bitstream before the gadget is safer than trusting passthrough bytes. pub(super) fn direct_mjpeg_normalize_enabled() -> bool { std::env::var("LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE") .ok() diff --git a/server/src/video_sinks/hevc_mjpeg_guard/tests.rs b/server/src/video_sinks/hevc_mjpeg_guard/tests.rs index ad8002d..ccc2e1b 100644 --- a/server/src/video_sinks/hevc_mjpeg_guard/tests.rs +++ b/server/src/video_sinks/hevc_mjpeg_guard/tests.rs @@ -14,7 +14,7 @@ fn hevc_jpeg_quality_defaults_to_moderate_transport_pressure() { } #[test] -fn direct_mjpeg_normalization_defaults_off_and_clamps_tuning() { +fn direct_mjpeg_normalization_defaults_on_and_clamps_tuning() { temp_env::with_vars( [ ("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_eq!(super::direct_mjpeg_jpeg_quality(), 72); + assert!(super::direct_mjpeg_normalize_enabled()); + assert_eq!(super::direct_mjpeg_jpeg_quality(), 60); 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_rss_limit_kb(), - Some(768 * 1024) + Some(384 * 1024) ); }, ); diff --git a/server/src/video_sinks/mjpeg_spool.rs b/server/src/video_sinks/mjpeg_spool.rs index 02ee027..1281027 100644 --- a/server/src/video_sinks/mjpeg_spool.rs +++ b/server/src/video_sinks/mjpeg_spool.rs @@ -13,7 +13,7 @@ mod audit; static SPOOL_SEQUENCE: AtomicU64 = AtomicU64::new(1); static SPOOL_TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(1); const MAX_MJPEG_FRAME_BYTES: usize = 8 * 1024 * 1024; -const DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC: u32 = 10_000_000; +const DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC: u32 = 4_500_000; const DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT: u32 = 85; const HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC: u32 = 8_000; const CONFIGFS_UVC_BASE: &str = "/sys/kernel/config/usb_gadget/lesavka/functions/uvc.usb0"; @@ -377,8 +377,8 @@ pub(super) fn spool_mjpeg_frame_with_timing( } if let (Some(dir), Some(sequence)) = (audit_dir, sequence) - && audit::claim_spool_audit_frame(sequence) - && let Err(err) = audit::write_mjpeg_spool_audit_frame(&dir, sequence, data, timing) + && let Some(slot) = audit::claim_spool_audit_frame(sequence) + && let Err(err) = audit::write_mjpeg_spool_audit_frame(&dir, sequence, slot, data, timing) { tracing::warn!( target: "lesavka_server::video", diff --git a/server/src/video_sinks/mjpeg_spool/audit.rs b/server/src/video_sinks/mjpeg_spool/audit.rs index 8f1397c..0ef14b3 100644 --- a/server/src/video_sinks/mjpeg_spool/audit.rs +++ b/server/src/video_sinks/mjpeg_spool/audit.rs @@ -10,9 +10,15 @@ static AUDIT_TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(1); static AUDIT_SAVED_FRAMES: AtomicU64 = AtomicU64::new(0); const DEFAULT_UVC_FRAME_AUDIT_EVERY: u32 = 1; const DEFAULT_UVC_FRAME_AUDIT_MAX_FRAMES: u32 = 1800; +const DEFAULT_UVC_FRAME_AUDIT_ROLLING: bool = true; const FNV1A64_OFFSET: u64 = 0xcbf29ce484222325; 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. /// /// 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. /// /// 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)) } -/// 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 -/// allow a save. Why: the max cap must count saved audit frames, not every -/// frame the long-running server emitted before the audit started. -pub(super) fn claim_spool_audit_frame(sequence: u64) -> bool { +/// Inputs: global spool sequence. Output: the bounded audit file slot, if +/// sampling and retention allow a save. Why: rolling audits must stay disk +/// bounded while still keeping the frames nearest the user's recording. +pub(super) fn claim_spool_audit_frame(sequence: u64) -> Option { if !should_sample_spool_audit_frame(sequence, mjpeg_spool_audit_every()) { - return false; + return None; } 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. @@ -146,6 +177,7 @@ pub(super) fn claim_spool_audit_frame(sequence: u64) -> bool { /// RCT frame with the server-boundary payload that preceded it. fn format_audit_record( sequence: u64, + slot: u64, data: &[u8], timing: Option, frame_file: &str, @@ -154,8 +186,9 @@ fn format_audit_record( let source_pts_us = timing.and_then(|value| value.source_pts_us); let decoded_pts_us = timing.and_then(|value| value.decoded_pts_us); 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, + slot, json_escape(profile), data.len(), json_number_or_null(source_pts_us), @@ -174,11 +207,12 @@ fn format_audit_record( pub(super) fn write_mjpeg_spool_audit_frame( dir: &Path, sequence: u64, + slot: u64, data: &[u8], timing: Option, ) -> anyhow::Result<()> { 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!( ".{frame_file}.{}.{}.tmp", std::process::id(), @@ -187,6 +221,6 @@ pub(super) fn write_mjpeg_spool_audit_frame( fs::write(&tmp, data)?; 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) } diff --git a/server/src/video_sinks/mjpeg_spool/tests.rs b/server/src/video_sinks/mjpeg_spool/tests.rs index b04e521..1a4a9c5 100644 --- a/server/src/video_sinks/mjpeg_spool/tests.rs +++ b/server/src/video_sinks/mjpeg_spool/tests.rs @@ -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>), ], || { - 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_EVERY", 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_every(), 1); 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_EVERY", Some("0")), ("LESAVKA_UVC_FRAME_AUDIT_MAX_FRAMES", Some("0")), + ("LESAVKA_UVC_FRAME_AUDIT_ROLLING", Some("off")), ], || { 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_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 /// whether corruption exists before or after the server-to-UVC boundary. #[test] +#[serial_test::serial(audit)] fn spool_mjpeg_frame_writes_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"); @@ -338,6 +344,7 @@ fn spool_mjpeg_frame_writes_enabled_boundary_audit() { ), ("LESAVKA_UVC_FRAME_AUDIT_EVERY", Some("1")), ("LESAVKA_UVC_FRAME_AUDIT_MAX_FRAMES", Some("1")), + ("LESAVKA_UVC_FRAME_AUDIT_ROLLING", Some("0")), ], || { 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("\"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::>(); + 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")); +} diff --git a/tests/contract/scripts/daemon/server_core_script_contract.rs b/tests/contract/scripts/daemon/server_core_script_contract.rs index 240108b..37159d6 100644 --- a/tests/contract/scripts/daemon/server_core_script_contract.rs +++ b/tests/contract/scripts/daemon/server_core_script_contract.rs @@ -101,7 +101,7 @@ fn core_script_keeps_uvc_output_on_supported_mjpeg_descriptor() { "apply_uvc_payload_limits", "UVC_MAXPACKET=${LESAVKA_UVC_MAXPACKET:-1024}", "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_budget_bytes_per_sec()", "isoch_budget=\"$(uvc_isochronous_budget_bytes_per_sec)\"", diff --git a/tests/contract/scripts/install/server_install_script_contract.rs b/tests/contract/scripts/install/server_install_script_contract.rs index 02c77fe..6224069 100644 --- a/tests/contract/scripts/install/server_install_script_contract.rs +++ b/tests/contract/scripts/install/server_install_script_contract.rs @@ -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_FRESHNESS_QUEUE_BUFFERS:-2}")); 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_JPEG_QUALITY:-72}")); + assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE:-1}")); + 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_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!( SERVER_INSTALL.contains("lesavka_server::video=info"), "server installs should not leave the hot webcam frame path at debug logging by default" diff --git a/tests/regression/server/video_sinks/server_mjpeg_normalizer_memory_regression.rs b/tests/regression/server/video_sinks/server_mjpeg_normalizer_memory_regression.rs index 9733014..2db0ac6 100644 --- a/tests/regression/server/video_sinks/server_mjpeg_normalizer_memory_regression.rs +++ b/tests/regression/server/video_sinks/server_mjpeg_normalizer_memory_regression.rs @@ -76,7 +76,7 @@ fn assert_ordered(haystack: &str, earlier: &str, later: &str) { } #[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( [ ("LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE", None::<&str>), @@ -87,10 +87,10 @@ fn direct_mjpeg_normalizer_is_opt_in_after_native_rss_leak() { ], || { assert!( - !guard::normalizer_enabled(), - "direct MJPEG normalization must stay opt-in after the native RSS leak" + guard::normalizer_enabled(), + "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] -fn installer_keeps_the_leaking_native_normalizer_disabled_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_RSS_LIMIT_MB:-768}")); +fn installer_keeps_the_native_normalizer_memory_bounded_by_default() { + 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:-384}")); } #[test]