Add orderly shutdown on window close

This commit is contained in:
2026-06-16 17:37:00 -04:00
parent 44bad7b70b
commit 33e3998e7c
4 changed files with 92 additions and 3 deletions
+53 -3
View File
@@ -82,6 +82,8 @@ const CHAT_MIN_H: f32 = 110.0;
const ABOVE_CHAT_MIN_H: f32 = 300.0;
/// Thickness of a draggable divider (px).
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
/// below its minimum, given the current window width.
@@ -221,6 +223,10 @@ pub enum AppMessage {
ToggleScreenShare,
/// Watch a peer's screen share, identified by their pixelpass ticket.
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> {
@@ -324,6 +330,8 @@ pub struct AppState {
friend_add_name: String,
/// Inline feedback for the add-friend form (e.g. a bad id), cleared on edit.
friend_add_error: Option<String>,
/// Window close has been requested and the GUI is waiting for core teardown.
closing: bool,
}
impl AppState {
@@ -441,6 +449,7 @@ impl Default for AppState {
friend_add_id: String::new(),
friend_add_name: String::new(),
friend_add_error: None,
closing: false,
}
}
}
@@ -520,6 +529,20 @@ fn subscription(_state: &AppState) -> Subscription<AppMessage> {
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
/// 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
@@ -865,6 +888,11 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.status_message =
"Discoverable timed out — back to Normal".to_string();
}
UiEvent::ShutdownComplete => {
if state.closing {
return iced::exit();
}
}
UiEvent::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);
}
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
// persist the final window size + position before quitting. Both are
// already mirrored into config by the Resized/Moved handlers above.
// persist the final window size + position and give core a chance to
// leave the room/finalize recordings before quitting.
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::ShutdownCommandSent(sent) => {
if !sent {
return iced::exit();
}
}
AppMessage::ShutdownTimeout => {
if state.closing {
return iced::exit();
}
}
AppMessage::NavigateToSettings => {
state.current_screen = Screen::Settings;
state.layout_picker_open = false;
+6
View File
@@ -11,6 +11,10 @@ pub enum CoreCommand {
/// 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 },
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,
/// Change our avatar (W4) and re-announce it to the room over presence.
SetAvatar(crate::avatar::Avatar),
@@ -126,5 +130,7 @@ pub enum UiEvent {
/// Discoverable. Distinct from a user-driven change so the GUI knows to update
/// without having issued the command itself.
PresenceModeReverted { mode: PresenceMode },
/// Core finished orderly app shutdown and the GUI can exit.
ShutdownComplete,
Error(String),
}
+23
View File
@@ -50,6 +50,12 @@ impl CoreController {
pub fn send(&self, cmd: CoreCommand) -> bool {
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
@@ -856,6 +862,23 @@ async fn run_core_loop(
}
};
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 } => {
current_name = name.clone();
current_avatar = avatar;
+10
View File
@@ -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.
## 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
- `cargo check` passed.
- `cargo test --lib` passed: 288 passed, 0 failed, 2 ignored.
- `cargo clippy --all-targets` passed.
- `cargo build --release` passed.