diff --git a/AGENTS.md b/AGENTS.md index fa254eb..6b74f0d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -393,3 +393,19 @@ evidence. - [x] Run shell syntax checks, focused contract tests, and package checks. - [x] Push clean semver `0.17.18` for installed client/server testing. - [ ] Re-run the mirrored probe only after confirming the intended microphone is physically present and selected. + +## 0.17.19 Fatal Required Source Failure Checklist + +Context: the 0.17.18 run proved fallback was blocked, but the headless client kept running +camera-only after the required Bumblebee source failed to open. The server stayed in `acquiring` +for all four segments (`awaiting both upstream media streams`), the analyzer saw no color-coded +video pulses, and no calibration data was produced. Required-source failure must fail the probe, +not degrade into camera-only evidence. + +- [x] Treat the 0.17.18 run as a required-microphone setup failure, not a lip-sync measurement. +- [x] Keep strict no-fallback behavior from 0.17.18. +- [x] Abort the client process when an explicit required microphone source cannot start. +- [x] Abort the client process when an explicit required camera source cannot start. +- [x] Run shell syntax checks, focused contract tests, and package checks. +- [x] Push clean semver `0.17.19` for installed client/server testing. +- [ ] Re-run only after `LESAVKA_MIC_SOURCE` is listed by the local audio stack. diff --git a/Cargo.lock b/Cargo.lock index 806e0a9..1b6a39c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.17.18" +version = "0.17.19" dependencies = [ "anyhow", "async-stream", @@ -1686,7 +1686,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.17.18" +version = "0.17.19" dependencies = [ "anyhow", "base64", @@ -1698,7 +1698,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.17.18" +version = "0.17.19" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index 3c793df..ec76e19 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.17.18" +version = "0.17.19" edition = "2024" [dependencies] diff --git a/client/src/app/uplink_media.rs b/client/src/app/uplink_media.rs index 945ac28..60d5d2b 100644 --- a/client/src/app/uplink_media.rs +++ b/client/src/app/uplink_media.rs @@ -40,6 +40,7 @@ impl LesavkaClientApp { "🎤 microphone uplink setup failed for {:?}: {err:#}", active_source.as_deref().unwrap_or("auto") ); + abort_if_required_media_source_failed("microphone", "🎤", active_source.as_deref(), &err); delay = app_support::next_delay(delay); tokio::time::sleep(delay).await; continue; @@ -47,6 +48,12 @@ impl LesavkaClientApp { Err(err) => { telemetry.record_disconnect(format!("microphone uplink setup task failed: {err}")); warn!("🎤 microphone uplink setup task failed before StreamMicrophone could start: {err}"); + abort_if_required_media_source_failed( + "microphone", + "🎤", + active_source.as_deref(), + &err, + ); delay = app_support::next_delay(delay); tokio::time::sleep(delay).await; continue; @@ -216,6 +223,7 @@ impl LesavkaClientApp { "📸 webcam uplink setup failed for {:?}: {err:#}", active_source.as_deref().unwrap_or("auto") ); + abort_if_required_media_source_failed("camera", "📸", active_source.as_deref(), &err); delay = app_support::next_delay(delay); tokio::time::sleep(delay).await; continue; @@ -223,6 +231,12 @@ impl LesavkaClientApp { Err(err) => { telemetry.record_disconnect(format!("webcam uplink setup task failed: {err}")); warn!("📸 webcam uplink setup task failed before StreamCamera could start: {err}"); + abort_if_required_media_source_failed( + "camera", + "📸", + active_source.as_deref(), + &err, + ); delay = app_support::next_delay(delay); tokio::time::sleep(delay).await; continue; @@ -412,6 +426,39 @@ fn parse_camera_profile_id(raw: &str) -> Option<(u32, u32, u32)> { (width > 0 && height > 0 && fps > 0).then_some((width, height, fps)) } +#[cfg(not(coverage))] +fn abort_if_required_media_source_failed( + kind: &str, + icon: &str, + source: Option<&str>, + err: &dyn std::fmt::Display, +) { + if !explicit_media_sources_required() || source.is_none_or(|source| source.trim().is_empty()) { + return; + } + let source = source.expect("checked source presence"); + error!( + "{icon} required {kind} source '{source}' failed to start; aborting client because LESAVKA_REQUIRE_EXPLICIT_MEDIA_SOURCES=1: {err}" + ); + eprintln!( + "{icon} required {kind} source '{source}' failed to start; aborting client because LESAVKA_REQUIRE_EXPLICIT_MEDIA_SOURCES=1: {err}" + ); + std::process::exit(2); +} + +#[cfg(not(coverage))] +fn explicit_media_sources_required() -> bool { + std::env::var("LESAVKA_REQUIRE_EXPLICIT_MEDIA_SOURCES") + .ok() + .is_some_and(|value| { + let value = value.trim(); + value == "1" + || value.eq_ignore_ascii_case("true") + || value.eq_ignore_ascii_case("yes") + || value.eq_ignore_ascii_case("on") + }) +} + #[cfg(not(coverage))] const VIDEO_UPLINK_QUEUE: crate::uplink_fresh_queue::FreshQueueConfig = crate::uplink_fresh_queue::FreshQueueConfig { diff --git a/common/Cargo.toml b/common/Cargo.toml index 6ad1733..bb188a0 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.17.18" +version = "0.17.19" edition = "2024" build = "build.rs" diff --git a/server/Cargo.toml b/server/Cargo.toml index 599ed57..6c2ceea 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.17.18" +version = "0.17.19" edition = "2024" autobins = false diff --git a/testing/tests/client_uplink_freshness_contract.rs b/testing/tests/client_uplink_freshness_contract.rs index f12bdc6..eb6bd6f 100644 --- a/testing/tests/client_uplink_freshness_contract.rs +++ b/testing/tests/client_uplink_freshness_contract.rs @@ -121,3 +121,20 @@ fn sync_probe_audio_queue_preserves_bounded_marker_continuity() { ); assert_queue_policy(block, "PROBE_AUDIO_QUEUE", "DrainOldest"); } + +#[test] +fn strict_explicit_media_source_failures_abort_the_headless_probe_client() { + for expected in [ + "LESAVKA_REQUIRE_EXPLICIT_MEDIA_SOURCES", + "abort_if_required_media_source_failed", + "required {kind} source '{source}' failed to start", + "std::process::exit(2)", + "abort_if_required_media_source_failed(\"microphone\"", + "abort_if_required_media_source_failed(\"camera\"", + ] { + assert!( + UPLINK_MEDIA_SRC.contains(expected), + "required-source setup failures must be fatal in strict probe mode: missing {expected}" + ); + } +}