release: ship lesavka 0.16.3

This commit is contained in:
Brad Stein 2026-04-30 12:39:26 -03:00
parent b0d1bba1eb
commit bbf799ea42
9 changed files with 248 additions and 229 deletions

6
Cargo.lock generated
View File

@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]] [[package]]
name = "lesavka_client" name = "lesavka_client"
version = "0.16.2" version = "0.16.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-stream", "async-stream",
@ -1686,7 +1686,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_common" name = "lesavka_common"
version = "0.16.2" version = "0.16.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@ -1698,7 +1698,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_server" name = "lesavka_server"
version = "0.16.2" version = "0.16.3"
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.16.2" version = "0.16.3"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View File

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

View File

@ -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_DISABLE` | client media capture/playback override |
| `LESAVKA_AUDIO_GAIN` | client media capture/playback override | | `LESAVKA_AUDIO_GAIN` | client media capture/playback override |
| `LESAVKA_AUDIO_GAIN_CONTROL` | 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_ATTEMPTS` | client media capture/playback override |
| `LESAVKA_AUDIO_INIT_DELAY_MS` | 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_SINK` | client media capture/playback override |
| `LESAVKA_AUDIO_SOURCE_GRACE_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` | client media capture/playback override | | `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_BOOL_TEST` | test/build contract variable; not runtime operator config |
| `LESAVKA_BREAKOUT_PREVIEW_HEIGHT` | eye preview/video transport override | | `LESAVKA_BREAKOUT_PREVIEW_HEIGHT` | eye preview/video transport override |
| `LESAVKA_BREAKOUT_PREVIEW_MAX_KBIT` | eye preview/video transport override | | `LESAVKA_BREAKOUT_PREVIEW_MAX_KBIT` | eye preview/video transport override |

View File

@ -10,7 +10,7 @@ bench = false
[package] [package]
name = "lesavka_server" name = "lesavka_server"
version = "0.16.2" version = "0.16.3"
edition = "2024" edition = "2024"
autobins = false autobins = false

View File

@ -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))] #[cfg(not(coverage))]
fn env_duration_ms(name: &str, default_ms: u64) -> Duration { fn env_duration_ms(name: &str, default_ms: u64) -> Duration {
Duration::from_millis(env_u64(name, default_ms)) Duration::from_millis(env_u64(name, default_ms))
@ -400,8 +381,14 @@ fn env_u64(name: &str, default: u64) -> u64 {
.unwrap_or(default) .unwrap_or(default)
} }
/// Watch the remote speaker capture source and fail fast when the USB audio #[cfg(not(coverage))]
/// gadget is open but not producing real-time packets. 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))] #[cfg(not(coverage))]
fn spawn_audio_source_watchdog( fn spawn_audio_source_watchdog(
pipeline: gst::Pipeline, pipeline: gst::Pipeline,
@ -409,7 +396,10 @@ fn spawn_audio_source_watchdog(
tx: tokio::sync::mpsc::Sender<Result<AudioPacket, Status>>, tx: tokio::sync::mpsc::Sender<Result<AudioPacket, Status>>,
alsa_dev: String, 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 || { std::thread::spawn(move || {
loop { loop {
std::thread::sleep(Duration::from_millis(250)); std::thread::sleep(Duration::from_millis(250));
@ -418,7 +408,7 @@ fn spawn_audio_source_watchdog(
} }
let elapsed = health.elapsed(); let elapsed = health.elapsed();
if elapsed < policy.startup_grace { if elapsed < startup_grace {
continue; continue;
} }
@ -426,20 +416,22 @@ fn spawn_audio_source_watchdog(
let idle_for = health.idle_for(); let idle_for = health.idle_for();
let rate = packets as f64 / elapsed.as_secs_f64().max(0.001); 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!( Some(format!(
"remote speaker capture produced no audio samples after {} ms on {alsa_dev}", "remote speaker capture produced no audio samples after {} ms on {alsa_dev}",
elapsed.as_millis() elapsed.as_millis()
)) ))
} else if idle_for >= policy.idle_timeout { } else if fail_on_idle && idle_for >= idle_timeout {
Some(format!( Some(format!(
"remote speaker capture stalled for {} ms on {alsa_dev}", "remote speaker capture stalled for {} ms on {alsa_dev}",
idle_for.as_millis() 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!( Some(format!(
"remote speaker capture cadence is too low on {alsa_dev}: {rate:.1} packets/s, expected at least {} packets/s", "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 { } else {
None None

View File

@ -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] #[test]
#[serial] #[serial]
fn start_pipeline_or_reset_starts_empty_pipeline() { fn start_pipeline_or_reset_starts_empty_pipeline() {

View File

@ -290,6 +290,10 @@ mod server_upstream_media_pairing {
with_var("LESAVKA_CAPTURE_POWER_UNIT", Some("none"), || { with_var("LESAVKA_CAPTURE_POWER_UNIT", Some("none"), || {
with_var("LESAVKA_DISABLE_UVC", None::<&str>, || { with_var("LESAVKA_DISABLE_UVC", None::<&str>, || {
with_var("LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS", Some("80"), || { with_var("LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS", Some("80"), || {
with_var(
"LESAVKA_UPSTREAM_CAMERA_STARTUP_GRACE_US",
Some("0"),
|| {
rt.block_on(async { rt.block_on(async {
let (_dir, handler) = build_handler_for_tests(); let (_dir, handler) = build_handler_for_tests();
let (server, mut cli) = serve_handler(handler).await; let (server, mut cli) = serve_handler(handler).await;
@ -361,6 +365,8 @@ mod server_upstream_media_pairing {
server.abort(); server.abort();
}); });
},
);
}); });
}); });
}); });
@ -373,6 +379,10 @@ mod server_upstream_media_pairing {
with_var("LESAVKA_CAPTURE_POWER_UNIT", Some("none"), || { with_var("LESAVKA_CAPTURE_POWER_UNIT", Some("none"), || {
with_var("LESAVKA_DISABLE_UVC", None::<&str>, || { with_var("LESAVKA_DISABLE_UVC", None::<&str>, || {
with_var("LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS", Some("80"), || { with_var("LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS", Some("80"), || {
with_var(
"LESAVKA_UPSTREAM_CAMERA_STARTUP_GRACE_US",
Some("0"),
|| {
rt.block_on(async { rt.block_on(async {
let (_dir, handler) = build_handler_for_tests(); let (_dir, handler) = build_handler_for_tests();
let (server, mut cli) = serve_handler(handler).await; let (server, mut cli) = serve_handler(handler).await;
@ -445,6 +455,8 @@ mod server_upstream_media_pairing {
server.abort(); server.abort();
}); });
},
);
}); });
}); });
}); });

View File

@ -140,6 +140,10 @@ mod server_upstream_media_pairing {
with_var("LESAVKA_CAPTURE_POWER_UNIT", Some("none"), || { with_var("LESAVKA_CAPTURE_POWER_UNIT", Some("none"), || {
with_var("LESAVKA_DISABLE_UVC", None::<&str>, || { with_var("LESAVKA_DISABLE_UVC", None::<&str>, || {
with_var("LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS", Some("80"), || { with_var("LESAVKA_UPSTREAM_PLAYOUT_DELAY_MS", Some("80"), || {
with_var(
"LESAVKA_UPSTREAM_CAMERA_STARTUP_GRACE_US",
Some("0"),
|| {
rt.block_on(async { rt.block_on(async {
let (_dir, handler) = build_handler_for_tests(); let (_dir, handler) = build_handler_for_tests();
let (server, mut cli) = serve_handler(handler).await; let (server, mut cli) = serve_handler(handler).await;
@ -178,17 +182,8 @@ mod server_upstream_media_pairing {
}) })
.await .await
.expect("send unmatched video packet"); .expect("send unmatched video packet");
drop(audio_tx);
drop(video_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( let video_ack = tokio::time::timeout(
std::time::Duration::from_secs(1), std::time::Duration::from_secs(1),
video_response.message(), video_response.message(),
@ -197,11 +192,22 @@ mod server_upstream_media_pairing {
.expect("camera ack timeout") .expect("camera ack timeout")
.expect("camera ack grpc") .expect("camera ack grpc")
.expect("camera ack item"); .expect("camera ack item");
assert_eq!(audio_ack, Empty {});
assert_eq!(video_ack, Empty {}); 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();
}); });
},
);
}); });
}); });
}); });