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]]
name = "lesavka_client"
version = "0.22.26"
version = "0.22.27"
dependencies = [
"anyhow",
"async-stream",
@ -1686,7 +1686,7 @@ dependencies = [
[[package]]
name = "lesavka_common"
version = "0.22.26"
version = "0.22.27"
dependencies = [
"anyhow",
"base64",
@ -1698,7 +1698,7 @@ dependencies = [
[[package]]
name = "lesavka_server"
version = "0.22.26"
version = "0.22.27"
dependencies = [
"anyhow",
"base64",

View File

@ -4,7 +4,7 @@ path = "src/main.rs"
[package]
name = "lesavka_client"
version = "0.22.26"
version = "0.22.27"
edition = "2024"
[dependencies]

View File

@ -64,6 +64,8 @@ impl InputAggregator {
self.pending_release_started_at = Some(Instant::now());
self.last_keyboard_report = [0; 8];
self.capture_pending_keys();
#[cfg(not(coverage))]
self.publish_routing_state_if_changed();
}
fn finish_local_release(&mut self, focus_launcher: bool) {
@ -271,7 +273,7 @@ impl InputAggregator {
#[cfg(not(coverage))]
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) {
return;
}

View File

@ -58,27 +58,38 @@
let transport_combo = widgets.webcam_transport_combo.clone();
let transport_combo_read = transport_combo.clone();
transport_combo.connect_changed(move |_| {
if widgets.webcam_transport_syncing.get() {
return;
}
let selected = transport_combo_read
.active_id()
.as_deref()
.and_then(WebcamTransport::from_id)
.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();
if relay_live {
apply_live_media_device_change(
&state.borrow(),
&widgets,
&child_proc,
"Webcam transport",
);
if let Ok(state_ref) = state.try_borrow() {
apply_live_media_device_change(
&state_ref,
&widgets,
&child_proc,
"Webcam transport",
);
}
} else {
widgets.status_label.set_text(&format!(
"Webcam transport set to {} for the next relay launch.",
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_read = audio_combo.clone();
audio_combo.connect_changed(move |_| {
if widgets.upstream_audio_transport_syncing.get() {
return;
}
let selected = audio_combo_read
.active_id()
.as_deref()
.and_then(UpstreamAudioTransport::from_id)
.unwrap_or_default();
state
.borrow_mut()
.select_upstream_audio_transport(selected);
let Ok(mut state_mut) = state.try_borrow_mut() else {
return;
};
state_mut.select_upstream_audio_transport(selected);
drop(state_mut);
let relay_live = child_proc.borrow().is_some();
if relay_live {
apply_live_media_device_change(
&state.borrow(),
&widgets,
&child_proc,
"Microphone transport",
);
if let Ok(state_ref) = state.try_borrow() {
apply_live_media_device_change(
&state_ref,
&widgets,
&child_proc,
"Microphone transport",
);
}
} else {
widgets.status_label.set_text(&format!(
"Upstream microphone transport set to {} for the next relay launch.",
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 gtk::{glib, pango, prelude::*};

View File

@ -162,7 +162,9 @@
camera_test_button: camera_test_button.clone(),
camera_preview_stack: camera_preview_stack.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_syncing: Rc::new(Cell::new(false)),
camera_mirror_button: camera_mirror_button.clone(),
camera_mirror_revealer: camera_mirror_revealer.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_preview_stack: gtk::Stack,
pub webcam_transport_combo: gtk::ComboBoxText,
pub webcam_transport_syncing: Rc<Cell<bool>>,
pub upstream_audio_transport_combo: gtk::ComboBoxText,
pub upstream_audio_transport_syncing: Rc<Cell<bool>>,
pub camera_mirror_button: gtk::ToggleButton,
pub camera_mirror_revealer: gtk::Revealer,
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()
!= Some(state.effective_webcam_transport().as_id())
{
widgets.webcam_transport_syncing.set(true);
widgets
.webcam_transport_combo
.set_active_id(Some(state.effective_webcam_transport().as_id()));
widgets.webcam_transport_syncing.set(false);
}
widgets
.webcam_transport_combo
@ -295,9 +297,11 @@ pub fn refresh_launcher_ui(widgets: &LauncherWidgets, state: &LauncherState, chi
.as_deref()
!= Some(state.upstream_audio_transport.as_id())
{
widgets.upstream_audio_transport_syncing.set(true);
widgets
.upstream_audio_transport_combo
.set_active_id(Some(state.upstream_audio_transport.as_id()));
widgets.upstream_audio_transport_syncing.set(false);
}
widgets
.upstream_audio_transport_combo

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -31,6 +31,12 @@ pub struct UpstreamStreamLease {
pub generation: u64,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct UpstreamBundledLeases {
pub camera: UpstreamStreamLease,
pub microphone: UpstreamStreamLease,
}
#[derive(Clone, Copy, Debug)]
pub struct PlannedUpstreamPacket {
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.
/// Inputs are the typed parameters; output is the return value or side effect.
fn is_active(&self, kind: UpstreamMediaKind, generation: u64) -> bool {

View File

@ -41,6 +41,11 @@ pub fn new() -> Self {
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> {
let permit = self
.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]
/// 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.

View File

@ -226,6 +226,14 @@ mod server_upstream_media_bundle_normal_mode {
env!("CARGO_MANIFEST_DIR"),
"/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]
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_LIFECYCLE.contains("client_capture_pts_us"));
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]
fn local_release_timeout_helpers_are_stable() {
let mut agg = new_aggregator();

View File

@ -68,7 +68,9 @@ fn webcam_transport_selector_exposes_real_hevc_and_mjpeg_choices() {
#[test]
fn webcam_transport_changes_are_staged_when_relay_is_live() {
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()",
"apply_live_media_device_change(",
"\"Webcam transport\"",
@ -81,6 +83,8 @@ fn webcam_transport_changes_are_staged_when_relay_is_live() {
}
for marker in [
"widgets.webcam_transport_syncing.set(true);",
"widgets.webcam_transport_syncing.set(false);",
".webcam_transport_combo\n .set_sensitive(state.channels.camera);",
"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.",

View File

@ -135,6 +135,14 @@ const UI_COMPONENTS_SRC: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/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!(
env!("CARGO_MANIFEST_DIR"),
"/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("camera_quality_syncing.set(true);"));
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("tests.set_camera_quality"));
assert!(DEVICE_TEST_SRC.contains("pub fn set_camera_quality"));