media: reset bundled upstream sessions

This commit is contained in:
Brad Stein 2026-05-13 19:12:11 -03:00
parent 602974b04e
commit 692c3a6545
21 changed files with 213 additions and 33 deletions

6
Cargo.lock generated
View File

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

View File

@ -64,6 +64,8 @@ impl InputAggregator {
self.pending_release_started_at = Some(Instant::now()); self.pending_release_started_at = Some(Instant::now());
self.last_keyboard_report = [0; 8]; self.last_keyboard_report = [0; 8];
self.capture_pending_keys(); self.capture_pending_keys();
#[cfg(not(coverage))]
self.publish_routing_state_if_changed();
} }
fn finish_local_release(&mut self, focus_launcher: bool) { fn finish_local_release(&mut self, focus_launcher: bool) {
@ -271,7 +273,7 @@ impl InputAggregator {
#[cfg(not(coverage))] #[cfg(not(coverage))]
fn publish_routing_state_if_changed(&mut self) { fn publish_routing_state_if_changed(&mut self) {
let remote_capture = !self.released; let remote_capture = self.remote_capture_active();
if self.published_remote_capture == Some(remote_capture) { if self.published_remote_capture == Some(remote_capture) {
return; return;
} }

View File

@ -58,27 +58,38 @@
let transport_combo = widgets.webcam_transport_combo.clone(); let transport_combo = widgets.webcam_transport_combo.clone();
let transport_combo_read = transport_combo.clone(); let transport_combo_read = transport_combo.clone();
transport_combo.connect_changed(move |_| { transport_combo.connect_changed(move |_| {
if widgets.webcam_transport_syncing.get() {
return;
}
let selected = transport_combo_read let selected = transport_combo_read
.active_id() .active_id()
.as_deref() .as_deref()
.and_then(WebcamTransport::from_id) .and_then(WebcamTransport::from_id)
.unwrap_or_default(); .unwrap_or_default();
state.borrow_mut().select_webcam_transport(selected); let Ok(mut state_mut) = state.try_borrow_mut() else {
return;
};
state_mut.select_webcam_transport(selected);
drop(state_mut);
let relay_live = child_proc.borrow().is_some(); let relay_live = child_proc.borrow().is_some();
if relay_live { if relay_live {
apply_live_media_device_change( if let Ok(state_ref) = state.try_borrow() {
&state.borrow(), apply_live_media_device_change(
&widgets, &state_ref,
&child_proc, &widgets,
"Webcam transport", &child_proc,
); "Webcam transport",
);
}
} else { } else {
widgets.status_label.set_text(&format!( widgets.status_label.set_text(&format!(
"Webcam transport set to {} for the next relay launch.", "Webcam transport set to {} for the next relay launch.",
selected.label() selected.label()
)); ));
} }
refresh_launcher_ui(&widgets, &state.borrow(), relay_live); if let Ok(state_ref) = state.try_borrow() {
refresh_launcher_ui(&widgets, &state_ref, relay_live);
}
}); });
} }
@ -89,29 +100,38 @@
let audio_combo = widgets.upstream_audio_transport_combo.clone(); let audio_combo = widgets.upstream_audio_transport_combo.clone();
let audio_combo_read = audio_combo.clone(); let audio_combo_read = audio_combo.clone();
audio_combo.connect_changed(move |_| { audio_combo.connect_changed(move |_| {
if widgets.upstream_audio_transport_syncing.get() {
return;
}
let selected = audio_combo_read let selected = audio_combo_read
.active_id() .active_id()
.as_deref() .as_deref()
.and_then(UpstreamAudioTransport::from_id) .and_then(UpstreamAudioTransport::from_id)
.unwrap_or_default(); .unwrap_or_default();
state let Ok(mut state_mut) = state.try_borrow_mut() else {
.borrow_mut() return;
.select_upstream_audio_transport(selected); };
state_mut.select_upstream_audio_transport(selected);
drop(state_mut);
let relay_live = child_proc.borrow().is_some(); let relay_live = child_proc.borrow().is_some();
if relay_live { if relay_live {
apply_live_media_device_change( if let Ok(state_ref) = state.try_borrow() {
&state.borrow(), apply_live_media_device_change(
&widgets, &state_ref,
&child_proc, &widgets,
"Microphone transport", &child_proc,
); "Microphone transport",
);
}
} else { } else {
widgets.status_label.set_text(&format!( widgets.status_label.set_text(&format!(
"Upstream microphone transport set to {} for the next relay launch.", "Upstream microphone transport set to {} for the next relay launch.",
selected.label() selected.label()
)); ));
} }
refresh_launcher_ui(&widgets, &state.borrow(), relay_live); if let Ok(state_ref) = state.try_borrow() {
refresh_launcher_ui(&widgets, &state_ref, relay_live);
}
}); });
} }

View File

@ -1,4 +1,7 @@
use std::{cell::RefCell, rc::Rc}; use std::{
cell::{Cell, RefCell},
rc::Rc,
};
use evdev::Device; use evdev::Device;
use gtk::{glib, pango, prelude::*}; use gtk::{glib, pango, prelude::*};

View File

@ -162,7 +162,9 @@
camera_test_button: camera_test_button.clone(), camera_test_button: camera_test_button.clone(),
camera_preview_stack: camera_preview_stack.clone(), camera_preview_stack: camera_preview_stack.clone(),
webcam_transport_combo: webcam_transport_combo.clone(), webcam_transport_combo: webcam_transport_combo.clone(),
webcam_transport_syncing: Rc::new(Cell::new(false)),
upstream_audio_transport_combo: upstream_audio_transport_combo.clone(), upstream_audio_transport_combo: upstream_audio_transport_combo.clone(),
upstream_audio_transport_syncing: Rc::new(Cell::new(false)),
camera_mirror_button: camera_mirror_button.clone(), camera_mirror_button: camera_mirror_button.clone(),
camera_mirror_revealer: camera_mirror_revealer.clone(), camera_mirror_revealer: camera_mirror_revealer.clone(),
microphone_test_button: microphone_test_button.clone(), microphone_test_button: microphone_test_button.clone(),

View File

@ -169,7 +169,9 @@ pub struct LauncherWidgets {
pub camera_test_button: gtk::Button, pub camera_test_button: gtk::Button,
pub camera_preview_stack: gtk::Stack, pub camera_preview_stack: gtk::Stack,
pub webcam_transport_combo: gtk::ComboBoxText, pub webcam_transport_combo: gtk::ComboBoxText,
pub webcam_transport_syncing: Rc<Cell<bool>>,
pub upstream_audio_transport_combo: gtk::ComboBoxText, pub upstream_audio_transport_combo: gtk::ComboBoxText,
pub upstream_audio_transport_syncing: Rc<Cell<bool>>,
pub camera_mirror_button: gtk::ToggleButton, pub camera_mirror_button: gtk::ToggleButton,
pub camera_mirror_revealer: gtk::Revealer, pub camera_mirror_revealer: gtk::Revealer,
pub microphone_test_button: gtk::Button, pub microphone_test_button: gtk::Button,

View File

@ -275,9 +275,11 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
if widgets.webcam_transport_combo.active_id().as_deref() if widgets.webcam_transport_combo.active_id().as_deref()
!= Some(state.effective_webcam_transport().as_id()) != Some(state.effective_webcam_transport().as_id())
{ {
widgets.webcam_transport_syncing.set(true);
widgets widgets
.webcam_transport_combo .webcam_transport_combo
.set_active_id(Some(state.effective_webcam_transport().as_id())); .set_active_id(Some(state.effective_webcam_transport().as_id()));
widgets.webcam_transport_syncing.set(false);
} }
widgets widgets
.webcam_transport_combo .webcam_transport_combo
@ -295,9 +297,11 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
.as_deref() .as_deref()
!= Some(state.upstream_audio_transport.as_id()) != Some(state.upstream_audio_transport.as_id())
{ {
widgets.upstream_audio_transport_syncing.set(true);
widgets widgets
.upstream_audio_transport_combo .upstream_audio_transport_combo
.set_active_id(Some(state.upstream_audio_transport.as_id())); .set_active_id(Some(state.upstream_audio_transport.as_id()));
widgets.upstream_audio_transport_syncing.set(false);
} }
widgets widgets
.upstream_audio_transport_combo .upstream_audio_transport_combo

View File

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

View File

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

View File

@ -7,8 +7,9 @@ impl Handler {
let rpc_id = runtime_support::next_stream_id(); let rpc_id = runtime_support::next_stream_id();
let request = req.into_inner(); let request = req.into_inner();
let camera_cfg = camera::current_camera_config(); let camera_cfg = camera::current_camera_config();
let microphone_lease = self.upstream_media_rt.activate_microphone(); let bundled_leases = self.upstream_media_rt.activate_bundled_session();
let camera_lease = self.upstream_media_rt.activate_camera(); let microphone_lease = bundled_leases.microphone;
let camera_lease = bundled_leases.camera;
info!( info!(
rpc_id, rpc_id,
session_id = camera_lease.session_id, session_id = camera_lease.session_id,

View File

@ -6,8 +6,9 @@ impl Handler {
) -> Result<Response<ReceiverStream<Result<Empty, Status>>>, Status> { ) -> Result<Response<ReceiverStream<Result<Empty, Status>>>, Status> {
let rpc_id = runtime_support::next_stream_id(); let rpc_id = runtime_support::next_stream_id();
let camera_cfg = camera::current_camera_config(); let camera_cfg = camera::current_camera_config();
let microphone_lease = self.upstream_media_rt.activate_microphone(); let bundled_leases = self.upstream_media_rt.activate_bundled_session();
let camera_lease = self.upstream_media_rt.activate_camera(); let microphone_lease = bundled_leases.microphone;
let camera_lease = bundled_leases.camera;
info!( info!(
rpc_id, rpc_id,
session_id = camera_lease.session_id, session_id = camera_lease.session_id,

View File

@ -149,8 +149,9 @@ impl Relay for Handler {
&self, &self,
req: Request<tonic::Streaming<UpstreamMediaBundle>>, req: Request<tonic::Streaming<UpstreamMediaBundle>>,
) -> Result<Response<Self::StreamWebcamMediaStream>, Status> { ) -> Result<Response<Self::StreamWebcamMediaStream>, Status> {
let microphone_lease = self.upstream_media_rt.activate_microphone(); let bundled_leases = self.upstream_media_rt.activate_bundled_session();
let camera_lease = self.upstream_media_rt.activate_camera(); let microphone_lease = bundled_leases.microphone;
let camera_lease = bundled_leases.camera;
let (tx, rx) = tokio::sync::mpsc::channel(1); let (tx, rx) = tokio::sync::mpsc::channel(1);
let upstream_media_rt = self.upstream_media_rt.clone(); let upstream_media_rt = self.upstream_media_rt.clone();

View File

@ -31,6 +31,12 @@ pub struct UpstreamStreamLease {
pub generation: u64, pub generation: u64,
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct UpstreamBundledLeases {
pub camera: UpstreamStreamLease,
pub microphone: UpstreamStreamLease,
}
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
pub struct PlannedUpstreamPacket { pub struct PlannedUpstreamPacket {
pub local_pts_us: u64, pub local_pts_us: u64,

View File

@ -33,6 +33,36 @@ impl UpstreamMediaRuntime {
} }
} }
/// Starts a bundled camera+microphone generation with a fresh shared playout epoch.
fn activate_bundled(&self) -> UpstreamBundledLeases {
let camera_generation = self.next_camera_generation.fetch_add(1, Ordering::SeqCst) + 1;
let microphone_generation =
self.next_microphone_generation
.fetch_add(1, Ordering::SeqCst)
+ 1;
let mut state = self
.state
.lock()
.expect("upstream media state mutex poisoned");
state.session_id = self.next_session_id.fetch_add(1, Ordering::SeqCst) + 1;
reset_session_state(&mut state);
state.session_started_at = Some(Instant::now());
state.phase = UpstreamSyncPhase::Acquiring;
state.last_reason = "v2 bundled upstream session acquiring media".to_string();
state.active_camera_generation = Some(camera_generation);
state.active_microphone_generation = Some(microphone_generation);
UpstreamBundledLeases {
camera: UpstreamStreamLease {
session_id: state.session_id,
generation: camera_generation,
},
microphone: UpstreamStreamLease {
session_id: state.session_id,
generation: microphone_generation,
},
}
}
/// Keeps `is_active` explicit because it sits on server upstream media scheduling, where timing choices directly affect lip sync. /// Keeps `is_active` explicit because it sits on server upstream media scheduling, where timing choices directly affect lip sync.
/// Inputs are the typed parameters; output is the return value or side effect. /// Inputs are the typed parameters; output is the return value or side effect.
fn is_active(&self, kind: UpstreamMediaKind, generation: u64) -> bool { fn is_active(&self, kind: UpstreamMediaKind, generation: u64) -> bool {

View File

@ -41,6 +41,11 @@ pub fn new() -> Self {
self.activate(UpstreamMediaKind::Microphone) self.activate(UpstreamMediaKind::Microphone)
} }
#[must_use]
pub fn activate_bundled_session(&self) -> UpstreamBundledLeases {
self.activate_bundled()
}
pub async fn reserve_microphone_sink(&self, generation: u64) -> Option<OwnedSemaphorePermit> { pub async fn reserve_microphone_sink(&self, generation: u64) -> Option<OwnedSemaphorePermit> {
let permit = self let permit = self
.microphone_sink_gate .microphone_sink_gate

View File

@ -225,6 +225,58 @@ fn runtime_public_mapping_helpers_cover_legacy_and_bundled_paths() {
}); });
} }
#[test]
/// Keeps `bundled_session_activation_resets_stale_playout_epoch` explicit because reconnecting a v2 webcam+mic stream must not inherit stale clocks from a superseded stream.
/// Inputs are two bundled activations with an old presented frame; output proves the new stream starts at local PTS zero and retires the old generations.
fn bundled_session_activation_resets_stale_playout_epoch() {
with_clean_offset_env(|| {
let runtime = UpstreamMediaRuntime::new();
let first = runtime.activate_bundled_session();
assert_eq!(first.camera.session_id, first.microphone.session_id);
let first_epoch = tokio::time::Instant::now() + std::time::Duration::from_millis(10);
let first_decision = runtime.plan_bundled_pts(
UpstreamMediaKind::Camera,
500_000,
33_333,
100_000,
first_epoch,
);
let first_plan = match first_decision {
UpstreamPlanDecision::Play(plan) => plan,
other => panic!("expected first bundled play decision, got {other:?}"),
};
assert_eq!(first_plan.local_pts_us, 400_000);
runtime.mark_video_presented(first_plan.local_pts_us, first_plan.due_at);
assert_eq!(
runtime.snapshot().last_video_presented_pts_us,
Some(400_000)
);
let second = runtime.activate_bundled_session();
assert_ne!(first.camera.session_id, second.camera.session_id);
assert_eq!(second.camera.session_id, second.microphone.session_id);
assert!(!runtime.is_camera_active(first.camera.generation));
assert!(!runtime.is_microphone_active(first.microphone.generation));
assert!(runtime.is_camera_active(second.camera.generation));
assert!(runtime.is_microphone_active(second.microphone.generation));
assert_eq!(runtime.snapshot().last_video_presented_pts_us, None);
let second_epoch = tokio::time::Instant::now() + std::time::Duration::from_millis(10);
let second_decision = runtime.plan_bundled_pts(
UpstreamMediaKind::Camera,
1_000,
33_333,
1_000,
second_epoch,
);
match second_decision {
UpstreamPlanDecision::Play(plan) => assert_eq!(plan.local_pts_us, 0),
other => panic!("expected fresh bundled play decision, got {other:?}"),
}
});
}
#[test] #[test]
/// Keeps `runtime_soft_microphone_recovery_cycles_only_the_microphone_generation` explicit because a failed UAC handoff should not disturb active camera playout. /// Keeps `runtime_soft_microphone_recovery_cycles_only_the_microphone_generation` explicit because a failed UAC handoff should not disturb active camera playout.
/// Inputs are an active camera lease and a soft microphone recovery request; output keeps camera active while cycling microphone state. /// Inputs are an active camera lease and a soft microphone recovery request; output keeps camera active while cycling microphone state.

View File

@ -226,6 +226,14 @@ mod server_upstream_media_bundle_normal_mode {
env!("CARGO_MANIFEST_DIR"), env!("CARGO_MANIFEST_DIR"),
"/server/src/main/relay_stream_lifecycle.rs" "/server/src/main/relay_stream_lifecycle.rs"
)); ));
const UPSTREAM_RUNTIME: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/server/src/upstream_media_runtime.rs"
));
const UPSTREAM_RUNTIME_LIFECYCLE: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/server/src/upstream_media_runtime/stream_lifecycle_methods.rs"
));
#[test] #[test]
fn bundled_rpc_preserves_one_media_unit_at_server_ingress() { fn bundled_rpc_preserves_one_media_unit_at_server_ingress() {
@ -233,5 +241,8 @@ mod server_upstream_media_bundle_normal_mode {
assert!(RELAY_RPC.contains("UpstreamMediaBundle")); assert!(RELAY_RPC.contains("UpstreamMediaBundle"));
assert!(RELAY_LIFECYCLE.contains("client_capture_pts_us")); assert!(RELAY_LIFECYCLE.contains("client_capture_pts_us"));
assert!(RELAY_RPC.contains("record_client_timing")); assert!(RELAY_RPC.contains("record_client_timing"));
assert!(RELAY_RPC.contains("activate_bundled_session()"));
assert!(UPSTREAM_RUNTIME.contains("pub struct UpstreamBundledLeases"));
assert!(UPSTREAM_RUNTIME_LIFECYCLE.contains("pub fn activate_bundled_session"));
} }
} }

