diff --git a/src/api.rs b/src/api.rs index 211b45d..2f05978 100644 --- a/src/api.rs +++ b/src/api.rs @@ -2,10 +2,13 @@ use std::{ io::Read, ops::{Deref, DerefMut}, panic::{AssertUnwindSafe, catch_unwind}, - sync::Mutex, sync::{ Arc, RwLock, - mpsc::{SyncSender, TrySendError, sync_channel}, + mpsc::{Receiver, SyncSender, TrySendError, sync_channel}, + }, + sync::{ + Condvar, Mutex, + atomic::{AtomicBool, Ordering}, }, thread, }; @@ -25,6 +28,48 @@ pub const API_ADDRESS: &str = "127.0.0.1:4737"; const MAX_CODE_SIZE: u64 = endor_host::MAX_SOURCE_BYTES as u64; type Notify = Arc; +#[derive(Clone, Debug)] +pub struct ShutdownOutcome { + pub summary: String, + pub error: Option, +} +type ShutdownCompletion = Arc<(Mutex>, Condvar)>; +#[derive(Clone)] +pub struct RuntimeControl { + sender: SyncSender<()>, + requested: Arc, + completion: ShutdownCompletion, +} +impl RuntimeControl { + pub fn request_shutdown(&self) -> bool { + if self.requested.swap(true, Ordering::AcqRel) { + return true; + } + if self.sender.try_send(()).is_ok() { + true + } else { + self.requested.store(false, Ordering::Release); + false + } + } + pub fn is_exiting(&self) -> bool { + self.requested.load(Ordering::Acquire) + } + pub fn wait_shutdown(&self, timeout: std::time::Duration) -> Option { + let (lock, ready) = &*self.completion; + let result = lock.lock().ok()?; + let (result, _) = ready + .wait_timeout_while(result, timeout, |result| result.is_none()) + .ok()?; + result.clone() + } +} +struct Shutdown { + receiver: Receiver<()>, + control: RuntimeControl, + proxy: EventLoopProxy, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum RequestKind { Event, @@ -45,7 +90,18 @@ struct BuildRequest { pub fn start( namespace: Arc>, proxy: EventLoopProxy, -) -> Result { +) -> Result<(Activity, RuntimeControl)> { + let (sender, receiver) = sync_channel(1); + let control = RuntimeControl { + sender, + requested: Arc::new(AtomicBool::new(false)), + completion: Arc::new((Mutex::new(None), Condvar::new())), + }; + let shutdown = Shutdown { + receiver, + control: control.clone(), + proxy: proxy.clone(), + }; let server = Server::http(API_ADDRESS) .map_err(|error| anyhow::anyhow!(error)) .with_context(|| format!("bind Tessera API to {API_ADDRESS}"))?; @@ -59,8 +115,9 @@ pub fn start( notify.clone(), activity.clone(), notify, + Some(shutdown), )?; - Ok(activity) + Ok((activity, control)) } #[cfg(test)] @@ -75,6 +132,7 @@ fn start_server( notify, Arc::new(Mutex::new(ActivityLog::default())), Arc::new(|| {}), + None, ) } @@ -84,16 +142,56 @@ fn start_server_observed( notify: Notify, activity: Activity, observer_notify: Notify, + shutdown: Option, ) -> Result> { let (sender, receiver) = sync_channel::(8); let worker_namespace = namespace.clone(); let worker_notify = notify.clone(); + let exiting = shutdown + .as_ref() + .map(|shutdown| shutdown.control.requested.clone()); thread::Builder::new() .name("tessera-endor-worker".into()) .spawn(move || { let mut worker = Worker::default(); let mut pending: Option<(BuildRequest, PendingRequest)> = None; loop { + if let Some(shutdown) = &shutdown + && shutdown.receiver.try_recv().is_ok() + { + if let Some((job, input)) = pending.take() { + drop(input); + let _ = job + .request + .respond(error_response(StatusCode(503), "application is exiting")); + } + let outcome = worker.shutdown_runtime(); + while let Ok(job) = receiver.try_recv() { + let _ = job + .request + .respond(error_response(StatusCode(503), "application is exiting")); + } + if let Ok(mut current) = worker_namespace.write() { + current.runtime_session = 0; + current.window_commands.clear(); + current.document.frame.session = 0; + current.document.frame.focus = None; + for view in current.document.windows.values_mut() { + view.frame.session = 0; + view.frame.focus = None; + } + } + eprintln!("{}", outcome.summary); + let (lock, ready) = &*shutdown.control.completion; + if let Ok(mut result) = lock.lock() { + *result = Some(outcome.clone()); + ready.notify_all(); + } + let _ = shutdown.proxy.send_event(AppEvent::ShutdownComplete { + error: outcome.error, + }); + break; + } if let Some((job, input)) = pending.take() { match worker.resume_request(&job, input, &worker_namespace, &worker_notify) { RequestProgress::Pending(input) => { @@ -142,6 +240,15 @@ fn start_server_observed( .name("tessera-agent-api".to_owned()) .spawn(move || { for request in server.incoming_requests() { + if exiting + .as_ref() + .is_some_and(|flag| flag.load(Ordering::Acquire)) + && !matches!(request.method(), Method::Get | Method::Options) + { + let _ = + request.respond(error_response(StatusCode(503), "application is exiting")); + continue; + } handle( request, &namespace, @@ -507,6 +614,36 @@ struct Worker { next_session: u64, } impl Worker { + fn shutdown_runtime(&mut self) -> ShutdownOutcome { + let Some((_, mut runtime)) = self.repl.take() else { + return ShutdownOutcome { + summary: "Tessera shutdown: no live runtime".into(), + error: None, + }; + }; + let callbacks = !runtime.is_faulted(); + let result = catch_unwind(AssertUnwindSafe(|| runtime.shutdown())); + match result { + Ok(Ok((windows, mounts, tasks))) => ShutdownOutcome { + summary: format!( + "Tessera shutdown: {windows} windows, {mounts} mounts, {tasks} task entries; callbacks={callbacks}" + ), + error: None, + }, + result => { + let error = match result { + Ok(Err(error)) => format!("shutdown cleanup failed: {error:#}"), + _ => "shutdown cleanup failed after an internal panic".into(), + }; + let error: String = error.chars().take(4096).collect(); + ShutdownOutcome { + summary: error.clone(), + error: Some(error), + } + } + } + } + fn close_inactive_window( &mut self, input: &crate::events::Input, @@ -1426,9 +1563,12 @@ declarations, explicit first `self` parameters, positional field construction an member assignment. Inherited methods bind the actual receiver; overridden methods are dynamically selected through self. Concrete native visual subclasses reuse real native allocators; abstract bases remain unconstructible. Explicit traits and source parameter/return annotation checks are enabled; declared-field construction/write checks are enabled alongside source function checks; complete initialization and static/effect typing remain pending. Native Block/Page methods use the same type method tables and binding path. -Window is a host-provided managed resource: ui.window(), page.window(), page.open(window), window.pages()/id()/viewport(), resize(width,height), moveTo(x,y), setTitle(text) and focus(). Requests project to their actual native window only after successful input/candidate completion and carry the current runtime session. Resize is asynchronous and the reported viewport is authoritative; this host accepts 1..4096 logical dimensions with a native 720x480 minimum. Failed input/candidates discard pending requests and read-only rendering rejects window effects. Open Pages appear in native tab chrome, with page.title, click activation/close, Ctrl+Tab cycling and Cmd/Ctrl+W close when page keyboard focus is active. Overflow arrows reach additional pages. These operations call actual Page methods in the same VM; tab IDs are weak runtime projections and add no GC roots. The 36-logical-pixel bar reduces Page constraint height; window/ui viewport reports the full inner size and native pointer positions are translated into content coordinates. Window() now creates additional native windows in the same VM, up to 16 open resources. Page.open(window)/moveToWindow(window) preserve Page and business identity while replacing Mounts; detachToWindow() creates and returns a new Window. Dragging a tab away from its strip invokes the same method. Window.close()/secondary OS close remove Page roots, unmount, and retire the resource context; retained Pages can reopen in another live window, and closed Window resources reject viewport/resize/title/focus operations. Closing the primary OS shell exits the application. Secondary viewport inputs and frame publication use the same worker. Secondary close after callback/rendering failure uses host-only teardown without invoking source callbacks or resuming execution. After reset/discard, closing an old picture never touches a fresh VM resource with the same number; viewport inputs are also generation-scoped. A teardown failure discards the affected VM and disables remaining pictures. Native initialization/surface errors are reported rather than panicking; failed secondary creation leaves a managed resource closable from Console. Native tab reordering, existing-window drops and release-point placement use the managed methods described below. Full application-exit notifications remain pending. +Window is a host-provided managed resource: ui.window(), page.window(), page.open(window), window.pages()/id()/viewport(), resize(width,height), moveTo(x,y), setTitle(text) and focus(). Requests project to their actual native window only after successful input/candidate completion and carry the current runtime session. Resize is asynchronous and the reported viewport is authoritative; this host accepts 1..4096 logical dimensions with a native 720x480 minimum. Failed input/candidates discard pending requests and read-only rendering rejects window effects. Open Pages appear in native tab chrome, with page.title, click activation/close, Ctrl+Tab cycling and Cmd/Ctrl+W close when page keyboard focus is active. Overflow arrows reach additional pages. These operations call actual Page methods in the same VM; tab IDs are weak runtime projections and add no GC roots. The 36-logical-pixel bar reduces Page constraint height; window/ui viewport reports the full inner size and native pointer positions are translated into content coordinates. Window() now creates additional native windows in the same VM, up to 16 open resources. Page.open(window)/moveToWindow(window) preserve Page and business identity while replacing Mounts; detachToWindow() creates and returns a new Window. Dragging a tab away from its strip invokes the same method. Window.close()/secondary OS close remove Page roots, unmount, and retire the resource context; retained Pages can reopen in another live window, and closed Window resources reject viewport/resize/title/focus operations. Closing the primary OS shell exits the application. Secondary viewport inputs and frame publication use the same worker. Secondary close after callback/rendering failure uses host-only teardown without invoking source callbacks or resuming execution. After reset/discard, closing an old picture never touches a fresh VM resource with the same number; viewport inputs are also generation-scoped. A teardown failure discards the affected VM and disables remaining pictures. Native initialization/surface errors are reported rather than panicking; failed secondary creation leaves a managed resource closable from Console. Native tab reordering, existing-window drops and release-point placement use the managed methods described below. Application-exit cleanup is described below. -While a native tab drag is held over an overflow arrow area, the strip waits 350ms then reveals one hidden tab every 120ms, including in a compatible destination window. The preview scroll does not activate Pages or change business state. Drawing, hit testing and insertion gaps share the same transient visible range; release uses its full-order insertion index. Leaving the strip stops the timer; release, Escape, a new press or runtime generation change restores the normal active-tab-centered strip. A resize or changing tab count clamps the range. Native drag/mixed-DPI behavior still requires platform validation; application-exit lifecycle notifications remain unfinished. +Application exit now uses a private VM-worker control channel, independent of HTTP queue capacity. Main native close and a managed primary Window.close request asynchronous cleanup; duplicate requests coalesce and mutation requests are rejected with 503 while exiting. An unpublished candidate/input refresh is cancelled without replay. The current VM cancels all Task entries, closes managed windows, detaches occurrences child-before-parent per window, and invokes normal onUnmount hooks once. It does not run layout/paint during shutdown. All windows are closed before hooks run; new windows and tasks are forbidden. A stopped/faulted VM detaches without source hooks; reset/discarded pictures cannot revive an old VM. A hook error aborts remaining source hooks, releases prepared hook roots, drops the VM and reports failure before native exit. Completed shutdown is acknowledged to the GUI, which then ends the event loop. An OS exit bypassing CloseRequested uses a five-second bounded final fallback; non-cooperative native calls remain atomic and force-kill/crash cannot guarantee notifications. Native stderr records the window/mount/task-entry counts and whether source hooks were enabled, or the cleanup error. See examples/exit-lifecycle.endor. Cross-platform exit/drag validation remains broader follow-up work. + + +While a native tab drag is held over an overflow arrow area, the strip waits 350ms then reveals one hidden tab every 120ms, including in a compatible destination window. The preview scroll does not activate Pages or change business state. Drawing, hit testing and insertion gaps share the same transient visible range; release uses its full-order insertion index. Leaving the strip stops the timer; release, Escape, a new press or runtime generation change restores the normal active-tab-centered strip. A resize or changing tab count clamps the range. Native drag/mixed-DPI behavior still requires platform validation; application-exit cleanup is described below. ContainerBlock.insert(position,block) accepts a zero-based position through the current count, returns a new stable slot and preserves existing slots/Mounts. Invalid indices and child cycles fail before changing the child list. Page.moveTab(position) uses a final zero-based index in its owning window. Native drops invoke the actual Page moveToWindow/moveTab/activate methods; a declined or redirected window move skips destination reordering. A drop allows 300000 instructions per method. Source overrides participate; earlier writes do not roll back after failure. Window.moveTo(x,y) queues the physical desktop outer origin, with signed integers within +/-1000000. Detached placement preserves the saved grab offset; the window manager may adjust it and unsupported global placement reports a diagnostic. Dragging uses a 12-logical-pixel threshold, visible insertion marker and Escape cancellation. Overflow auto-scroll, external-app occlusion and mixed-DPI verification remain pending. See examples/tab-placement.endor. diff --git a/src/app.rs b/src/app.rs index ee25d1b..19e1a7a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -27,6 +27,7 @@ use crate::{ pub enum AppEvent { RuntimeFailed { message: String }, DocumentUpdated, + ShutdownComplete { error: Option }, ConsoleFailed { message: String, source: String }, } @@ -40,6 +41,8 @@ struct TabScrollHover { pub struct TesseraApp { resource: u64, + control: crate::api::RuntimeControl, + exit_requested: bool, native_session: u64, native_error: Option, children: std::collections::BTreeMap, @@ -72,10 +75,13 @@ impl TesseraApp { namespace: Arc>, activity: Activity, proxy: EventLoopProxy, + control: crate::api::RuntimeControl, ) -> anyhow::Result { let event_client = crate::events::Client::new(proxy.clone())?; Ok(Self { resource: crate::window::MAIN_WINDOW, + control, + exit_requested: false, native_session: 0, native_error: None, children: std::collections::BTreeMap::new(), @@ -153,7 +159,12 @@ impl TesseraApp { submission, ..NamespaceState::default() })); - match Self::new(namespace, self.activity.clone(), self.proxy.clone()) { + match Self::new( + namespace, + self.activity.clone(), + self.proxy.clone(), + self.control.clone(), + ) { Ok(mut child) => { child.resource = id; child.native_session = session; @@ -188,7 +199,7 @@ impl TesseraApp { let id = envelope.command.window; if matches!(envelope.command.action, crate::window::Action::Close) { if id == crate::window::MAIN_WINDOW { - event_loop.exit(); + self.request_exit(); } else { self.children.remove(&id); } @@ -993,6 +1004,23 @@ impl TesseraApp { Ok(()) } + fn request_exit(&mut self) { + if self.exit_requested { + return; + } + self.clear_tab_scroll(); + self.guest_pressed = None; + self.guest_key = None; + if self.control.request_shutdown() { + self.exit_requested = true; + } else { + self.native_error = Some("VM worker unavailable during shutdown".into()); + let _ = self.proxy.send_event(AppEvent::ShutdownComplete { + error: self.native_error.clone(), + }); + } + } + pub fn exit_result(&self) -> anyhow::Result<()> { if let Some(error) = &self.native_error { anyhow::bail!("{error}"); @@ -1079,6 +1107,23 @@ impl TesseraApp { } impl ApplicationHandler for TesseraApp { + fn exiting(&mut self, _: &ActiveEventLoop) { + // OS termination can bypass CloseRequested. The loop is already ending; + // use a bounded fallback rather than silently dropping every callback. + if !self.control.request_shutdown() { + self.native_error = Some("VM worker unavailable during shutdown".into()); + return; + } + match self.control.wait_shutdown(Duration::from_secs(5)) { + Some(outcome) => { + if let Some(error) = outcome.error { + self.native_error = Some(error); + } + } + None => self.native_error = Some("VM shutdown did not finish within 5 seconds".into()), + } + } + fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { event_loop.set_control_flow(ControlFlow::Wait); self.tick_tab_scroll(event_loop); @@ -1097,7 +1142,16 @@ impl ApplicationHandler for TesseraApp { } fn user_event(&mut self, event_loop: &ActiveEventLoop, event: AppEvent) { + if self.control.is_exiting() && !matches!(&event, AppEvent::ShutdownComplete { .. }) { + return; + } match event { + AppEvent::ShutdownComplete { error } => { + if let Some(error) = error { + self.native_error = Some(error); + } + event_loop.exit(); + } AppEvent::RuntimeFailed { message } => { self.developer_tools.input_error = message; self.developer_tools.open = true; @@ -1124,6 +1178,9 @@ impl ApplicationHandler for TesseraApp { window_id: WindowId, event: WindowEvent, ) { + if self.control.is_exiting() { + return; + } self.native_tab_drag(window_id, &event); let Some(window) = self.window.as_ref().cloned() else { return; @@ -1144,7 +1201,7 @@ impl ApplicationHandler for TesseraApp { match event { WindowEvent::CloseRequested => { if self.resource == crate::window::MAIN_WINDOW { - event_loop.exit(); + self.request_exit(); } else { let input = self .namespace diff --git a/src/endor_host.rs b/src/endor_host.rs index 22ca9f9..eddaf24 100644 --- a/src/endor_host.rs +++ b/src/endor_host.rs @@ -999,6 +999,30 @@ impl ReplSession { result } + fn cancel_pending_refresh(&mut self) -> Result<()> { + if let Some(mut work) = self.refresh.take() { + if let Some(paint) = work.paint.take() { + self.bridge.cancel_paint_frame( + &mut self.candidate.vm, + paint.window, + *paint.work, + )?; + } + if let Some(pending) = work.pending.take() { + self.bridge + .cancel_layout_frame(&mut self.candidate.vm, *pending)?; + } + } + Ok(()) + } + + pub fn shutdown(&mut self) -> Result<(usize, usize, usize)> { + self.cancel_pending_refresh()?; + let callbacks = !self.faulted; + self.faulted = true; + self.bridge.shutdown(&mut self.candidate.vm, callbacks) + } + pub fn retire_window(&mut self, id: u64) -> Result<()> { self.require_idle_refresh()?; // This path is exclusively host cleanup after the domain has stopped. @@ -1083,21 +1107,7 @@ impl ReplSession { impl Drop for ReplSession { fn drop(&mut self) { - if let Some(mut work) = self.refresh.take() { - if let Some(paint) = work.paint.take() { - let _ = self.bridge.cancel_paint_frame( - &mut self.candidate.vm, - paint.window, - *paint.work, - ); - } - if let Some(pending) = work.pending.take() { - // Close the read-only call/checkpoint before Candidate roots. - let _ = self - .bridge - .cancel_layout_frame(&mut self.candidate.vm, *pending); - } - } + let _ = self.cancel_pending_refresh(); } } @@ -6774,4 +6784,66 @@ function main(): View { let child = ui.text("shared") return wrap(child) }"#; Some(deleted + 1) ); } + + const EXIT_SOURCE: &str = r#" +class ExitText: TextBlock { + log: Object + function onUnmount(self: ExitText, mount: Mount): Unit { + self.log.increment() + } +} +log = ui.state(0) +text = ExitText("exit",1,log) +page = Page() +page.render(text) +second = Window() +other = Page() +other.render(text) +other.moveToWindow(second) +pending = ui.sleep(ui,"pending",30000) +"#; + + fn exit_log(session: &ReplSession) -> Value { + let globals = session.candidate.globals.unwrap(); + let Some(Value::Object(log)) = session + .candidate + .vm + .dict_get(globals, &Value::Text("log".into())) + .unwrap() + else { + panic!("log") + }; + session.candidate.vm.get_attr(log, "value").unwrap() + } + + #[test] + fn shutdown_unmounts_all_windows_once_and_cancels_tasks() { + let (mut session, _) = ReplSession::load(EXIT_SOURCE, "app.main", 701, 1).unwrap(); + assert_eq!(exit_log(&session), Value::Number(0_i64.into())); + let (windows, mounts, tasks) = session.shutdown().unwrap(); + assert_eq!(windows, 2); + assert!(mounts >= 2); + assert!(tasks >= 1); + assert_eq!(exit_log(&session), Value::Number(2_i64.into())); + session.shutdown().unwrap(); + assert_eq!(exit_log(&session), Value::Number(2_i64.into())); + assert!(session.execute("42", "app.main").is_err()); + } + + #[test] + fn shutdown_stopped_runtime_never_reenters_source_hooks() { + let (mut session, _) = ReplSession::load(EXIT_SOURCE, "app.main", 702, 1).unwrap(); + session.faulted = true; + session.shutdown().unwrap(); + assert_eq!(exit_log(&session), Value::Number(0_i64.into())); + } + + #[test] + fn shutdown_hooks_cannot_create_new_background_work() { + let source = EXIT_SOURCE.replace("self.log.increment()", "ui.sleep(ui,\"late\",30000)"); + let (mut session, _) = ReplSession::load(&source, "app.main", 703, 1).unwrap(); + let error = session.shutdown().unwrap_err(); + assert!(format!("{error:#}").contains("runtime is exiting; new tasks are forbidden")); + assert!(session.is_faulted()); + } } diff --git a/src/main.rs b/src/main.rs index d0a643f..bae186a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,8 +46,8 @@ fn main() -> Result<()> { let event_loop = EventLoop::::with_user_event().build()?; event_loop.set_control_flow(ControlFlow::Wait); let namespace = Arc::new(RwLock::new(NamespaceState::default())); - let activity = api::start(namespace.clone(), event_loop.create_proxy())?; - let mut app = TesseraApp::new(namespace, activity, event_loop.create_proxy())?; + let (activity, control) = api::start(namespace.clone(), event_loop.create_proxy())?; + let mut app = TesseraApp::new(namespace, activity, event_loop.create_proxy(), control)?; event_loop.run_app(&mut app)?; app.exit_result() } diff --git a/src/tasks.rs b/src/tasks.rs index e82006a..c2d2bbb 100644 --- a/src/tasks.rs +++ b/src/tasks.rs @@ -247,6 +247,7 @@ struct After { signature: endor_core::CallSignature, index: Index, runnable: Arc, + quiescing: Arc, desktop: ObjectRef, } impl NativeCallable for After { @@ -258,6 +259,9 @@ impl NativeCallable for After { } fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { + if self.quiescing.load(Ordering::Acquire) { + return Err(invalid("runtime is exiting; new tasks are forbidden")); + } if cx.store().is_read_only() { return Err(invalid( "asynchronous work is forbidden during read-only rendering", @@ -560,10 +564,15 @@ impl NativeCallable for Method { pub struct Scheduler { index: Index, runnable: Arc, + quiescing: Arc, desktop: ObjectRef, provider: crate::io_tasks::Provider, } impl Scheduler { + pub fn quiesce(&self, vm: &mut Vm) -> Result { + self.quiescing.store(true, Ordering::Release); + self.cancel_all(vm) + } pub fn has_cooperative_work(&self) -> bool { self.runnable.load(Ordering::Acquire) } @@ -933,6 +942,7 @@ pub fn install( ) -> Result { let index = Arc::new(Mutex::new(Vec::new())); let runnable = Arc::new(AtomicBool::new(false)); + let quiescing = Arc::new(AtomicBool::new(false)); let definition = desktop_type(vm.object_store(), "Task", desktop)?; let provider = crate::io_tasks::Provider::default(); for (name, operation) in [ @@ -982,6 +992,7 @@ pub fn install( }, index: index.clone(), runnable: runnable.clone(), + quiescing: quiescing.clone(), desktop, }, vec![desktop], @@ -1023,6 +1034,7 @@ pub fn install( Ok(Scheduler { index, runnable, + quiescing, desktop, provider, }) diff --git a/src/ui_bridge.rs b/src/ui_bridge.rs index 5b5c6d9..95045ab 100644 --- a/src/ui_bridge.rs +++ b/src/ui_bridge.rs @@ -233,6 +233,7 @@ struct Session { layout_cancels: usize, paint_cancels: usize, paint_slices: usize, + exiting: bool, } impl std::ops::Deref for Session { @@ -771,6 +772,9 @@ fn create_window( "window creation is forbidden during read-only rendering", )); } + if state.exiting { + return Err(invalid("runtime is exiting; window creation is forbidden")); + } if state.presentations.len() >= 16 { return Err(VmError::ExecutionLimit("16 window budget")); } @@ -1812,6 +1816,77 @@ fn project_tabs( } impl UiBridge { + /// Detach all occurrences without running layout/paint. All windows are + /// already closed before source unmount hooks run, so they cannot remount. + pub fn shutdown(&self, vm: &mut Vm, callbacks: bool) -> Result<(usize, usize, usize)> { + let tasks = self.tasks.quiesce(vm)?; + let mut drivers = Vec::new(); + let mut mounts = 0; + let prepared = (|| -> Result { + let mut state = self + .session + .lock() + .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; + state.exiting = true; + let ids: Vec<_> = state.presentations.keys().copied().collect(); + for id in &ids { + let view = state.presentations.get_mut(id).expect("known window"); + let desktop = view.desktop; + mounts += view.mounts.active.len(); + if callbacks { + if let Some(hooks) = view.mounts.synchronize( + vm, + desktop, + None, + Vec::new(), + &crate::document::Frame::default(), + false, + )? { + drivers.push((*id, hooks)); + } + } else { + view.mounts.retire(vm, desktop)?; + } + let window = object(Some(&vm.get_attr(desktop, "window")?))?; + crate::window::retire(vm, window)?; + vm.set_attr(desktop, "pending", Value::Nil)?; + vm.set_attr(desktop, "focus", Value::Nil)?; + view.pending = Some(None); + view.active = None; + view.closing = true; + } + Ok(ids.len()) + })(); + let mut result = prepared.map(|windows| (windows, mounts, tasks)); + for (id, hooks) in drivers { + if result.is_ok() { + let call = (|| -> Result<()> { + self.select_window(id)?; + let value = vm + .call_with_limits( + hooks, + &[], + endor_core::ExecutionLimits { + max_frames: 128, + max_instructions: 1_000_000, + }, + ) + .map_err(|error| crate::endor_host::runtime_error(vm, error))?; + vm.release_value(value)?; + Ok(()) + })(); + if let Err(error) = call { + result = Err(error); + } + } + if let Err(error) = vm.release(hooks) { + result = Err(error.into()); + } + } + self.select_window(crate::window::MAIN_WINDOW)?; + result + } + pub fn has_cooperative_tasks(&self) -> bool { self.tasks.has_cooperative_work() } @@ -2902,6 +2977,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { layout_cancels: 0, paint_cancels: 0, paint_slices: 0, + exiting: false, })); let mut public_functions = Vec::new(); let mut factories = Vec::new();