diff --git a/src/app/mod.rs b/src/app/mod.rs index bb4fd28..f56321b 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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 { @@ -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, + /// 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 { Subscription::batch(vec![core_sub, event_sub]) } +fn shutdown_timeout_task() -> Task { + 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 { 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 { 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; diff --git a/src/core/messages.rs b/src/core/messages.rs index b738b50..0833db6 100644 --- a/src/core/messages.rs +++ b/src/core/messages.rs @@ -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, output_device: Option, 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), } diff --git a/src/core/mod.rs b/src/core/mod.rs index 671267b..2bf208b 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -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 { + 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; diff --git a/task-report.md b/task-report.md index 8abad41..daf9ef5 100644 --- a/task-report.md +++ b/task-report.md @@ -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.