View File

@ -381,6 +381,30 @@ mod inputs_contract {
); );
} }
#[test]
fn begin_local_release_publishes_local_before_ungrab_finishes() {
let dir = tempfile::tempdir().expect("tempdir");
let state_path = dir.path().join("routing-state");
let mut agg = new_aggregator();
agg.routing_state_path = Some(state_path.clone());
agg.publish_routing_state_if_changed();
assert_eq!(
std::fs::read_to_string(&state_path).expect("initial routing state"),
"remote\n"
);
agg.begin_local_release();
assert!(agg.pending_release);
assert!(!agg.remote_capture_active());
assert_eq!(
std::fs::read_to_string(&state_path).expect("local routing state"),
"local\n",
"launcher polling must see local as soon as remote sends are disabled, not only after final ungrab"
);
}
#[test] #[test]
fn local_release_timeout_helpers_are_stable() { fn local_release_timeout_helpers_are_stable() {
let mut agg = new_aggregator(); let mut agg = new_aggregator();

View File

@ -68,7 +68,9 @@ fn webcam_transport_selector_exposes_real_hevc_and_mjpeg_choices() {
#[test] #[test]
fn webcam_transport_changes_are_staged_when_relay_is_live() { fn webcam_transport_changes_are_staged_when_relay_is_live() {
for marker in [ for marker in [
"state.borrow_mut().select_webcam_transport(selected);", "if widgets.webcam_transport_syncing.get()",
"state.try_borrow_mut()",
"state_mut.select_webcam_transport(selected);",
"child_proc.borrow().is_some()", "child_proc.borrow().is_some()",
"apply_live_media_device_change(", "apply_live_media_device_change(",
"\"Webcam transport\"", "\"Webcam transport\"",
@ -81,6 +83,8 @@ fn webcam_transport_changes_are_staged_when_relay_is_live() {
} }
for marker in [ for marker in [
"widgets.webcam_transport_syncing.set(true);",
"widgets.webcam_transport_syncing.set(false);",
".webcam_transport_combo\n .set_sensitive(state.channels.camera);", ".webcam_transport_combo\n .set_sensitive(state.channels.camera);",
"Changing upstream webcam transport restarts the live camera path; the picture may pause briefly.", "Changing upstream webcam transport restarts the live camera path; the picture may pause briefly.",
"Use the server-advertised upstream webcam transport for the next relay launch; MJPEG is the safe calibrated default.", "Use the server-advertised upstream webcam transport for the next relay launch; MJPEG is the safe calibrated default.",

View File

@ -135,6 +135,14 @@ const UI_COMPONENTS_SRC: &str = concat!(
env!("CARGO_MANIFEST_DIR"), env!("CARGO_MANIFEST_DIR"),
"/client/src/launcher/ui_components.rs" "/client/src/launcher/ui_components.rs"
)), )),
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/client/src/launcher/ui_components/types.rs"
)),
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/client/src/launcher/ui_components/assemble_view.rs"
)),
include_str!(concat!( include_str!(concat!(
env!("CARGO_MANIFEST_DIR"), env!("CARGO_MANIFEST_DIR"),
"/client/src/launcher/ui_components/build_shell.rs" "/client/src/launcher/ui_components/build_shell.rs"
@ -264,6 +272,10 @@ fn launcher_webcam_quality_selection_reaches_preview_and_relay_env() {
assert!(UI_SRC.contains("let camera_quality_syncing = Rc::new(Cell::new(false));")); assert!(UI_SRC.contains("let camera_quality_syncing = Rc::new(Cell::new(false));"));
assert!(UI_SRC.contains("camera_quality_syncing.set(true);")); assert!(UI_SRC.contains("camera_quality_syncing.set(true);"));
assert!(UI_SRC.contains("if camera_quality_syncing.get()")); assert!(UI_SRC.contains("if camera_quality_syncing.get()"));
assert!(UI_COMPONENTS_SRC.contains("webcam_transport_syncing: Rc<Cell<bool>>"));
assert!(UI_COMPONENTS_SRC.contains("upstream_audio_transport_syncing: Rc<Cell<bool>>"));
assert!(UI_RUNTIME_SRC.contains("widgets.webcam_transport_syncing.set(true);"));
assert!(UI_SRC.contains("if widgets.webcam_transport_syncing.get()"));
assert!(UI_SRC.contains("state.try_borrow_mut()")); assert!(UI_SRC.contains("state.try_borrow_mut()"));
assert!(UI_SRC.contains("tests.set_camera_quality")); assert!(UI_SRC.contains("tests.set_camera_quality"));
assert!(DEVICE_TEST_SRC.contains("pub fn set_camera_quality")); assert!(DEVICE_TEST_SRC.contains("pub fn set_camera_quality"));