# Launcher shutdown ownership The native close button starts two cooperating paths. Cancellation is a request to stop work; the later waits prove that the owners have finished. ```text Native CloseRequested | +-- Rust: begin_application_shutdown (main window only, once) | +-- close AppInvokeScope admission | +-- cancel registered unrar workers, including late registrations | +-- AppTaskScope child requests PeerRuntimeHandle::shutdown | +-- JS: Tauri onCloseRequested wrapper awaits bootstrapFrontend handler +-- windowAsyncScope.disposeOwned() | +-- invalidate owners and stop retry timers | +-- drain admitted invokes and listener registrations/cleanups | +-- drain sharing/Call-to-Play mutation queues and thumbnails | +-- settle pending companion-window creation +-- windowPersistenceScope.closeAndDrain(), concurrently | +-- finish settings hydration and queued settings writes | +-- finish admitted game-directory updates and persistence +-- Tauri calls plugin:window|destroy +-- capability authorization must allow this command +-- native window destruction; last window triggers Exit +-- shutdown_application +-- wait for AppInvokeScope guards to drop +-- take sole PeerRuntimeHandle from its slot +-- request shutdown again (idempotent) +-- wait_stopped: join the peer supervisor +-- cancel/close/wait AppTaskScope ``` The frontend handler keeps its close listener installed while draining. The first request returns without `preventDefault`, allowing Tauri's wrapper to destroy the window. Repeated requests prevent their own automatic destruction and await the same drain. Registration/render failure has a separate explicit destroy path. All three window capabilities therefore need `core:window:allow-destroy`; `core:default` permits event subscription but does not permit window destruction. `AppInvokeScope` guards keep backend command futures owned through completion. Its separate serial mutex orders startup, sharing changes, and acknowledged UI commits. The early cancellation task borrows the runtime slot to signal it; it does not take the join handle. After admitted invokes drain, final shutdown can take that slot without racing runtime publication or replacement. Background UI event processing remains alive until invokes and the peer have settled, because their acknowledgement paths can require that event loop. The peer supervisor owns an isolated Tokio runtime. Root shutdown closes and waits for the core task tracker. The network manager closes generation admission, cancels and drains its service tasks and operation permits, then stops and joins the QUIC endpoints. mDNS owners wait for daemon cleanup. Install/download owners settle their cancellation and rollback paths; captured unrar workers kill and reap their child and join pipe readers. `wait_stopped` joins the supervisor only after its runtime teardown. The existing slow warning continues waiting; it does not discard ownership on a timer. ## The September 12 close failure The native reproduction reached `plugin:window|destroy` after the frontend drain and failed with: ```text window.destroy not allowed. Permissions associated with this command: core:window:allow-destroy ``` This was a rejected final IPC command, not a wait cycle. Earlier code removed the close listener before trying the denied destroy, so a second native click could close the window without the JS handler. Keeping that listener installed correctly prevented the bypass, but exposed the missing permission on every click. Mocked successful destruction never exercised the capability boundary. There was also a rebuild trap: Tauri's context macro reads generated `capabilities.json` and `acl-manifests.json` through filesystem calls that do not appear in rustc dependency information. The configured `kache` compiler wrapper reused the old library after a permission-only rebuild. `application_context` explicitly includes both generated files so compiler caches track their contents. The ACL regression calls that same context constructor and checks the resolved listen, unlisten, and destroy commands for each window. Native X11/XWayland checks on September 12, 2026: | Case | Observed result | | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | Original capabilities | Destroy denied; window and process remained alive. | | Corrected capabilities and tracked ACL inputs | One close request; process exited normally in 22 ms. | | Both companion windows open | Each log window closed in 13 ms with the main process alive; main close then exited normally in 222 ms. | | Remove only the main destroy grant, then restore it | Denial reproduced; the permission-only rebuild with the configured cache exited normally in 197 ms after restoration. | These timings measure local runs, not a shutdown deadline. The negative cases needed explicit process termination after recording the failure; that cleanup was not counted as a passing close test. Native Windows/macOS behavior was not measured. The final production executable was also started with isolated application state and an active peer. One native close request ended it with status zero in 43 ms; its previously occupied QUIC listener port could then be rebound. ## Native close verification on Linux Use X11 for this probe, including XWayland on a Wayland desktop. From the workspace root, run: ```sh GDK_BACKEND=x11 just run-local crates/lanspread-peer-cli/fixtures/fixture-alpha ``` Wait for the launcher to load and publish `Local peer ready`. In another terminal, compile the close-request helper and identify the exact test window and PID: ```sh cc -Wall -Wextra -Werror tools/send_window_close.c -lX11 -o /tmp/lanspread-send-close xdotool search --onlyvisible --name 'softlan-launcher' xprop -id WINDOW_ID WM_NAME _NET_WM_PID WM_PROTOCOLS /tmp/lanspread-send-close WINDOW_ID EXPECTED_PID ``` Substitute the observed numeric IDs. The helper verifies the PID and advertised protocol, then sends one `WM_PROTOCOLS` / `WM_DELETE_WINDOW` client message directly to the application. This reaches GTK/Tao's close-request handler even when the compositor does not implement `_NET_CLOSE_WINDOW` (which `xdotool windowquit` uses). `SIGTERM`, `SIGKILL`, and `xdotool windowclose` are not substitutes: they do not exercise this frontend close boundary. Success requires the native window to disappear and the launcher process to exit normally after that one request. A permission rejection, a process that remains alive, a second click, or forced process termination is a failure. Use a PID handle or wait on the launched process to measure termination, not just the peer-count log. For companion windows, verify each closes while the main window and peer stay alive, then close the main window and check process exit.