Add orderly shutdown on window close
This commit is contained in:
+53
-3
@@ -82,6 +82,8 @@ const CHAT_MIN_H: f32 = 110.0;
|
|||||||
const ABOVE_CHAT_MIN_H: f32 = 300.0;
|
const ABOVE_CHAT_MIN_H: f32 = 300.0;
|
||||||
/// Thickness of a draggable divider (px).
|
/// Thickness of a draggable divider (px).
|
||||||
const DIVIDER_THICKNESS: f32 = 8.0;
|
const DIVIDER_THICKNESS: f32 = 8.0;
|
||||||
|
/// Upper bound for waiting on orderly core shutdown before letting the window exit.
|
||||||
|
const SHUTDOWN_TIMEOUT_SECS: u64 = 5;
|
||||||
|
|
||||||
/// Clamp the Participants panel width so neither it nor the Controls panel drops
|
/// Clamp the Participants panel width so neither it nor the Controls panel drops
|
||||||
/// below its minimum, given the current window width.
|
/// below its minimum, given the current window width.
|
||||||
@@ -221,6 +223,10 @@ pub enum AppMessage {
|
|||||||
ToggleScreenShare,
|
ToggleScreenShare,
|
||||||
/// Watch a peer's screen share, identified by their pixelpass ticket.
|
/// Watch a peer's screen share, identified by their pixelpass ticket.
|
||||||
WatchShare(String),
|
WatchShare(String),
|
||||||
|
/// Result of asynchronously enqueueing the core shutdown command.
|
||||||
|
ShutdownCommandSent(bool),
|
||||||
|
/// Fallback close if the core does not acknowledge shutdown promptly.
|
||||||
|
ShutdownTimeout,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn core_subscription() -> impl iced::futures::Stream<Item = UiEvent> {
|
fn core_subscription() -> impl iced::futures::Stream<Item = UiEvent> {
|
||||||
@@ -324,6 +330,8 @@ pub struct AppState {
|
|||||||
friend_add_name: String,
|
friend_add_name: String,
|
||||||
/// Inline feedback for the add-friend form (e.g. a bad id), cleared on edit.
|
/// Inline feedback for the add-friend form (e.g. a bad id), cleared on edit.
|
||||||
friend_add_error: Option<String>,
|
friend_add_error: Option<String>,
|
||||||
|
/// Window close has been requested and the GUI is waiting for core teardown.
|
||||||
|
closing: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
@@ -441,6 +449,7 @@ impl Default for AppState {
|
|||||||
friend_add_id: String::new(),
|
friend_add_id: String::new(),
|
||||||
friend_add_name: String::new(),
|
friend_add_name: String::new(),
|
||||||
friend_add_error: None,
|
friend_add_error: None,
|
||||||
|
closing: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -520,6 +529,20 @@ fn subscription(_state: &AppState) -> Subscription<AppMessage> {
|
|||||||
Subscription::batch(vec![core_sub, event_sub])
|
Subscription::batch(vec![core_sub, event_sub])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn shutdown_timeout_task() -> Task<AppMessage> {
|
||||||
|
Task::perform(
|
||||||
|
async {
|
||||||
|
let (tx, rx) = iced::futures::channel::oneshot::channel();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(SHUTDOWN_TIMEOUT_SECS));
|
||||||
|
let _ = tx.send(());
|
||||||
|
});
|
||||||
|
let _ = rx.await;
|
||||||
|
},
|
||||||
|
|_| AppMessage::ShutdownTimeout,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Reconnect-chime edge trigger for `UiEvent::PeerConnecting`. Marks the peer as
|
/// Reconnect-chime edge trigger for `UiEvent::PeerConnecting`. Marks the peer as
|
||||||
/// connecting and returns `Some(Sound::ReconnectAttempt)` exactly once per outage:
|
/// connecting and returns `Some(Sound::ReconnectAttempt)` exactly once per outage:
|
||||||
/// only when the peer had a live link before (a genuine reconnect, not a first
|
/// only when the peer had a live link before (a genuine reconnect, not a first
|
||||||
@@ -865,6 +888,11 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
state.status_message =
|
state.status_message =
|
||||||
"Discoverable timed out — back to Normal".to_string();
|
"Discoverable timed out — back to Normal".to_string();
|
||||||
}
|
}
|
||||||
|
UiEvent::ShutdownComplete => {
|
||||||
|
if state.closing {
|
||||||
|
return iced::exit();
|
||||||
|
}
|
||||||
|
}
|
||||||
UiEvent::Error(err) => {
|
UiEvent::Error(err) => {
|
||||||
state.status_message = format!("Error: {}", err);
|
state.status_message = format!("Error: {}", err);
|
||||||
}
|
}
|
||||||
@@ -1318,13 +1346,35 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
state.config.window_y = Some(position.y as i32);
|
state.config.window_y = Some(position.y as i32);
|
||||||
}
|
}
|
||||||
AppMessage::EventOccurred(Event::Window(iced::window::Event::CloseRequested)) => {
|
AppMessage::EventOccurred(Event::Window(iced::window::Event::CloseRequested)) => {
|
||||||
|
if state.closing {
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
// We took over the close path (exit_on_close_request:false) so we can
|
// We took over the close path (exit_on_close_request:false) so we can
|
||||||
// persist the final window size + position before quitting. Both are
|
// persist the final window size + position and give core a chance to
|
||||||
// already mirrored into config by the Resized/Moved handlers above.
|
// leave the room/finalize recordings before quitting.
|
||||||
state.config.save();
|
state.config.save();
|
||||||
return iced::exit();
|
state.closing = true;
|
||||||
|
state.status_message = "Shutting down...".to_string();
|
||||||
|
let tx = state.controller.command_sender();
|
||||||
|
return Task::batch(vec![
|
||||||
|
Task::perform(
|
||||||
|
async move { tx.send(CoreCommand::Shutdown).await.is_ok() },
|
||||||
|
AppMessage::ShutdownCommandSent,
|
||||||
|
),
|
||||||
|
shutdown_timeout_task(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
AppMessage::EventOccurred(_) => {}
|
AppMessage::EventOccurred(_) => {}
|
||||||
|
AppMessage::ShutdownCommandSent(sent) => {
|
||||||
|
if !sent {
|
||||||
|
return iced::exit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppMessage::ShutdownTimeout => {
|
||||||
|
if state.closing {
|
||||||
|
return iced::exit();
|
||||||
|
}
|
||||||
|
}
|
||||||
AppMessage::NavigateToSettings => {
|
AppMessage::NavigateToSettings => {
|
||||||
state.current_screen = Screen::Settings;
|
state.current_screen = Screen::Settings;
|
||||||
state.layout_picker_open = false;
|
state.layout_picker_open = false;
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ pub enum CoreCommand {
|
|||||||
/// for a NEW room; it's ignored when joining (the label rides in the ticket).
|
/// for a NEW room; it's ignored when joining (the label rides in the ticket).
|
||||||
Join { name: String, ticket: String, room_name: String, input_device: Option<String>, output_device: Option<String>, echo_cancellation: bool, avatar: crate::avatar::Avatar },
|
Join { name: String, ticket: String, room_name: String, input_device: Option<String>, output_device: Option<String>, echo_cancellation: bool, avatar: crate::avatar::Avatar },
|
||||||
Leave,
|
Leave,
|
||||||
|
/// Orderly app shutdown: finalize recordings, leave any active room, stop local
|
||||||
|
/// audio/screen-share work, close the persistent network stack, then ack with
|
||||||
|
/// [`UiEvent::ShutdownComplete`].
|
||||||
|
Shutdown,
|
||||||
ToggleMute,
|
ToggleMute,
|
||||||
/// Change our avatar (W4) and re-announce it to the room over presence.
|
/// Change our avatar (W4) and re-announce it to the room over presence.
|
||||||
SetAvatar(crate::avatar::Avatar),
|
SetAvatar(crate::avatar::Avatar),
|
||||||
@@ -126,5 +130,7 @@ pub enum UiEvent {
|
|||||||
/// Discoverable. Distinct from a user-driven change so the GUI knows to update
|
/// Discoverable. Distinct from a user-driven change so the GUI knows to update
|
||||||
/// without having issued the command itself.
|
/// without having issued the command itself.
|
||||||
PresenceModeReverted { mode: PresenceMode },
|
PresenceModeReverted { mode: PresenceMode },
|
||||||
|
/// Core finished orderly app shutdown and the GUI can exit.
|
||||||
|
ShutdownComplete,
|
||||||
Error(String),
|
Error(String),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ impl CoreController {
|
|||||||
pub fn send(&self, cmd: CoreCommand) -> bool {
|
pub fn send(&self, cmd: CoreCommand) -> bool {
|
||||||
self.cmd_tx.try_send(cmd).is_ok()
|
self.cmd_tx.try_send(cmd).is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clone the command sender for asynchronous one-shot sends that should wait
|
||||||
|
/// for channel capacity instead of failing immediately on a full queue.
|
||||||
|
pub fn command_sender(&self) -> mpsc::Sender<CoreCommand> {
|
||||||
|
self.cmd_tx.clone()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How long a peer may stay "reconnecting" after a transient drop before we give
|
/// How long a peer may stay "reconnecting" after a transient drop before we give
|
||||||
@@ -856,6 +862,23 @@ async fn run_core_loop(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
match cmd {
|
match cmd {
|
||||||
|
CoreCommand::Shutdown => {
|
||||||
|
crate::log_msg("Core shutdown requested");
|
||||||
|
// Finalize recordings while capture/mixer feeders are still alive.
|
||||||
|
stop_recording(&recorder, &is_recording, &multitrack, &is_multitrack, &ui_tx).await;
|
||||||
|
stop_mic_monitor(&audio_backend, mic_monitor.take());
|
||||||
|
|
||||||
|
if let Some(session) = active_session.take() {
|
||||||
|
session.shutdown(audio_backend.clone()).await;
|
||||||
|
net.audio_router.clear();
|
||||||
|
}
|
||||||
|
*current_room.lock().unwrap() = None;
|
||||||
|
|
||||||
|
net.shutdown().await;
|
||||||
|
let _ = ui_tx.send(UiEvent::ShutdownComplete).await;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
CoreCommand::Join { name, ticket, room_name, input_device, output_device, echo_cancellation, avatar } => {
|
CoreCommand::Join { name, ticket, room_name, input_device, output_device, echo_cancellation, avatar } => {
|
||||||
current_name = name.clone();
|
current_name = name.clone();
|
||||||
current_avatar = avatar;
|
current_avatar = avatar;
|
||||||
|
|||||||
@@ -61,8 +61,18 @@ Reason for not implementing: the current `run_playback` / `run_capture` code doe
|
|||||||
|
|
||||||
Unverified: the same-identity peer restart has not been exercised in a live 2-machine call; the WAV fix is counter/size-field tested, not a real >12h recording.
|
Unverified: the same-identity peer restart has not been exercised in a live 2-machine call; the WAV fix is counter/size-field tested, not a real >12h recording.
|
||||||
|
|
||||||
|
## Backlog A14 - orderly window-close shutdown
|
||||||
|
|
||||||
|
- Added `CoreCommand::Shutdown` and `UiEvent::ShutdownComplete`.
|
||||||
|
- Window close now saves config, marks the GUI as closing, asynchronously queues `Shutdown`, and exits only after the core acknowledges completion or after a 5-second fallback timeout.
|
||||||
|
- Core shutdown finalizes active mixed/multitrack recordings before session teardown, stops the standalone mic monitor, runs `ActiveSession::shutdown()` for active calls, clears room presence/routing, closes the persistent network stack, sends `ShutdownComplete`, and ends the core loop.
|
||||||
|
- The shutdown command is queued with an awaited `mpsc::Sender::send` task instead of the best-effort `try_send`, so a full command queue does not immediately drop the close command.
|
||||||
|
|
||||||
|
Unverified: actual GUI window-close behavior during a live call/recording still needs a manual run; tests/builds only prove the path compiles and existing unit coverage still passes.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
|
- `cargo check` passed.
|
||||||
- `cargo test --lib` passed: 288 passed, 0 failed, 2 ignored.
|
- `cargo test --lib` passed: 288 passed, 0 failed, 2 ignored.
|
||||||
- `cargo clippy --all-targets` passed.
|
- `cargo clippy --all-targets` passed.
|
||||||
- `cargo build --release` passed.
|
- `cargo build --release` passed.
|
||||||
|
|||||||
Reference in New Issue
Block a user