From 2246a9201d24daa5f8a65b1b7588d97a60f961bc Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:50:37 -0700 Subject: [PATCH] shell: a process app may refuse a close and ask first (#179) A process client's close sent CloseRequested and Kill back to back, so closing a process Terminal's tile (the macOS default hosting) still ended its running jobs without asking. Stacked on #186, which did the same for in-process modules. - process_close.rs: a pure close state machine per process client. An app that says WmRequest::AsksBeforeClose (makepad feat/wm-close-refused) is asked with CloseRequested alone; its tile and process stay. Its answer is its own WmRequest::Close (yes: closed at once, without asking back), CloseRefused (it is asking the person: no deadline, the tile comes to the front) or an exit. - Fallback for an app that does not answer: a second close of the same tile while the first is unanswered ends it at once (a repeat within 0.5 s is the same click); left alone it is ended after 5 s. A refusal is never a licence to hang: every close asks again, a live app re-answers at once, a frozen one falls to the fallback. So an app that is visibly asking is never killed, and a hung one is always closable. - An app that never declared closes exactly as before (CloseRequested + Kill, the tile leaves at once), so apps without the new words, and a Terminal from an older makepad, lose nothing. - Shell quit (menu, Cmd+Q, the window's close, /quit) asks declared apps the same way and waits while any module or app is deciding; it goes when the last says yes. A second quit forces unanswered apps. A termination signal or a crash still asks nobody. - The words are read off the wire by their exact text, so this works with the current makepad pin; on_wm_request gains a catch-all arm so a repin to the new variants compiles. Co-Authored-By: Claude Opus 5.5 --- crates/shell/src/lib.rs | 200 +++++++++++++- crates/shell/src/mobile_app.rs | 2 +- crates/shell/src/module_host.rs | 16 ++ crates/shell/src/process_close.rs | 442 ++++++++++++++++++++++++++++++ 4 files changed, 648 insertions(+), 12 deletions(-) create mode 100644 crates/shell/src/process_close.rs diff --git a/crates/shell/src/lib.rs b/crates/shell/src/lib.rs index 4c68c504..06bc234e 100644 --- a/crates/shell/src/lib.rs +++ b/crates/shell/src/lib.rs @@ -57,6 +57,7 @@ pub mod sandbox; pub mod pane_links; pub mod peer_link; pub mod preview; +pub mod process_close; pub mod questions; pub mod run_view; pub mod shell; @@ -108,6 +109,7 @@ use android_integration::AndroidRuntime; use makepad_ai_services::wire::{ServiceCall, ServiceDown, ToolResult}; use makepad_app_module::{AppModule, CloseDecision, ExecOutcome, ModuleCloseAction, ModuleUpstream, WindowRequest}; use module_host::ModuleHost; +use process_close::ProcessCloseGate; use pane_links::{PaneCall, PaneLinks}; use makepad_widgets::ai_slot::AiSlotRequests; use shell::ai_pane::ShellAiPane; @@ -477,6 +479,10 @@ pub struct App { /// isolate each, seated in a `ModuleTile`. #[rust] pub module_host: ModuleHost, + /// Process apps that answer a close themselves, and the closes they + /// are answering (process_close.rs, OctoSense#179). + #[rust] + pub process_close: ProcessCloseGate, /// Extra windows module instances opened (`ModuleWindows`): window /// client -> (owner instance's client, the instance's key for it). #[rust] @@ -1239,7 +1245,9 @@ impl App { let (app, now) = (self.state_mut().clients.get(&client).map(|s| s.app.clone()).unwrap_or_default(), cx.seconds_since_app_start()); self.state_mut().phone.shade.post(&app, title, body, now, vec!["Open".into()]); } - WmRequest::Close => self.request_close(cx, client), + // The app itself is done (or its person just confirmed): no + // question goes back to it. + WmRequest::Close => self.close_client(cx, client, false), WmRequest::SetFloating { floating } => { let area = self.desk_area(cx); let gap = self.state_mut().gap; @@ -1256,6 +1264,10 @@ impl App { self.do_action(cx, WmAction::Fullscreen(FullscreenMode::Fullscreen)); } } + // Words a newer makepad adds (the close answers are read before + // this parse, by `process_close::parse_close_word`). + #[allow(unreachable_patterns)] + _ => {} } } @@ -1578,6 +1590,22 @@ impl App { } fn request_close(&mut self, cx: &mut Cx, client: ClientId) { + self.close_client(cx, client, true); + } + + /// Close `client`. `ask`: a close the person (or the shell) asked for, + /// which a process app that answers closes itself is asked about + /// first. Not `ask`: the app's own `WmRequest::Close` — it decided, so + /// it closes as any app always did (a hosted app cannot end its own + /// process: `cx.quit()` does nothing under the Studio runtime). + fn close_client(&mut self, cx: &mut Cx, client: ClientId, ask: bool) { + // A close of one app drops a quit that was waiting on others (the + // person turned to something else; module_host::CloseGate). An + // app's own close is its answer, which a waiting quit counts on. + if ask { + self.module_host.abandon_quit(); + self.process_close.abandon_quit(); + } let stopped = self.state.as_ref().and_then(|s| s.clients.get(&client)).is_some_and(|s| s.stopped); // A live module instance is asked first (makepad#65): one holding // something a close would destroy (the terminal's running jobs) @@ -1600,6 +1628,38 @@ impl App { self.update_bar(cx); return; } + // A process app that answers closes itself (it said + // `AsksBeforeClose`): ask, and wait for its answer — an exit, or a + // refusal while it asks the person. Its tile stays meanwhile. + // Anything else closes as before, below (process_close.rs). + let answerable = self + .state + .as_ref() + .and_then(|s| s.clients.get(&client)) + .is_some_and(|s| s.sender.is_some() && s.closing.is_none() && !s.warm && !s.pane); + if !ask { + // Its answer came as the close itself: nothing left to wait on. + self.process_close.gone(client); + } else if answerable { + match self.process_close.close(client, host::now()) { + process_close::CloseStep::Legacy => {} + process_close::CloseStep::Ask => { + let app = self.state_mut().clients.get(&client).map(|s| s.app.clone()).unwrap_or_default(); + log!("wm: asking {app} (client {client}) to close; it answers before anything ends"); + self.send_wm_event(client, WmEvent::CloseRequested); + self.activate_client(cx, client); + self.update_bar(cx); + self.redraw_all(cx); + return; + } + process_close::CloseStep::Wait => return, + process_close::CloseStep::Force => { + log!("wm: client {client} was closed again before it answered; ending it"); + self.end_process_client(cx, client); + return; + } + } + } let mut polite = false; if let Some(slot) = self.state_mut().clients.get_mut(&client) { if slot.closing.is_some() { @@ -1628,8 +1688,13 @@ impl App { } } } - // Out of the layout now: the tiles reflow and the desk plays the - // popin-out with the frame the tile already has. + self.leave_layout(cx, client); + } + + /// Out of the layout now: the tiles reflow and the desk plays the + /// popin-out with the frame the tile already has. The slot stays until + /// the reaper sees the process gone. + fn leave_layout(&mut self, cx: &mut Cx, client: ClientId) { self.state_mut().layout.remove(client); if let Some(mut desk) = self.desk(cx).borrow_mut::() { desk.remove_client(client); @@ -1639,6 +1704,67 @@ impl App { self.redraw_all(cx); } + /// End a process app that was asked to close and did not answer (the + /// person closed it again, or `process_close::ANSWER_TIMEOUT` passed): + /// the protocol's Kill and the process group's SIGTERM, SIGKILL after + /// `GROUP_KILL_GRACE`. A SIGTERM is a termination signal, which the + /// terminal never refuses. Counted as asked for, not as a crash. + fn end_process_client(&mut self, cx: &mut Cx, client: ClientId) { + let pool = cx.task_pool(); + let Some(slot) = self.state_mut().clients.get_mut(&client) else { + return; + }; + slot.closing = Some(host::now()); + if let Some(sender) = &slot.sender { + send_to_app(sender, vec![StudioToApp::Kill]); + } + if let Some(child) = slot.child.as_mut() { + if matches!(child.try_wait(), Ok(None)) { + clients::kill_child_group(child, clients::GROUP_KILL_GRACE, &pool); + } + } + self.leave_layout(cx, client); + } + + /// An app's word about closes (makepad `WmRequest::AsksBeforeClose` + /// / `CloseRefused`). + fn on_close_word(&mut self, cx: &mut Cx, client: ClientId, word: process_close::CloseWord) { + match word { + process_close::CloseWord::AsksBeforeClose => { + log!("wm: client {client} answers its closes itself"); + self.process_close.declare(client); + } + process_close::CloseWord::CloseRefused => { + // Only an answer to a close the shell is waiting on counts. + let first = self.process_close.asking() == 0; + if !self.process_close.refused(client) { + return; + } + let app = self.state_mut().clients.get(&client).map(|s| s.app.clone()).unwrap_or_default(); + log!("wm: {app} (client {client}) asks before closing; its tile stays until the person answers"); + // In front so the question is seen; while a quit waits on + // several, the first to ask keeps the front. + if first || !self.process_close.quit_waiting() { + self.activate_client(cx, client); + } + self.update_bar(cx); + self.redraw_all(cx); + } + } + } + + /// Whether `client` is answering a close or asking the person about + /// one (a hosted module or a process app). + fn close_pending(&self, client: ClientId) -> bool { + self.module_host.close_pending(client) || self.process_close.is_pending(client) + } + + /// A quit that waited on instances and apps asking the person may go + /// ahead: every one of them confirmed (or went). + fn take_quit_ready(&mut self) -> bool { + process_close::take_quit_ready(self.module_host.close_gate_mut(), &mut self.process_close) + } + /// A module instance's root confirmed the close it refused earlier /// (`ModuleCloseAction::Confirmed` from the root's uid): it closes now, /// through the same removal as any close. A confirmation from a root @@ -1661,11 +1787,38 @@ impl App { /// forced end (a termination signal, a kill) never asks. fn ask_before_quit(&mut self, cx: &mut Cx) -> bool { let refused = self.module_host.ask_quit(cx); - let Some(&first) = refused.first() else { + // Process apps that answer closes themselves are asked the same way + // (process_close.rs); the rest end with the shell, as always. + let windows: Vec = self + .state + .as_ref() + .map(|s| { + s.clients + .iter() + .filter(|(_, s)| s.sender.is_some() && s.closing.is_none() && !s.warm && !s.pane && !s.stopped) + .map(|(client, _)| *client) + .collect() + }) + .unwrap_or_default(); + let ask = self.process_close.quit(windows, host::now()); + for client in &ask.asked { + self.send_wm_event(*client, WmEvent::CloseRequested); + } + for client in &ask.forced { + log!("wm: client {client} was asked to close again before it answered; ending it"); + self.end_process_client(cx, *client); + } + if refused.is_empty() && self.process_close.idle() { + self.process_close.abandon_quit(); return true; - }; - log!("wm: quit waits: clients {refused:?} ask before closing"); - self.activate_client(cx, first); + } + log!( + "wm: quit waits: instances {refused:?} ask, apps {:?} are answering", + ask.asked + ); + if let Some(&first) = refused.first() { + self.activate_client(cx, first); + } self.update_bar(cx); self.redraw_all(cx); false @@ -1707,6 +1860,7 @@ impl App { fn remove_client(&mut self, cx: &mut Cx, client: ClientId) { log!("wm: removing client {}", client); peer_link::process_gone(client); + self.process_close.gone(client); // An instance's extra window: the tile lets go of the root (the // instance keeps the widget), and the instance hears it was closed. if let Some((owner, key)) = self.module_windows.remove(&client) { @@ -1907,6 +2061,16 @@ impl App { // Capture diagnostics queued by the output pumps before removing a // failed Cargo launch and its temporary progress tile. self.drain_client_lines(cx); + // A process app that promised to answer a close and did not: the + // fallback ends it (process_close.rs). One asking the person is + // never among these. + for client in self.process_close.overdue(host::now()) { + log!( + "wm: client {client} did not answer its close in {}s; ending it", + process_close::ANSWER_TIMEOUT + ); + self.end_process_client(cx, client); + } // A client that ignored the polite close gets the fallback. let pool = cx.task_pool(); for slot in self.state_mut().clients.values_mut() { @@ -1920,12 +2084,20 @@ impl App { } } } + // An app that quits while a close of it is pending gave its answer + // (yes): a close, not a crash. + let mut answering: Vec = self.state_mut().clients.keys().copied().collect(); + answering.retain(|client| self.process_close.is_pending(*client)); + let now = host::now(); let dead: Vec<(ClientId, Option)> = self .state_mut() .clients .iter_mut() .filter_map(|(id, slot)| { let status = slot.child.as_mut()?.try_wait().ok()??; + if answering.contains(id) && slot.closing.is_none() { + slot.closing = Some(now); + } let label = clients::find_app(&slot.app) .map(|app| app.label).unwrap_or_else(|| slot.app.clone()); Some((*id, slot.exit_failure(status, &label))) @@ -2614,8 +2786,8 @@ impl App { } Some(client) => { self.request_close(cx, client); - if self.module_host.close_pending(client) { - ToolResult::ok(id, format!("{app} asks the person before closing"), "asking") + if self.close_pending(client) { + ToolResult::ok(id, format!("{app} was asked to close and may ask the person first"), "asking") } else { ToolResult::ok(id, format!("{app} is closing"), "closing") } @@ -2843,6 +3015,12 @@ impl App { peer_link::on_frame(client, &app, &json, sender); return; } + // An app's answer about closes, read by its wire text so it + // works with any makepad pin (process_close.rs). + if let Some(word) = process_close::parse_close_word(&json) { + self.on_close_word(cx, client, word); + return; + } // The typed app<->WM vocabulary (libs/wm_api) first; what // is not the WM's own envelope is the AI bus's (or noise). if let Some(req) = WmRequest::parse(&json) { @@ -5403,8 +5581,8 @@ impl App { self.contain_module_faults(cx); // A quit that waited on instances asking the person goes ahead once // the last of them confirmed (or failed and has nothing left to ask). - if self.module_host.take_quit_ready() { - log!("wm: every instance confirmed; quitting"); + if self.take_quit_ready() { + log!("wm: every instance and app confirmed; quitting"); cx.quit(); } } diff --git a/crates/shell/src/mobile_app.rs b/crates/shell/src/mobile_app.rs index 68dbe198..1b4a4327 100644 --- a/crates/shell/src/mobile_app.rs +++ b/crates/shell/src/mobile_app.rs @@ -1157,7 +1157,7 @@ impl App { } _=>{ if g.screen==PhoneScreen::Recents && !g.shell && delta.y < -90.0 && delta.y.abs()>delta.x.abs()*1.2 { - if let Some(PhoneHit::Card(client))=g.hit {self.request_close(cx,client);if !self.module_host.close_pending(client) {self.state_mut().phone.navigate(PhoneScreen::Recents);}} + if let Some(PhoneHit::Card(client))=g.hit {self.request_close(cx,client);if !self.close_pending(client) {self.state_mut().phone.navigate(PhoneScreen::Recents);}} }else if delta.length()<12.0 { if let Some(hit)=g.hit.filter(|h|Some(h)==hit.as_ref()) {self.phone_action(cx,hit);} }else if !crate::mobile_navigation::ENABLED && g.screen==PhoneScreen::Home && delta.y < -55.0 && delta.y.abs()>delta.x.abs() {self.phone_action(cx,PhoneHit::Drawer);} diff --git a/crates/shell/src/module_host.rs b/crates/shell/src/module_host.rs index 6b7d38dd..8e468bab 100644 --- a/crates/shell/src/module_host.rs +++ b/crates/shell/src/module_host.rs @@ -371,6 +371,11 @@ impl CloseGate { pub fn quit_waiting(&self) -> bool { self.quit_waiting } + + /// No instance is asking the person. + pub fn idle(&self) -> bool { + self.pending.is_empty() + } } /// The isolate removes mod.res after bootstrap. Trusted framework themes @@ -683,6 +688,17 @@ impl ModuleHost { self.close_gate.take_quit_ready() } + /// The closes instances refused, for a quit that also waits on + /// process apps (`process_close::take_quit_ready`). + pub fn close_gate_mut(&mut self) -> &mut CloseGate { + &mut self.close_gate + } + + /// Drop a waiting quit (a close of one app, or the quit went). + pub fn abandon_quit(&mut self) { + self.close_gate.close_asked(); + } + /// Whether `client` is an instance whose module panicked. pub fn is_failed(&self, client: ClientId) -> bool { self.instances.get(&client).is_some_and(|i| i.failed.is_some()) diff --git a/crates/shell/src/process_close.rs b/crates/shell/src/process_close.rs new file mode 100644 index 00000000..0b0fea4a --- /dev/null +++ b/crates/shell/src/process_close.rs @@ -0,0 +1,442 @@ +//! Closing a PROCESS client that may ask the person first (OctoSense#179). +//! +//! makepad#65 made the terminal refuse a `WmEvent::CloseRequested` while +//! jobs run and ask in its own window; it quits itself on a yes. The +//! shell used to follow `CloseRequested` with `Kill` in the same breath, +//! so closing a process terminal's tile still ended its jobs. The fix +//! needs the shell to WAIT for the app's answer, and to know a live app +//! that is asking from a hung one. Two additive `wm_api` requests carry +//! that (makepad `WmRequest::AsksBeforeClose`, `WmRequest::CloseRefused`): +//! +//! - An app that sends `AsksBeforeClose` (in reply to `Hosted`) is +//! *declared*. Only a declared app is asked and waited for; every other +//! app keeps today's close — `CloseRequested` + `Kill`, the tile leaves +//! at once, the reaper as fallback — so apps that never learned the new +//! words close exactly as promptly as before. +//! - A close of a declared app sends `CloseRequested` alone. Its tile and +//! process stay. The app answers yes with its own `WmRequest::Close` (a +//! hosted app cannot end its process with `cx.quit()`; the Studio +//! runtime ignores it), which the shell carries out at once and without +//! asking back, or with `CloseRefused` (it is showing its question: the +//! close waits, with no deadline, for as long as the person takes; a no +//! leaves everything as it was, a yes later is again a `Close`). An exit +//! also answers. +//! - The fallback for an app that does not answer: after +//! [`ANSWER_TIMEOUT`] with neither an exit nor a refusal it is ended as +//! a close always ended apps. The person need not wait for that: closing +//! the same tile again while the first close is still unanswered ends it +//! at once (a repeat inside [`REPEAT_WINDOW`] is the same click, not +//! insistence). +//! - A refusal is never a licence to hang forever: every close is a fresh +//! question. Closing a tile whose app is asking asks again; a live app +//! answers again at once (the terminal re-shows its question), a frozen +//! one does not and falls to the fallback above. So an app that is +//! visibly showing its question is never killed, and a hung one is +//! always closable. +//! +//! A shell quit (the menu's Quit, Cmd+Q, the window's close button) asks +//! every declared app the same way and waits while any is answering or +//! asking; it goes ahead when the last one has said yes (or gone). A termination +//! signal or a crash of the shell asks nobody, as before. + +use crate::hub::ClientId; +use crate::module_host::CloseGate; +use std::collections::{HashMap, HashSet}; + +/// How long a declared app has to answer a close (exit or refuse) before +/// the shell ends it anyway. Generous on purpose: the only apps it catches +/// are ones that promised to answer and did not (hung), while killing a +/// slow but live app here would lose exactly what the question protects. +/// The person can end a hung app sooner by closing it again. +pub const ANSWER_TIMEOUT: f64 = 5.0; + +/// A second close of the same tile this soon after the first is the same +/// gesture (a double click), not a demand to force it. +pub const REPEAT_WINDOW: f64 = 0.5; + +/// Where one declared app's close stands. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Pending { + /// `CloseRequested` went out at `since`; no answer yet. + Awaiting { since: f64 }, + /// The app refused and is asking the person. + Asking, +} + +/// What the shell does for one close of a process client. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CloseStep { + /// Not declared: today's close (`CloseRequested` + `Kill`, the tile + /// leaves the layout at once, the reaper kills after `CLOSE_GRACE`). + Legacy, + /// Send `CloseRequested` alone and wait; the tile stays, in front. + Ask, + /// Already asked a moment ago: nothing new to do. + Wait, + /// Asked, unanswered, and asked again: end it now. + Force, +} + +/// What a shell quit asked of the declared apps. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct QuitAsk { + /// Sent `CloseRequested`; the quit waits on their answers. + pub asked: Vec, + /// Unanswered since an earlier ask: end them now. + pub forced: Vec, +} + +/// An app's word about closes (makepad `WmRequest::AsksBeforeClose` / +/// `WmRequest::CloseRefused`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CloseWord { + AsksBeforeClose, + CloseRefused, +} + +/// Recognise the two close words on the wire by their exact text, so the +/// shell understands an app that sends them whether or not the makepad it +/// is pinned to has the variants yet (an older `WmRequest::parse` rejects +/// them and they would fall through to the AI bus as noise). +pub fn parse_close_word(json: &str) -> Option { + if !json.contains("\"wm\"") { + return None; + } + let compact: String = json.chars().filter(|c| !c.is_whitespace()).collect(); + match compact.as_str() { + r#"{"wm":{"AsksBeforeClose":[]}}"# | r#"{"wm":"AsksBeforeClose"}"# => Some(CloseWord::AsksBeforeClose), + r#"{"wm":{"CloseRefused":[]}}"# | r#"{"wm":"CloseRefused"}"# => Some(CloseWord::CloseRefused), + _ => None, + } +} + +/// The shell's book of declared process apps and the closes they are +/// answering (see the module docs). Pure: the caller sends, kills and +/// removes tiles; this only decides. +#[derive(Debug, Default)] +pub struct ProcessCloseGate { + declared: HashSet, + pending: HashMap, + quit_waiting: bool, +} + +impl ProcessCloseGate { + /// The app sent `AsksBeforeClose`: its closes are asked and awaited. + pub fn declare(&mut self, client: ClientId) { + self.declared.insert(client); + } + + pub fn is_declared(&self, client: ClientId) -> bool { + self.declared.contains(&client) + } + + /// The person closes one app. A quit that was waiting is abandoned + /// (the person turned to something else), as for a hosted module. + pub fn close(&mut self, client: ClientId, now: f64) -> CloseStep { + self.quit_waiting = false; + self.step(client, now) + } + + fn step(&mut self, client: ClientId, now: f64) -> CloseStep { + if !self.declared.contains(&client) { + return CloseStep::Legacy; + } + match self.pending.get(&client) { + Some(Pending::Awaiting { since }) if now - since < REPEAT_WINDOW => CloseStep::Wait, + Some(Pending::Awaiting { .. }) => { + self.pending.remove(&client); + CloseStep::Force + } + // Idle, or asking the person: ask (again). A live app answers. + Some(Pending::Asking) | None => { + self.pending.insert(client, Pending::Awaiting { since: now }); + CloseStep::Ask + } + } + } + + /// The app sent `CloseRefused`. `true` when it answered a close this + /// gate is waiting on (it is asking the person now); a refusal nobody + /// asked for is ignored. + pub fn refused(&mut self, client: ClientId) -> bool { + match self.pending.get_mut(&client) { + Some(state @ Pending::Awaiting { .. }) => { + *state = Pending::Asking; + true + } + Some(Pending::Asking) => true, + None => false, + } + } + + /// The closes nobody answered within [`ANSWER_TIMEOUT`]: the caller + /// ends each. An app that is asking the person is never among them. + pub fn overdue(&mut self, now: f64) -> Vec { + let mut late: Vec = self + .pending + .iter() + .filter(|(_, p)| matches!(p, Pending::Awaiting { since } if now - since >= ANSWER_TIMEOUT)) + .map(|(client, _)| *client) + .collect(); + late.sort_unstable(); + for client in &late { + self.pending.remove(client); + } + late + } + + /// The client is gone or going on its own word (exited, killed, + /// removed, or it sent `WmRequest::Close`). `true` when a close of it + /// was pending: that was its answer, not a crash. + pub fn gone(&mut self, client: ClientId) -> bool { + self.declared.remove(&client); + self.pending.remove(&client).is_some() + } + + /// Whether a close of `client` is waiting on its answer or its person. + pub fn is_pending(&self, client: ClientId) -> bool { + self.pending.contains_key(&client) + } + + /// How many apps are asking the person right now. + pub fn asking(&self) -> usize { + self.pending.values().filter(|p| **p == Pending::Asking).count() + } + + /// Whether `client` refused and is asking the person. + pub fn is_asking(&self, client: ClientId) -> bool { + self.pending.get(&client) == Some(&Pending::Asking) + } + + /// The shell is quitting: ask every live declared app among `clients` + /// (the caller passes the windows the person has). Undeclared apps + /// are not asked: the shutdown ends them as it always did. + pub fn quit(&mut self, clients: impl IntoIterator, now: f64) -> QuitAsk { + let mut clients: Vec = clients.into_iter().collect(); + clients.sort_unstable(); + clients.dedup(); + let mut ask = QuitAsk::default(); + for client in clients { + match self.step(client, now) { + CloseStep::Ask => ask.asked.push(client), + CloseStep::Force => ask.forced.push(client), + CloseStep::Wait | CloseStep::Legacy => {} + } + } + self.quit_waiting = !self.pending.is_empty(); + ask + } + + /// A quit is waiting on apps here. + pub fn quit_waiting(&self) -> bool { + self.quit_waiting + } + + /// Nothing is pending: no app is answering or asking. + pub fn idle(&self) -> bool { + self.pending.is_empty() + } + + /// Drop a waiting quit (a new close of one app, or the quit went). + pub fn abandon_quit(&mut self) { + self.quit_waiting = false; + } +} + +/// A shell quit waits on both kinds of client that may ask: hosted +/// modules (`modules`, #186) and process apps (`apps`). It may go once one +/// of them is waiting and neither has anyone left answering or asking; +/// taking it clears both. +pub fn take_quit_ready(modules: &mut CloseGate, apps: &mut ProcessCloseGate) -> bool { + let waiting = modules.quit_waiting() || apps.quit_waiting(); + if !waiting || !modules.idle() || !apps.idle() { + return false; + } + modules.close_asked(); + apps.abandon_quit(); + true +} + +#[cfg(test)] +mod tests { + use super::*; + use makepad_widgets::WidgetUid; + + fn declared(clients: &[ClientId]) -> ProcessCloseGate { + let mut gate = ProcessCloseGate::default(); + for client in clients { + gate.declare(*client); + } + gate + } + + #[test] + fn the_close_words_are_read_off_the_wire() { + // What makepad's `WmRequest::{AsksBeforeClose,CloseRefused}.to_json()` sends. + assert_eq!(parse_close_word(r#"{"wm":{"AsksBeforeClose":[]}}"#), Some(CloseWord::AsksBeforeClose)); + assert_eq!(parse_close_word(r#"{"wm":{"CloseRefused":[]}}"#), Some(CloseWord::CloseRefused)); + assert_eq!(parse_close_word(r#"{ "wm" : "CloseRefused" }"#), Some(CloseWord::CloseRefused)); + assert_eq!(parse_close_word(r#"{"wm":{"Close":[]}}"#), None); + assert_eq!(parse_close_word(r#"{"wm":{"Title":{"title":"CloseRefused"}}}"#), None); + assert_eq!(parse_close_word("CloseRefused"), None); + } + + #[test] + fn an_app_that_never_declared_closes_as_before() { + let mut gate = ProcessCloseGate::default(); + assert_eq!(gate.close(7, 0.0), CloseStep::Legacy); + assert!(!gate.is_pending(7), "nothing waits on it"); + assert!(gate.overdue(100.0).is_empty()); + assert!(!gate.refused(7), "a stray refusal from it is ignored"); + } + + #[test] + fn a_declared_app_is_asked_and_its_exit_is_the_yes() { + let mut gate = declared(&[3]); + assert_eq!(gate.close(3, 10.0), CloseStep::Ask); + assert!(gate.is_pending(3)); + // It quit itself (nothing running, or the person said yes). + assert!(gate.gone(3), "the exit answered a pending close: not a crash"); + assert!(!gate.is_pending(3) && !gate.is_declared(3)); + assert!(gate.idle()); + } + + #[test] + fn a_refusal_keeps_it_with_no_deadline_and_a_confirmed_exit_removes_it() { + let mut gate = declared(&[3]); + assert_eq!(gate.close(3, 0.0), CloseStep::Ask); + assert!(gate.refused(3)); + assert!(gate.is_asking(3)); + // The person takes their time, or says no: never ended for it. + assert!(gate.overdue(ANSWER_TIMEOUT * 100.0).is_empty(), "an app asking is never killed"); + assert!(gate.is_asking(3), "a no leaves the question's mark"); + // Later: the person said yes in the app; it quits; the tile goes. + assert!(gate.gone(3)); + } + + #[test] + fn an_app_that_never_answers_is_ended_after_the_timeout() { + let mut gate = declared(&[3, 4]); + assert_eq!(gate.close(3, 0.0), CloseStep::Ask); + assert_eq!(gate.close(4, 1.0), CloseStep::Ask); + assert!(gate.overdue(ANSWER_TIMEOUT - 0.01).is_empty(), "not yet"); + assert_eq!(gate.overdue(ANSWER_TIMEOUT), vec![3]); + assert!(!gate.is_pending(3), "handed to the caller to end"); + assert_eq!(gate.overdue(ANSWER_TIMEOUT + 1.0), vec![4]); + assert!(gate.idle()); + } + + #[test] + fn a_second_close_of_an_unanswered_app_forces_it() { + let mut gate = declared(&[3]); + assert_eq!(gate.close(3, 0.0), CloseStep::Ask); + assert_eq!(gate.close(3, REPEAT_WINDOW / 2.0), CloseStep::Wait, "a double click is one close"); + assert_eq!(gate.close(3, REPEAT_WINDOW), CloseStep::Force); + assert!(!gate.is_pending(3)); + } + + #[test] + fn a_second_close_of_an_app_that_is_asking_asks_again() { + let mut gate = declared(&[3]); + gate.close(3, 0.0); + gate.refused(3); + // Its question is up; the person closes the tile again. The app is + // asked again, not killed: a live app re-answers at once. + assert_eq!(gate.close(3, 30.0), CloseStep::Ask); + assert!(gate.refused(3)); + assert!(gate.is_asking(3)); + // A frozen one does not re-answer: the next close forces it, or the + // timeout ends it. + assert_eq!(gate.close(3, 40.0), CloseStep::Ask); + assert_eq!(gate.close(3, 41.0), CloseStep::Force); + gate.close(3, 50.0); + assert_eq!(gate.overdue(50.0 + ANSWER_TIMEOUT), vec![3]); + } + + #[test] + fn quit_waits_for_every_declared_app_and_goes_when_the_last_exits() { + let mut gate = declared(&[1, 2, 3]); + // 9 never declared: the shutdown ends it, nobody waits on it. + let ask = gate.quit([3, 1, 2, 9], 0.0); + assert_eq!(ask, QuitAsk { asked: vec![1, 2, 3], forced: vec![] }); + assert!(gate.quit_waiting()); + gate.gone(1); // nothing running: quit itself + assert!(gate.refused(2)); // asking the person + assert!(gate.refused(3)); + assert!(!gate.idle(), "the quit waits"); + gate.gone(2); // yes + assert!(!gate.idle()); + gate.gone(3); // yes + assert!(gate.idle() && gate.quit_waiting(), "the quit may go now"); + gate.abandon_quit(); + assert!(!gate.quit_waiting()); + } + + #[test] + fn quit_waits_for_modules_and_process_apps_together() { + let mut modules = CloseGate::default(); + let mut apps = declared(&[5]); + let root = WidgetUid(42); + // One in-process terminal refuses; one process terminal is asked. + assert!(!modules.quit_asked([(4, root)])); + assert_eq!(apps.quit([5], 0.0).asked, vec![5]); + assert!(!take_quit_ready(&mut modules, &mut apps)); + // The process one says yes and exits: the module still asks. + apps.gone(5); + assert!(!take_quit_ready(&mut modules, &mut apps), "the module is still asking"); + // The module confirms: now the quit goes, once. + assert_eq!(modules.confirmed(root), Some(4)); + assert!(take_quit_ready(&mut modules, &mut apps)); + assert!(!take_quit_ready(&mut modules, &mut apps), "taken"); + + // The other way round: only process apps hold it. + let mut modules = CloseGate::default(); + let mut apps = declared(&[6]); + assert!(modules.quit_asked(std::iter::empty())); + apps.quit([6], 0.0); + apps.refused(6); + assert!(!take_quit_ready(&mut modules, &mut apps)); + apps.gone(6); + assert!(take_quit_ready(&mut modules, &mut apps)); + + // Nobody waiting: nothing to take (an ordinary close emptied it). + let mut apps = declared(&[7]); + apps.close(7, 0.0); + apps.gone(7); + assert!(!take_quit_ready(&mut CloseGate::default(), &mut apps)); + } + + #[test] + fn a_quit_with_no_declared_app_goes_at_once() { + let mut gate = ProcessCloseGate::default(); + assert_eq!(gate.quit([1, 2], 0.0), QuitAsk::default()); + assert!(!gate.quit_waiting() && gate.idle()); + } + + #[test] + fn a_no_in_one_app_holds_the_quit_and_a_close_abandons_it() { + let mut gate = declared(&[1, 2]); + gate.quit([1, 2], 0.0); + gate.refused(1); + gate.gone(2); + assert!(!gate.idle(), "1 is still asking (the person said no)"); + // The person turns to closing one app: the waiting quit is dropped, + // so confirming later ends only that app, never the shell. + assert_eq!(gate.close(1, 20.0), CloseStep::Ask); + assert!(!gate.quit_waiting()); + } + + #[test] + fn a_hung_app_holding_a_quit_falls_to_the_timeout_or_a_second_quit() { + let mut gate = declared(&[1, 2]); + gate.quit([1, 2], 0.0); + gate.refused(1); + // 2 hangs. A second Cmd+Q forces it; 1 (asking) is asked again. + let again = gate.quit([1, 2], 2.0); + assert_eq!(again, QuitAsk { asked: vec![1], forced: vec![2] }); + assert!(gate.quit_waiting(), "1 still has to answer"); + // Or, left alone, the timeout ends an unanswered one. + assert_eq!(gate.overdue(2.0 + ANSWER_TIMEOUT), vec![1]); + assert!(gate.idle()); + } +}