From dec7f765491ef590da779cb83750d78e04199d2f Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:17:57 -0700 Subject: [PATCH] shell: a hosted module may refuse a close and ask first [skip ci] makepad#65 gave AppModule a close-veto hook (close_requested -> CloseDecision, ModuleCloseAction::Confirmed). The shell dropped a module instance at once, so closing an in-process Terminal SIGHUPed its running jobs without asking. - request_close asks a live instance first. Allow: torn down as before. Veto: the tile and instance stay, the tile comes to the front so its question shows, and the root's uid is remembered (module_host CloseGate). - A ModuleCloseAction::Confirmed from a pending root's uid tears that instance down; from any other uid it is ignored. A cancel emits nothing. - Quit (the shell menu's Quit, Cmd+Q / QuitRequested, the window's close button) asks every instance and waits while any refuses; it quits when the last one confirms. A termination signal, a crash or a fault never asks. A later close of one app abandons a waiting quit. - A module that panics while answering is contained and let go; a failed instance no longer holds a quit. - The assistant's close tool says the app is asking; a Recents swipe on a refusing app opens it instead of staying on Recents. Co-Authored-By: Claude Opus 5.5 --- crates/shell/src/lib.rs | 85 +++++++++- crates/shell/src/mobile_app.rs | 2 +- crates/shell/src/module_close_tests.rs | 226 +++++++++++++++++++++++++ crates/shell/src/module_host.rs | 143 ++++++++++++++++ 4 files changed, 451 insertions(+), 5 deletions(-) create mode 100644 crates/shell/src/module_close_tests.rs diff --git a/crates/shell/src/lib.rs b/crates/shell/src/lib.rs index fedb42b8..4c68c504 100644 --- a/crates/shell/src/lib.rs +++ b/crates/shell/src/lib.rs @@ -48,6 +48,8 @@ pub mod layout; pub mod octosense; pub mod module_host; #[cfg(test)] +mod module_close_tests; +#[cfg(test)] mod module_panic_tests; pub mod module_view; pub mod native_apps; @@ -104,7 +106,7 @@ use ai_bus::{AiBus, Route}; use apps::{AppRegistry, Hosting}; use android_integration::AndroidRuntime; use makepad_ai_services::wire::{ServiceCall, ServiceDown, ToolResult}; -use makepad_app_module::{AppModule, ExecOutcome, ModuleUpstream, WindowRequest}; +use makepad_app_module::{AppModule, CloseDecision, ExecOutcome, ModuleCloseAction, ModuleUpstream, WindowRequest}; use module_host::ModuleHost; use pane_links::{PaneCall, PaneLinks}; use makepad_widgets::ai_slot::AiSlotRequests; @@ -1576,10 +1578,23 @@ impl App { } fn request_close(&mut self, cx: &mut Cx, client: ClientId) { + 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) + // refuses and asks the person in its own root. Its tile stays, in + // front so the question is seen; a yes arrives as the root's + // `ModuleCloseAction::Confirmed` (handle_actions), a no as nothing. + if !stopped && self.module_host.is_module(client) && self.module_host.ask_close(cx, client) == CloseDecision::Veto { + let app = self.state.as_ref().and_then(|s| s.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 confirms"); + self.activate_client(cx, client); + self.update_bar(cx); + self.redraw_all(cx); + return; + } // A module instance has no process to ask politely and nothing to // reap later: it ends now, through the same removal as a death. // So does one of its extra windows, and a stopped process's tile. - let stopped = self.state.as_ref().and_then(|s| s.clients.get(&client)).is_some_and(|s| s.stopped); if stopped || self.module_host.is_module(client) || self.module_windows.contains_key(&client) { self.remove_client(cx, client); self.update_bar(cx); @@ -1624,6 +1639,38 @@ impl App { self.redraw_all(cx); } + /// 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 + /// whose close is not pending is ignored. + fn module_close_confirmed(&mut self, cx: &mut Cx, uid: WidgetUid) { + let Some(client) = self.module_host.close_confirmed(uid) else { + return; + }; + log!("wm: client {client} confirmed its close"); + self.remove_client(cx, client); + self.update_bar(cx); + self.redraw_all(cx); + } + + /// The shell is asked to quit (the menu's Quit, Cmd+Q, the window's + /// close button): every hosted instance is asked first. True when + /// nobody refused and the quit may go ahead now; otherwise the first + /// refusing instance comes to the front with its question, and the + /// quit happens when the last of them confirms (`take_quit_ready`). A + /// 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 { + return true; + }; + log!("wm: quit waits: clients {refused:?} ask before closing"); + self.activate_client(cx, first); + self.update_bar(cx); + self.redraw_all(cx); + false + } + /// A client the pool is holding: no tile, no focus, invisible. fn is_warm(&mut self, client: ClientId) -> bool { self.state_mut() @@ -2567,7 +2614,11 @@ impl App { } Some(client) => { self.request_close(cx, client); - ToolResult::ok(id, format!("{app} is closing"), "closing") + if self.module_host.close_pending(client) { + ToolResult::ok(id, format!("{app} asks the person before closing"), "asking") + } else { + ToolResult::ok(id, format!("{app} is closing"), "closing") + } } }, }, @@ -3144,7 +3195,11 @@ impl App { return; } match target { - "system.quit" => cx.quit(), + "system.quit" => { + if self.ask_before_quit(cx) { + cx.quit(); + } + } "style.background" => { self.close_shell_menu(cx); self.next_background(cx); @@ -5068,6 +5123,9 @@ impl MatchEvent for App { let Some(wa) = action.as_widget_action() else { continue; }; + if let ModuleCloseAction::Confirmed = wa.cast::() { + self.module_close_confirmed(cx, wa.widget_uid); + } if let glance_panel::ShellGlancePanelAction::Open { app, route } = wa.cast::() { log!("wm: glance card opens {} (route {:?})", app, route); self.set_glance_open(cx, false); @@ -5343,9 +5401,28 @@ impl App { // or draw, or in a call the shell made — is contained by now; show // it closed and free it before the next event (module_host.rs). 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"); + cx.quit(); + } } fn shell_handle_event_inner(&mut self, cx: &mut Cx, event: &Event) { + // Quitting, or closing the shell's window, asks the hosted instances + // first (makepad#65). A termination signal is never refused. + match event { + Event::QuitRequested(request) + if request.reason != QuitReason::Signal && !request.handled.get() && !self.ask_before_quit(cx) => + { + request.handle(); + } + Event::WindowCloseRequested(request) if request.accept_close.get() && !self.ask_before_quit(cx) => { + request.accept_close.set(false); + } + _ => {} + } // Recording belongs to the WM, including on Home and in an OS menu. // Forwarding this chord also starts a recorder in the focused child. if let Event::KeyDown(e) | Event::KeyUp(e) = event { diff --git a/crates/shell/src/mobile_app.rs b/crates/shell/src/mobile_app.rs index 8e89282e..68dbe198 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);self.state_mut().phone.navigate(PhoneScreen::Recents);} + 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);}} }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_close_tests.rs b/crates/shell/src/module_close_tests.rs new file mode 100644 index 00000000..814c5ae0 --- /dev/null +++ b/crates/shell/src/module_close_tests.rs @@ -0,0 +1,226 @@ +//! Hosted close confirmation (makepad#65, `AppModule::close_requested`): +//! a module that refuses a close keeps its instance until its root emits +//! `ModuleCloseAction::Confirmed`, and a shell quit waits on every refusal. +//! The shell's side is `App::request_close`, `App::module_close_confirmed` +//! and `App::ask_before_quit` (lib.rs); these pin the decisions they act on. + +use crate::module_host::{CloseGate, ModuleHost}; +use makepad_ai_services::wire::{ServiceCall, ServiceManifest}; +use makepad_app_module::*; +use makepad_widgets::*; + +/// How a probe answers `close_requested`. +#[derive(Clone, Copy)] +enum Answer { + /// The trait's default (no override reached). + Default, + Veto, + Panic, +} + +struct CloseProbe { + id: &'static str, + answer: Answer, +} + +impl AppModule for CloseProbe { + fn id(&self) -> &'static str { + self.id + } + fn label(&self) -> &'static str { + "Close probe" + } + fn capabilities(&self) -> &'static [&'static str] { + &[] + } + fn open_schema(&self) -> OpenSchema { + OpenSchema::new(1) + } + fn register(&self, _vm: &mut ScriptVm) {} + fn create(&self, vm: &mut ScriptVm, _open: ValidatedOpen, _handles: InstanceHandles) -> InstanceParts { + let value = script_eval!(vm, { use mod.widgets.* View {} }); + InstanceParts { + root: WidgetRef::script_from_value(vm, value), + executor: Box::new(Quiet), + shutdown: Box::new(|_| {}), + } + } + fn close_requested(&self, _cx: &mut Cx, _root: &WidgetRef) -> CloseDecision { + match self.answer { + Answer::Default => CloseDecision::Allow, + Answer::Veto => CloseDecision::Veto, + Answer::Panic => panic!("probe: panic in close_requested"), + } + } +} + +struct Quiet; +impl ServiceExecutor for Quiet { + fn manifest(&self) -> ServiceManifest { + ServiceManifest::new("close-probe", "Close probe", "test") + } + fn execute(&mut self, _cx: &mut Cx, call: &ServiceCall) -> ExecOutcome { + ExecOutcome::Done(makepad_ai_services::wire::ToolResult::unavailable(&call.call_id, "probe")) + } +} + +static AGREEABLE: CloseProbe = CloseProbe { id: "agreeable", answer: Answer::Default }; +static REFUSING: CloseProbe = CloseProbe { id: "refusing", answer: Answer::Veto }; +static PANICKING: CloseProbe = CloseProbe { id: "panicking", answer: Answer::Panic }; + +fn setup() -> (Cx, ModuleHost) { + let mut cx = Cx::new(Box::new(|_, _| {})); + cx.with_vm(makepad_widgets::script_mod); + (cx, ModuleHost::default()) +} + +fn create(cx: &mut Cx, host: &mut ModuleHost, client: u64, module: &'static dyn AppModule) { + host.create(cx, client, module, module.open_schema().empty_open().unwrap(), dvec2(400.0, 700.0)).unwrap(); +} + +fn uid(host: &ModuleHost, client: u64) -> WidgetUid { + host.get(client).unwrap().root.widget_uid() +} + +// ---- the gate's decisions, without a module ---- + +#[test] +fn an_allowed_close_tears_down_and_leaves_nothing_pending() { + let mut gate = CloseGate::default(); + assert!(gate.answered(1, CloseDecision::Allow, WidgetUid(10))); + assert!(!gate.is_pending(1)); + assert_eq!(gate.confirmed(WidgetUid(10)), None, "nothing was waiting for a yes"); +} + +#[test] +fn a_vetoed_close_keeps_the_instance_and_waits_for_its_root() { + let mut gate = CloseGate::default(); + assert!(!gate.answered(1, CloseDecision::Veto, WidgetUid(10)), "a veto never tears down"); + assert!(gate.is_pending(1)); + // Asked again (the person clicked close twice) and it now allows: the + // mark goes and the instance closes. + assert!(gate.answered(1, CloseDecision::Allow, WidgetUid(10))); + assert!(!gate.is_pending(1)); +} + +#[test] +fn only_the_pending_root_confirms_its_close() { + let mut gate = CloseGate::default(); + gate.answered(1, CloseDecision::Veto, WidgetUid(10)); + gate.answered(2, CloseDecision::Veto, WidgetUid(20)); + assert_eq!(gate.confirmed(WidgetUid(99)), None, "another widget's yes is ignored"); + assert!(gate.is_pending(1) && gate.is_pending(2)); + assert_eq!(gate.confirmed(WidgetUid(20)), Some(2)); + assert!(!gate.is_pending(2) && gate.is_pending(1)); + assert_eq!(gate.confirmed(WidgetUid(20)), None, "a second yes closes nothing more"); + gate.forget(1); + assert_eq!(gate.confirmed(WidgetUid(10)), None, "a torn-down instance's root confirms nothing"); +} + +#[test] +fn a_quit_nobody_refuses_goes_ahead_at_once() { + let mut gate = CloseGate::default(); + assert!(gate.quit_asked([])); + assert!(!gate.quit_waiting()); + assert!(!gate.take_quit_ready(), "it quit already; nothing is waiting"); +} + +#[test] +fn a_quit_waits_for_every_refusing_instance() { + let mut gate = CloseGate::default(); + assert!(!gate.quit_asked([(1, WidgetUid(10)), (2, WidgetUid(20))])); + assert!(gate.quit_waiting()); + assert_eq!(gate.confirmed(WidgetUid(10)), Some(1)); + assert!(!gate.take_quit_ready(), "one still asks"); + assert_eq!(gate.confirmed(WidgetUid(20)), Some(2)); + assert!(gate.take_quit_ready(), "the last yes lets the quit go"); + assert!(!gate.take_quit_ready(), "once"); +} + +#[test] +fn a_failed_instance_does_not_hold_a_quit_forever() { + let mut gate = CloseGate::default(); + assert!(!gate.quit_asked([(1, WidgetUid(10))])); + gate.forget(1); + assert!(gate.take_quit_ready()); +} + +#[test] +fn closing_one_app_abandons_a_waiting_quit() { + // The person declined the quit's question in the terminal, then later + // closes that terminal and says yes: only the terminal closes. + let mut gate = CloseGate::default(); + assert!(!gate.quit_asked([(1, WidgetUid(10))])); + gate.close_asked(); + assert!(!gate.answered(1, CloseDecision::Veto, WidgetUid(10))); + assert_eq!(gate.confirmed(WidgetUid(10)), Some(1)); + assert!(!gate.take_quit_ready(), "the shell stays up"); +} + +// ---- the host, with real instances ---- + +#[test] +fn a_module_that_allows_is_closed_and_one_that_refuses_is_kept() { + let (mut cx, mut host) = setup(); + create(&mut cx, &mut host, 1, &AGREEABLE); + create(&mut cx, &mut host, 2, &REFUSING); + + assert_eq!(host.ask_close(&mut cx, 1), CloseDecision::Allow); + assert!(!host.close_pending(1)); + assert!(host.teardown(&mut cx, 1)); + + assert_eq!(host.ask_close(&mut cx, 2), CloseDecision::Veto); + assert!(host.close_pending(2), "a refusal is remembered"); + assert!(host.is_module(2), "and the instance stays"); + + assert!(host.teardown(&mut cx, 2)); + assert!(!host.close_pending(2), "a teardown clears the mark"); +} + +#[test] +fn a_confirmation_closes_only_the_instance_whose_root_sent_it() { + let (mut cx, mut host) = setup(); + create(&mut cx, &mut host, 1, &REFUSING); + create(&mut cx, &mut host, 2, &REFUSING); + let (first, second) = (uid(&host, 1), uid(&host, 2)); + assert_ne!(first, second); + + assert_eq!(host.ask_close(&mut cx, 1), CloseDecision::Veto); + // Instance 2 was never asked: its yes means nothing. + assert_eq!(host.close_confirmed(second), None); + assert_eq!(host.close_confirmed(first), Some(1)); + assert!(!host.close_pending(1)); + assert!(host.teardown(&mut cx, 1) && host.teardown(&mut cx, 2)); +} + +#[test] +fn a_module_that_panics_while_answering_is_let_go() { + let (mut cx, mut host) = setup(); + create(&mut cx, &mut host, 1, &PANICKING); + assert_eq!(host.ask_close(&mut cx, 1), CloseDecision::Allow); + assert!(!host.close_pending(1)); + assert_eq!(host.take_faults(&mut cx).len(), 1, "the panic is contained and reported"); + assert!(host.teardown(&mut cx, 1)); +} + +#[test] +fn a_shell_quit_asks_every_instance_and_waits_for_the_refusals() { + let (mut cx, mut host) = setup(); + create(&mut cx, &mut host, 1, &AGREEABLE); + create(&mut cx, &mut host, 2, &REFUSING); + create(&mut cx, &mut host, 3, &REFUSING); + + assert_eq!(host.ask_quit(&mut cx), vec![2, 3]); + assert!(!host.take_quit_ready()); + assert_eq!(host.close_confirmed(uid(&host, 3)), Some(3)); + assert!(host.teardown(&mut cx, 3)); + assert!(!host.take_quit_ready(), "instance 2 still asks"); + assert_eq!(host.close_confirmed(uid(&host, 2)), Some(2)); + assert!(host.teardown(&mut cx, 2)); + assert!(host.take_quit_ready()); + + // Only instances that allow: the quit goes at once. + assert!(host.ask_quit(&mut cx).is_empty()); + assert!(!host.take_quit_ready()); + assert!(host.teardown(&mut cx, 1)); +} diff --git a/crates/shell/src/module_host.rs b/crates/shell/src/module_host.rs index e39ad530..6b7d38dd 100644 --- a/crates/shell/src/module_host.rs +++ b/crates/shell/src/module_host.rs @@ -285,6 +285,92 @@ pub struct ModuleHost { next_scope: u64, per_app: HashMap, style: Option, + /// Closes an instance refused while it asks the person (makepad#65). + close_gate: CloseGate, +} + +/// The closes instances refused (`AppModule::close_requested` answered +/// `Veto`) and a shell quit waiting on them. +/// +/// A veto means the instance is showing its own question in its root (the +/// terminal: "Closing the terminal ends 1 running job…"). The host keeps +/// the instance and remembers its root's uid; the root emits +/// `ModuleCloseAction::Confirmed` from that uid on a yes, and the shell +/// tears the instance down then. A no emits nothing: the mark stays until +/// the next close asks again or the instance goes another way. +/// +/// A quit asks every instance: with no veto it quits at once; otherwise it +/// waits, and quits when the last refusing instance confirms. The person +/// can decline in any of them; the quit then just never completes, and a +/// later close the person asks for (or a new quit, which asks again) +/// drops the waiting quit, so confirming that close later ends only that +/// instance, never the shell. +#[derive(Debug, Default)] +pub struct CloseGate { + pending: HashMap, + quit_waiting: bool, +} + +impl CloseGate { + /// An instance answered a close: `true` when the host should tear it + /// down now (`Allow`); on `Veto` it stays and `root_uid` is remembered. + pub fn answered(&mut self, client: ClientId, decision: CloseDecision, root_uid: WidgetUid) -> bool { + match decision { + CloseDecision::Allow => { + self.pending.remove(&client); + true + } + CloseDecision::Veto => { + self.pending.insert(client, root_uid); + false + } + } + } + + /// A root emitted `ModuleCloseAction::Confirmed`: the instance to tear + /// down, when the uid is one whose close is pending. Any other uid (an + /// instance nobody asked to close, a stale root) is ignored. + pub fn confirmed(&mut self, uid: WidgetUid) -> Option { + let client = self.pending.iter().find(|(_, pending)| **pending == uid).map(|(client, _)| *client)?; + self.pending.remove(&client); + Some(client) + } + + pub fn is_pending(&self, client: ClientId) -> bool { + self.pending.contains_key(&client) + } + + /// The instance went (torn down, failed): nothing to wait for. + pub fn forget(&mut self, client: ClientId) { + self.pending.remove(&client); + } + + /// A person-initiated close of one instance: a quit that was waiting + /// is abandoned (the person turned to something else). + pub fn close_asked(&mut self) { + self.quit_waiting = false; + } + + /// A shell quit asked every live instance and these refused. `true`: + /// quit now (nobody refused). Otherwise the quit waits for them. + pub fn quit_asked(&mut self, refused: impl IntoIterator) -> bool { + self.pending.extend(refused); + self.quit_waiting = !self.pending.is_empty(); + !self.quit_waiting + } + + /// A waiting quit whose last refusing instance has now confirmed. + pub fn take_quit_ready(&mut self) -> bool { + let ready = self.quit_waiting && self.pending.is_empty(); + if ready { + self.quit_waiting = false; + } + ready + } + + pub fn quit_waiting(&self) -> bool { + self.quit_waiting + } } /// The isolate removes mod.res after bootstrap. Trusted framework themes @@ -543,6 +629,60 @@ impl ModuleHost { contain(cx, vm_id, what, |cx| f(cx, &root)) } + /// Ask the live instance `client` whether it may close + /// (`AppModule::close_requested`, outside its isolate, contained) and + /// remember a refusal. `Allow` for a failed instance, a non-module and + /// a module that panicked while answering: nothing is left to protect. + pub fn ask_close(&mut self, cx: &mut Cx, client: ClientId) -> CloseDecision { + self.close_gate.close_asked(); + self.ask_one(cx, client) + } + + fn ask_one(&mut self, cx: &mut Cx, client: ClientId) -> CloseDecision { + let Some(instance) = self.instances.get(&client).filter(|i| i.failed.is_none()) else { + self.close_gate.forget(client); + return CloseDecision::Allow; + }; + let (module, root, vm_id) = (instance.module, instance.root.clone(), instance.vm_id); + let decision = contain_outside(cx, vm_id, "close_requested", |cx| module.close_requested(cx, &root)) + .unwrap_or(CloseDecision::Allow); + self.close_gate.answered(client, decision, root.widget_uid()); + decision + } + + /// The shell is quitting: ask every live instance. The clients that + /// refused (each now asking the person); empty means quit now. + pub fn ask_quit(&mut self, cx: &mut Cx) -> Vec { + let mut clients: Vec = self.instances.keys().copied().collect(); + clients.sort_unstable(); + let mut refused = Vec::new(); + for client in clients { + if self.ask_one(cx, client) == CloseDecision::Veto { + let uid = self.instances[&client].root.widget_uid(); + refused.push((client, uid)); + } + } + let vetoed = refused.iter().map(|(client, _)| *client).collect(); + self.close_gate.quit_asked(refused); + vetoed + } + + /// A root emitted `ModuleCloseAction::Confirmed`: the instance to tear + /// down now, when its close was pending. + pub fn close_confirmed(&mut self, uid: WidgetUid) -> Option { + self.close_gate.confirmed(uid) + } + + /// Whether `client` refused a close and is asking the person. + pub fn close_pending(&self, client: ClientId) -> bool { + self.close_gate.is_pending(client) + } + + /// A waiting quit can go ahead now (every refusing instance confirmed). + pub fn take_quit_ready(&mut self) -> bool { + self.close_gate.take_quit_ready() + } + /// 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()) @@ -685,6 +825,7 @@ impl ModuleHost { let Some(mut instance) = self.instances.remove(&client) else { return false; }; + self.close_gate.forget(client); let label = instance.label(); if instance.failed.is_some() { if !instance.released { @@ -762,6 +903,8 @@ impl ModuleHost { /// The entry stays, failed, until the shell closes or restarts it. pub fn release_failed(&mut self, cx: &mut Cx, client: ClientId) { let Some(mut instance) = self.instances.remove(&client) else { return }; + // A failed instance will never confirm the close it was asking about. + self.close_gate.forget(client); if instance.failed.is_some() && !instance.released { self.release_instance(cx, &mut instance); }