From bbf799ea421e209cb70c7d749529437c798040de Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Thu, 30 Apr 2026 12:39:26 -0300 Subject: [PATCH] release: ship lesavka 0.16.3 --- Cargo.lock | 6 +- client/Cargo.toml | 2 +- common/Cargo.toml | 2 +- docs/operational-env.md | 7 +- server/Cargo.toml | 2 +- server/src/audio/ear_capture.rs | 46 ++- .../tests/server_audio_include_contract.rs | 8 + .../server_upstream_media_pairing_contract.rs | 282 +++++++++--------- ...stream_media_pairing_freshness_contract.rs | 122 ++++---- 9 files changed, 248 insertions(+), 229 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e5b3f54..f9d36e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.16.2" +version = "0.16.3" dependencies = [ "anyhow", "async-stream", @@ -1686,7 +1686,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.16.2" +version = "0.16.3" dependencies = [ "anyhow", "base64", @@ -1698,7 +1698,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.16.2" +version = "0.16.3" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index 254811f..4a6cc3b 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.16.2" +version = "0.16.3" edition = "2024" [dependencies] diff --git a/common/Cargo.toml b/common/Cargo.toml index cdeecd7..dfa4462 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.16.2" +version = "0.16.3" edition = "2024" build = "build.rs" diff --git a/docs/operational-env.md b/docs/operational-env.md index 647e499..9dc47b0 100644 --- a/docs/operational-env.md +++ b/docs/operational-env.md @@ -21,12 +21,13 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta | `LESAVKA_AUDIO_DISABLE` | client media capture/playback override | | `LESAVKA_AUDIO_GAIN` | client media capture/playback override | | `LESAVKA_AUDIO_GAIN_CONTROL` | client media capture/playback override | +| `LESAVKA_AUDIO_FAIL_ON_IDLE` | server remote-speaker capture debug guardrail; default keeps idle UAC streams open | | `LESAVKA_AUDIO_INIT_ATTEMPTS` | client media capture/playback override | | `LESAVKA_AUDIO_INIT_DELAY_MS` | client media capture/playback override | -| `LESAVKA_AUDIO_MIN_PACKETS_PER_SEC` | client media capture/playback override | +| `LESAVKA_AUDIO_MIN_PACKETS_PER_SEC` | server remote-speaker capture debug guardrail; unset disables packet-rate hard failures | | `LESAVKA_AUDIO_SINK` | client media capture/playback override | -| `LESAVKA_AUDIO_SOURCE_GRACE_MS` | client media capture/playback override | -| `LESAVKA_AUDIO_SOURCE_IDLE_MS` | client media capture/playback override | +| `LESAVKA_AUDIO_SOURCE_GRACE_MS` | server remote-speaker capture startup grace before optional watchdog checks | +| `LESAVKA_AUDIO_SOURCE_IDLE_MS` | server remote-speaker capture idle window used only when idle hard-fail is enabled | | `LESAVKA_BOOL_TEST` | test/build contract variable; not runtime operator config | | `LESAVKA_BREAKOUT_PREVIEW_HEIGHT` | eye preview/video transport override | | `LESAVKA_BREAKOUT_PREVIEW_MAX_KBIT` | eye preview/video transport override | diff --git a/server/Cargo.toml b/server/Cargo.toml index 4d96022..79d2434 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.16.2" +version = "0.16.3" edition = "2024" autobins = false diff --git a/server/src/audio/ear_capture.rs b/server/src/audio/ear_capture.rs index 7c66d56..7916848 100644 --- a/server/src/audio/ear_capture.rs +++ b/server/src/audio/ear_capture.rs @@ -367,25 +367,6 @@ impl AudioSourceHealth { } } -#[cfg(not(coverage))] -#[derive(Clone, Copy)] -struct AudioWatchdogPolicy { - startup_grace: Duration, - idle_timeout: Duration, - min_packets_per_second: u64, -} - -#[cfg(not(coverage))] -impl AudioWatchdogPolicy { - fn from_env() -> Self { - Self { - startup_grace: env_duration_ms("LESAVKA_AUDIO_SOURCE_GRACE_MS", 3_000), - idle_timeout: env_duration_ms("LESAVKA_AUDIO_SOURCE_IDLE_MS", 1_500), - min_packets_per_second: env_u64("LESAVKA_AUDIO_MIN_PACKETS_PER_SEC", 20), - } - } -} - #[cfg(not(coverage))] fn env_duration_ms(name: &str, default_ms: u64) -> Duration { Duration::from_millis(env_u64(name, default_ms)) @@ -400,8 +381,14 @@ fn env_u64(name: &str, default: u64) -> u64 { .unwrap_or(default) } -/// Watch the remote speaker capture source and fail fast when the USB audio -/// gadget is open but not producing real-time packets. +#[cfg(not(coverage))] +fn env_bool(name: &str) -> bool { + matches!(std::env::var(name).as_deref(), Ok("1" | "true" | "yes" | "on")) +} + +/// Watch the remote speaker capture source without turning normal UAC silence +/// into a reconnect loop. Hard-fail idle/cadence checks only when an operator +/// explicitly enables those lab guardrails through the documented env knobs. #[cfg(not(coverage))] fn spawn_audio_source_watchdog( pipeline: gst::Pipeline, @@ -409,7 +396,10 @@ fn spawn_audio_source_watchdog( tx: tokio::sync::mpsc::Sender>, alsa_dev: String, ) { - let policy = AudioWatchdogPolicy::from_env(); + let startup_grace = env_duration_ms("LESAVKA_AUDIO_SOURCE_GRACE_MS", 3_000); + let idle_timeout = env_duration_ms("LESAVKA_AUDIO_SOURCE_IDLE_MS", 1_500); + let fail_on_idle = env_bool("LESAVKA_AUDIO_FAIL_ON_IDLE"); + let min_packets_per_second = env_u64("LESAVKA_AUDIO_MIN_PACKETS_PER_SEC", 0); std::thread::spawn(move || { loop { std::thread::sleep(Duration::from_millis(250)); @@ -418,7 +408,7 @@ fn spawn_audio_source_watchdog( } let elapsed = health.elapsed(); - if elapsed < policy.startup_grace { + if elapsed < startup_grace { continue; } @@ -426,20 +416,22 @@ fn spawn_audio_source_watchdog( let idle_for = health.idle_for(); let rate = packets as f64 / elapsed.as_secs_f64().max(0.001); - let failure = if packets == 0 { + let failure = if fail_on_idle && packets == 0 { Some(format!( "remote speaker capture produced no audio samples after {} ms on {alsa_dev}", elapsed.as_millis() )) - } else if idle_for >= policy.idle_timeout { + } else if fail_on_idle && idle_for >= idle_timeout { Some(format!( "remote speaker capture stalled for {} ms on {alsa_dev}", idle_for.as_millis() )) - } else if (packets / elapsed.as_secs().max(1)) < policy.min_packets_per_second { + } else if min_packets_per_second > 0 + && packets / elapsed.as_secs().max(1) < min_packets_per_second + { Some(format!( "remote speaker capture cadence is too low on {alsa_dev}: {rate:.1} packets/s, expected at least {} packets/s", - policy.min_packets_per_second + min_packets_per_second )) } else { None diff --git a/testing/tests/server_audio_include_contract.rs b/testing/tests/server_audio_include_contract.rs index 280cabe..b4a7a8f 100644 --- a/testing/tests/server_audio_include_contract.rs +++ b/testing/tests/server_audio_include_contract.rs @@ -51,6 +51,14 @@ mod tests { ); } + #[test] + fn speaker_capture_watchdog_does_not_hard_fail_idle_audio_by_default() { + assert!(AUDIO_SRC.contains("LESAVKA_AUDIO_FAIL_ON_IDLE")); + assert!(AUDIO_SRC.contains("let fail_on_idle = env_bool(\"LESAVKA_AUDIO_FAIL_ON_IDLE\")")); + assert!(AUDIO_SRC.contains("env_u64(\"LESAVKA_AUDIO_MIN_PACKETS_PER_SEC\", 0)")); + assert!(AUDIO_SRC.contains("remote speaker capture cadence is too low")); + } + #[test] #[serial] fn start_pipeline_or_reset_starts_empty_pipeline() { diff --git a/testing/tests/server_upstream_media_pairing_contract.rs b/testing/tests/server_upstream_media_pairing_contract.rs index 7f08d25..1f99f18 100644 --- a/testing/tests/server_upstream_media_pairing_contract.rs +++ b/testing/tests/server_upstream_media_pairing_contract.rs @@ -290,77 +290,83 @@ mod server_upstream_media_pairing { with_var("LESAVKA_CAPTURE_POWER_UNIT", Some("none"), || { with_var("LESAVKA_DISABLE_UVC", None::<&str>, || { with_var("LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS", Some("80"), || { - rt.block_on(async { - let (_dir, handler) = build_handler_for_tests(); - let (server, mut cli) = serve_handler(handler).await; - let (audio_tx, audio_rx) = tokio::sync::mpsc::channel(4); - let (video_tx, video_rx) = tokio::sync::mpsc::channel(4); + with_var( + "LESAVKA_UPSTREAM_CAMERA_STARTUP_GRACE_US", + Some("0"), + || { + rt.block_on(async { + let (_dir, handler) = build_handler_for_tests(); + let (server, mut cli) = serve_handler(handler).await; + let (audio_tx, audio_rx) = tokio::sync::mpsc::channel(4); + let (video_tx, video_rx) = tokio::sync::mpsc::channel(4); - let mut audio_response = cli - .stream_microphone(tonic::Request::new( - tokio_stream::wrappers::ReceiverStream::new(audio_rx), - )) - .await - .expect("microphone stream should open") - .into_inner(); - let mut video_response = cli - .stream_camera(tonic::Request::new( - tokio_stream::wrappers::ReceiverStream::new(video_rx), - )) - .await - .expect("camera stream should open") - .into_inner(); + let mut audio_response = cli + .stream_microphone(tonic::Request::new( + tokio_stream::wrappers::ReceiverStream::new(audio_rx), + )) + .await + .expect("microphone stream should open") + .into_inner(); + let mut video_response = cli + .stream_camera(tonic::Request::new( + tokio_stream::wrappers::ReceiverStream::new(video_rx), + )) + .await + .expect("camera stream should open") + .into_inner(); - audio_tx - .send(AudioPacket { - id: 0, - pts: 1_000_000, - data: vec![1, 2, 3, 4], - }) - .await - .expect("send leading audio packet"); - tokio::time::sleep(std::time::Duration::from_millis(20)).await; - video_tx - .send(VideoPacket { - id: 2, - pts: 1_300_000, - data: vec![0, 0, 0, 1, 0x65, 0x88], - ..Default::default() - }) - .await - .expect("send anchor video packet"); - audio_tx - .send(AudioPacket { - id: 0, - pts: 1_310_000, - data: vec![5, 6, 7, 8], - }) - .await - .expect("send post-anchor audio packet"); - drop(audio_tx); - drop(video_tx); + audio_tx + .send(AudioPacket { + id: 0, + pts: 1_000_000, + data: vec![1, 2, 3, 4], + }) + .await + .expect("send leading audio packet"); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + video_tx + .send(VideoPacket { + id: 2, + pts: 1_300_000, + data: vec![0, 0, 0, 1, 0x65, 0x88], + ..Default::default() + }) + .await + .expect("send anchor video packet"); + audio_tx + .send(AudioPacket { + id: 0, + pts: 1_310_000, + data: vec![5, 6, 7, 8], + }) + .await + .expect("send post-anchor audio packet"); + drop(audio_tx); + drop(video_tx); - let audio_ack = tokio::time::timeout( - std::time::Duration::from_secs(1), - audio_response.message(), - ) - .await - .expect("microphone ack timeout") - .expect("microphone ack grpc") - .expect("microphone ack item"); - let video_ack = tokio::time::timeout( - std::time::Duration::from_secs(1), - video_response.message(), - ) - .await - .expect("camera ack timeout") - .expect("camera ack grpc") - .expect("camera ack item"); - assert_eq!(audio_ack, Empty {}); - assert_eq!(video_ack, Empty {}); + let audio_ack = tokio::time::timeout( + std::time::Duration::from_secs(1), + audio_response.message(), + ) + .await + .expect("microphone ack timeout") + .expect("microphone ack grpc") + .expect("microphone ack item"); + let video_ack = tokio::time::timeout( + std::time::Duration::from_secs(1), + video_response.message(), + ) + .await + .expect("camera ack timeout") + .expect("camera ack grpc") + .expect("camera ack item"); + assert_eq!(audio_ack, Empty {}); + assert_eq!(video_ack, Empty {}); - server.abort(); - }); + server.abort(); + }); + }, + ); }); }); }); @@ -373,78 +379,84 @@ mod server_upstream_media_pairing { with_var("LESAVKA_CAPTURE_POWER_UNIT", Some("none"), || { with_var("LESAVKA_DISABLE_UVC", None::<&str>, || { with_var("LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS", Some("80"), || { - rt.block_on(async { - let (_dir, handler) = build_handler_for_tests(); - let (server, mut cli) = serve_handler(handler).await; - let (audio_tx, audio_rx) = tokio::sync::mpsc::channel(4); - let (video_tx, video_rx) = tokio::sync::mpsc::channel(4); + with_var( + "LESAVKA_UPSTREAM_CAMERA_STARTUP_GRACE_US", + Some("0"), + || { + rt.block_on(async { + let (_dir, handler) = build_handler_for_tests(); + let (server, mut cli) = serve_handler(handler).await; + let (audio_tx, audio_rx) = tokio::sync::mpsc::channel(4); + let (video_tx, video_rx) = tokio::sync::mpsc::channel(4); - let mut audio_response = cli - .stream_microphone(tonic::Request::new( - tokio_stream::wrappers::ReceiverStream::new(audio_rx), - )) - .await - .expect("microphone stream should open") - .into_inner(); - let mut video_response = cli - .stream_camera(tonic::Request::new( - tokio_stream::wrappers::ReceiverStream::new(video_rx), - )) - .await - .expect("camera stream should open") - .into_inner(); + let mut audio_response = cli + .stream_microphone(tonic::Request::new( + tokio_stream::wrappers::ReceiverStream::new(audio_rx), + )) + .await + .expect("microphone stream should open") + .into_inner(); + let mut video_response = cli + .stream_camera(tonic::Request::new( + tokio_stream::wrappers::ReceiverStream::new(video_rx), + )) + .await + .expect("camera stream should open") + .into_inner(); - video_tx - .send(VideoPacket { - id: 2, - pts: 1_000_000, - data: vec![0, 0, 0, 1, 0x65, 0x77], - ..Default::default() - }) - .await - .expect("send leading video packet"); - tokio::time::sleep(std::time::Duration::from_millis(20)).await; - audio_tx - .send(AudioPacket { - id: 0, - pts: 1_300_000, - data: vec![1, 2, 3, 4], - }) - .await - .expect("send anchor audio packet"); - video_tx - .send(VideoPacket { - id: 2, - pts: 1_310_000, - data: vec![0, 0, 0, 1, 0x65, 0x88], - ..Default::default() - }) - .await - .expect("send post-anchor video packet"); - drop(audio_tx); - drop(video_tx); + video_tx + .send(VideoPacket { + id: 2, + pts: 1_000_000, + data: vec![0, 0, 0, 1, 0x65, 0x77], + ..Default::default() + }) + .await + .expect("send leading video packet"); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + audio_tx + .send(AudioPacket { + id: 0, + pts: 1_300_000, + data: vec![1, 2, 3, 4], + }) + .await + .expect("send anchor audio packet"); + video_tx + .send(VideoPacket { + id: 2, + pts: 1_310_000, + data: vec![0, 0, 0, 1, 0x65, 0x88], + ..Default::default() + }) + .await + .expect("send post-anchor video packet"); + drop(audio_tx); + drop(video_tx); - let audio_ack = tokio::time::timeout( - std::time::Duration::from_secs(1), - audio_response.message(), - ) - .await - .expect("microphone ack timeout") - .expect("microphone ack grpc") - .expect("microphone ack item"); - let video_ack = tokio::time::timeout( - std::time::Duration::from_secs(1), - video_response.message(), - ) - .await - .expect("camera ack timeout") - .expect("camera ack grpc") - .expect("camera ack item"); - assert_eq!(audio_ack, Empty {}); - assert_eq!(video_ack, Empty {}); + let audio_ack = tokio::time::timeout( + std::time::Duration::from_secs(1), + audio_response.message(), + ) + .await + .expect("microphone ack timeout") + .expect("microphone ack grpc") + .expect("microphone ack item"); + let video_ack = tokio::time::timeout( + std::time::Duration::from_secs(1), + video_response.message(), + ) + .await + .expect("camera ack timeout") + .expect("camera ack grpc") + .expect("camera ack item"); + assert_eq!(audio_ack, Empty {}); + assert_eq!(video_ack, Empty {}); - server.abort(); - }); + server.abort(); + }); + }, + ); }); }); }); diff --git a/testing/tests/server_upstream_media_pairing_freshness_contract.rs b/testing/tests/server_upstream_media_pairing_freshness_contract.rs index cf3eb3f..365e594 100644 --- a/testing/tests/server_upstream_media_pairing_freshness_contract.rs +++ b/testing/tests/server_upstream_media_pairing_freshness_contract.rs @@ -140,68 +140,74 @@ mod server_upstream_media_pairing { with_var("LESAVKA_CAPTURE_POWER_UNIT", Some("none"), || { with_var("LESAVKA_DISABLE_UVC", None::<&str>, || { with_var("LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS", Some("80"), || { - rt.block_on(async { - let (_dir, handler) = build_handler_for_tests(); - let (server, mut cli) = serve_handler(handler).await; - let (audio_tx, audio_rx) = tokio::sync::mpsc::channel(4); - let (video_tx, video_rx) = tokio::sync::mpsc::channel(4); + with_var( + "LESAVKA_UPSTREAM_CAMERA_STARTUP_GRACE_US", + Some("0"), + || { + rt.block_on(async { + let (_dir, handler) = build_handler_for_tests(); + let (server, mut cli) = serve_handler(handler).await; + let (audio_tx, audio_rx) = tokio::sync::mpsc::channel(4); + let (video_tx, video_rx) = tokio::sync::mpsc::channel(4); - let mut audio_response = cli - .stream_microphone(tonic::Request::new( - tokio_stream::wrappers::ReceiverStream::new(audio_rx), - )) - .await - .expect("microphone stream should open") - .into_inner(); - let mut video_response = cli - .stream_camera(tonic::Request::new( - tokio_stream::wrappers::ReceiverStream::new(video_rx), - )) - .await - .expect("camera stream should open") - .into_inner(); + let mut audio_response = cli + .stream_microphone(tonic::Request::new( + tokio_stream::wrappers::ReceiverStream::new(audio_rx), + )) + .await + .expect("microphone stream should open") + .into_inner(); + let mut video_response = cli + .stream_camera(tonic::Request::new( + tokio_stream::wrappers::ReceiverStream::new(video_rx), + )) + .await + .expect("camera stream should open") + .into_inner(); - audio_tx - .send(AudioPacket { - id: 0, - pts: 1_000_000, - data: vec![1, 2, 3, 4], - }) - .await - .expect("send first audio packet"); - video_tx - .send(VideoPacket { - id: 2, - pts: 1_050_000, - data: vec![0, 0, 0, 1, 0x65, 0x55], - ..Default::default() - }) - .await - .expect("send unmatched video packet"); - drop(audio_tx); - drop(video_tx); + audio_tx + .send(AudioPacket { + id: 0, + pts: 1_000_000, + data: vec![1, 2, 3, 4], + }) + .await + .expect("send first audio packet"); + video_tx + .send(VideoPacket { + id: 2, + pts: 1_050_000, + data: vec![0, 0, 0, 1, 0x65, 0x55], + ..Default::default() + }) + .await + .expect("send unmatched video packet"); + drop(video_tx); - let audio_ack = tokio::time::timeout( - std::time::Duration::from_secs(1), - audio_response.message(), - ) - .await - .expect("microphone ack timeout") - .expect("microphone ack grpc") - .expect("microphone ack item"); - let video_ack = tokio::time::timeout( - std::time::Duration::from_secs(1), - video_response.message(), - ) - .await - .expect("camera ack timeout") - .expect("camera ack grpc") - .expect("camera ack item"); - assert_eq!(audio_ack, Empty {}); - assert_eq!(video_ack, Empty {}); + let video_ack = tokio::time::timeout( + std::time::Duration::from_secs(1), + video_response.message(), + ) + .await + .expect("camera ack timeout") + .expect("camera ack grpc") + .expect("camera ack item"); + assert_eq!(video_ack, Empty {}); + drop(audio_tx); + let audio_ack = tokio::time::timeout( + std::time::Duration::from_secs(1), + audio_response.message(), + ) + .await + .expect("microphone ack timeout") + .expect("microphone ack grpc") + .expect("microphone ack item"); + assert_eq!(audio_ack, Empty {}); - server.abort(); - }); + server.abort(); + }); + }, + ); }); }); });