media: reset bundled upstream sessions
This commit is contained in:
parent
602974b04e
commit
692c3a6545
6
Cargo.lock
generated
6
Cargo.lock
generated
@ -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",
|
||||
|
||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
||||
|
||||
[package]
|
||||
name = "lesavka_client"
|
||||
version = "0.22.26"
|
||||
version = "0.22.27"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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::*};
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lesavka_common"
|
||||
version = "0.22.26"
|
||||
version = "0.22.27"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ bench = false
|
||||
|
||||
[package]
|
||||
name = "lesavka_server"
|
||||
version = "0.22.26"
|
||||
version = "0.22.27"
|
||||
edition = "2024"
|
||||
autobins = false
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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"));
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user