diff --git a/Cargo.toml b/Cargo.toml index 11eaef5..dd96385 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -561,6 +561,10 @@ path = "tests/regression/server/gadget/server_gadget_recovery_contract.rs" name = "server_main_usb_recovery_contract" path = "tests/regression/server/main/server_main_usb_recovery_contract.rs" +[[test]] +name = "server_mjpeg_normalizer_memory_regression" +path = "tests/regression/server/video_sinks/server_mjpeg_normalizer_memory_regression.rs" + [[test]] name = "client_log_noise_contract" path = "tests/reliability/client/diagnostics/client_log_noise_contract.rs" diff --git a/tests/contract/testing/quality_ratchet_evidence_contract.rs b/tests/contract/testing/quality_ratchet_evidence_contract.rs index c34b7b1..b1631d9 100644 --- a/tests/contract/testing/quality_ratchet_evidence_contract.rs +++ b/tests/contract/testing/quality_ratchet_evidence_contract.rs @@ -128,6 +128,10 @@ fn corrupt_video_and_codec_modes_have_guard_compatibility_and_chaos_evidence() { category: "chaos", path: "tests/chaos/server/video_sinks/hevc_mjpeg_guard_chaos_contract.rs", }, + EvidencePath { + category: "regression", + path: "tests/regression/server/video_sinks/server_mjpeg_normalizer_memory_regression.rs", + }, EvidencePath { category: "performance", path: "tests/performance/server/video_sinks/hevc_mjpeg_handoff_performance_contract.rs", 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 new file mode 100644 index 0000000..e279260 --- /dev/null +++ b/tests/regression/server/video_sinks/server_mjpeg_normalizer_memory_regression.rs @@ -0,0 +1,131 @@ +// Regression coverage for the direct-MJPEG normalizer RSS leak. +// +// Scope: lock down the safe production defaults and lifecycle cleanup for the +// server UVC MJPEG path. +// Targets: server/src/video_sinks/hevc_mjpeg_guard.rs, +// server/src/video_sinks/webcam_sink.rs, server/src/camera_runtime.rs, and +// the camera/upstream media RPC lifecycles. +// Why: the native GStreamer jpegdec/jpegenc normalizer can retain process RSS +// during long calls; future changes must keep it opt-in, guarded, and cleaned +// up when the client stream closes. + +mod video_support { + pub fn env_u32(name: &str, default: u32) -> u32 { + std::env::var(name) + .ok() + .and_then(|value| value.trim().parse().ok()) + .unwrap_or(default) + } +} + +#[allow(clippy::items_after_test_module)] +mod guard { + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/video_sinks/hevc_mjpeg_guard.rs" + )); + + pub fn normalizer_enabled() -> bool { + direct_mjpeg_normalize_enabled() + } + + pub fn normalizer_rss_limit_kb() -> Option { + direct_mjpeg_normalize_rss_limit_kb() + } +} + +const SERVER_INSTALL: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/scripts/install/server.sh" +)); +const WEBCAM_SINK: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/video_sinks/webcam_sink.rs" +)); +const CAMERA_RUNTIME: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/camera_runtime.rs" +)); +const CAMERA_RPC: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/main/relay_service/camera_stream_rpc.rs" +)); +const UPSTREAM_MEDIA_RPC: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/server/src/main/relay_service/upstream_media_rpc.rs" +)); + +fn assert_ordered(haystack: &str, earlier: &str, later: &str) { + let earlier_pos = haystack + .find(earlier) + .unwrap_or_else(|| panic!("missing earlier marker {earlier:?}")); + let later_pos = haystack + .find(later) + .unwrap_or_else(|| panic!("missing later marker {later:?}")); + assert!( + earlier_pos < later_pos, + "expected marker {earlier:?} before {later:?}" + ); +} + +#[test] +fn direct_mjpeg_normalizer_is_opt_in_after_native_rss_leak() { + temp_env::with_vars( + [ + ("LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE", None::<&str>), + ( + "LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_RSS_LIMIT_MB", + None::<&str>, + ), + ], + || { + assert!( + !guard::normalizer_enabled(), + "direct MJPEG normalization must stay opt-in after the native RSS leak" + ); + assert_eq!(guard::normalizer_rss_limit_kb(), Some(768 * 1024)); + }, + ); + + temp_env::with_vars( + [ + ("LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE", Some("on")), + ("LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_RSS_LIMIT_MB", Some("0")), + ], + || { + assert!(guard::normalizer_enabled()); + assert_eq!(guard::normalizer_rss_limit_kb(), None); + }, + ); +} + +#[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}")); +} + +#[test] +fn opt_in_normalizer_has_rss_fuse_before_per_frame_gstreamer_allocation() { + assert!(WEBCAM_SINK.contains("current_process_rss_kb")); + assert!(WEBCAM_SINK.contains("direct_mjpeg_normalize_rss_limit_kb")); + assert!(WEBCAM_SINK.contains( + "direct MJPEG normalization disabled because server RSS exceeded its safety limit" + )); + assert_ordered( + WEBCAM_SINK, + "direct_mjpeg_normalize_rss_limit_kb()", + "gst::Buffer::from_slice(pkt.data.clone())", + ); +} + +#[test] +fn closed_camera_streams_release_native_uvc_pipeline_state() { + assert!(CAMERA_RUNTIME.contains("pub async fn release_if_active")); + assert!(CAMERA_RUNTIME.contains("slot.take()")); + assert!(WEBCAM_SINK.contains("impl Drop for WebcamSink")); + assert!(WEBCAM_SINK.contains("set_state(gst::State::Null)")); + assert!(CAMERA_RPC.contains("camera_rt.release_if_active(camera_session_id).await")); + assert!(UPSTREAM_MEDIA_RPC.contains("camera_rt.release_if_active(camera_session_id).await")); +} diff --git a/tests/test-taxonomy-manifest.json b/tests/test-taxonomy-manifest.json index f017389..8850821 100644 --- a/tests/test-taxonomy-manifest.json +++ b/tests/test-taxonomy-manifest.json @@ -267,6 +267,10 @@ "category": "regression", "new": "tests/regression/server/main/server_main_usb_recovery_contract.rs" }, + { + "category": "regression", + "new": "tests/regression/server/video_sinks/server_mjpeg_normalizer_memory_regression.rs" + }, { "category": "contract", "new": "tests/contract/server/runtime_support/server_runtime_contract.rs"