fix(launcher): authorize window destruction after close drains

A native WM_DELETE_WINDOW reproduction completed the frontend drain and then
failed with "window.destroy not allowed". All three windows lacked the destroy
permission used by Tauri's onCloseRequested wrapper. Removing the listener had
previously let a second click bypass that denied IPC; retaining the listener
made every click fail. The frontend was not stuck waiting on its own listener.

Grant window destruction to the three configured application windows. Keep the
existing frontend drains, early peer cancellation, and final runtime/task joins.
Test the application's actual generated RuntimeAuthority, including expanded
plugin defaults, instead of assuming a mocked successful destroy proves access.

Explicitly include generated capabilities and ACL manifests in the context
constructor's rustc dependencies. The configured compiler cache reused the old
library after a permission-only edit because Tauri's macro reads these files
without recording compiler dependencies. A remove/restore native probe now
rebuilds with the correct permission in both directions.

Document the complete ownership chain and failure evidence. Add a PID-checked
X11 WM_DELETE_WINDOW helper for repeatable close-button probes without killing
the process or bypassing the frontend boundary.

Test Plan:
- Native baseline: destroy denied and process remained alive after one request.
- Resolved-ACL regression failed before the permission fix and passes afterward.
- Native main/companion close probes: one request per window; normal process exit.
- Permission-only rebuild with compiler cache: normal exit in 197 ms.
- Production executable: normal exit in 43 ms; QUIC port released and rebound.
- just test: 797 passed on unchanged rerun after one initial subprocess fixture
  startup-marker timeout, before that test exercised cancellation.
- just frontend-test: 99 passed.
- just fmt, just clippy, just build, and git diff --cached --check: passed.
- Native helper compiled with -Wall -Wextra -Werror.
- Native probes ran on Linux X11/XWayland; Windows/macOS were not measured.
This commit is contained in:
2026-09-12 21:36:06 +02:00
parent 25bf56ff13
commit 9171560ad4
7 changed files with 261 additions and 5 deletions
+3
View File
@@ -91,6 +91,9 @@ just test
just frontend-test
```
See [launcher shutdown ownership](crates/lanspread-tauri-deno-ts/SHUTDOWN.md)
for the cancellation/drain sequence and a native close-button regression probe.
`just build` always uses the production resource map and production profile; use
`just build-fixture` when a fixture build is intended. Tauri's build script
rejects fixture and local resources unless their explicit development opt-in is
+133
View File
@@ -0,0 +1,133 @@
# 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.
@@ -7,6 +7,7 @@
],
"permissions": [
"core:default",
"core:window:allow-destroy",
"core:window:allow-set-focus",
"core:webview:allow-create-webview-window",
"dialog:default",
@@ -6,6 +6,7 @@
"main-logs"
],
"permissions": [
"core:default"
"core:default",
"core:window:allow-destroy"
]
}
@@ -6,6 +6,7 @@
"unpack-logs"
],
"permissions": [
"core:default"
"core:default",
"core:window:allow-destroy"
]
}
@@ -149,8 +149,8 @@ impl AppTaskScope {
///
/// Tauri owns the invoke futures, so they cannot be spawned in our task scope.
/// A tracker token instead makes each admitted future part of the application
/// shutdown boundary: shutdown closes admission, waits for every token to be
/// dropped, and only then takes ownership of the peer runtime to stop it. The
/// shutdown boundary: shutdown closes admission, requests peer cancellation,
/// waits for every token to be dropped, and then takes the runtime to join it. The
/// separate serial lock orders game-directory startup, sharing transitions,
/// and sharing-gated Call-to-Play publication through their acknowledgements
/// and UI commits without participating in shutdown locking.
@@ -4727,6 +4727,15 @@ struct ProtocolMismatchSnapshot {
mismatch: Option<ProtocolMismatch>,
}
fn application_context() -> tauri::Context<tauri::Wry> {
// generate_context! reads these files through the proc macro's filesystem
// API. Explicit includes also put them in rustc's dependency information,
// so compiler caches cannot reuse an application with an older ACL.
const _: &str = include_str!(concat!(env!("OUT_DIR"), "/capabilities.json"));
const _: &str = include_str!(concat!(env!("OUT_DIR"), "/acl-manifests.json"));
tauri::generate_context!()
}
#[allow(clippy::missing_panics_doc)]
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
@@ -4821,7 +4830,7 @@ pub fn run() {
spawn_peer_event_loop(app.handle().clone(), rx_peer_event, rx_ui_state);
Ok(())
})
.build(tauri::generate_context!())
.build(application_context())
.expect("error while building tauri application")
.run(|app_handle, event| {
if matches!(event, tauri::RunEvent::Exit) {
@@ -4904,6 +4913,40 @@ where
mod tests {
use super::*;
#[test]
fn every_frontend_close_boundary_can_destroy_its_window() {
// Test the application's resolved ACL, including Tauri's default
// permission sets. Mocked frontend invokes cannot detect a missing
// permission at the end of an otherwise successful close drain.
let mut context = application_context();
let authority = context.runtime_authority_mut();
for label in ["main", "main-logs", "unpack-logs"] {
for command in [
"plugin:event|listen",
"plugin:event|unlisten",
"plugin:window|destroy",
] {
assert!(
authority
.resolve_access(command, label, label, &tauri::ipc::Origin::Local)
.is_some(),
"{label} must be allowed to call {command} for its frontend close boundary",
);
}
}
assert!(
authority
.resolve_access(
"plugin:window|destroy",
"untrusted",
"untrusted",
&tauri::ipc::Origin::Local
)
.is_none(),
"window destruction must remain limited to the configured application windows",
);
}
fn download_attempt(id: &str, attempt_id: u64) -> DownloadAttemptKey {
serde_json::from_value(serde_json::json!({
"id": id,
+74
View File
@@ -0,0 +1,74 @@
/* Send the close-button protocol to one PID-checked X11 window. */
#include <X11/Xatom.h>
#include <X11/Xlib.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
static unsigned long parse_id(const char *text) {
char *end;
errno = 0;
unsigned long value = strtoul(text, &end, 0);
if (errno || *end || end == text || !value || *text == '-') {
fprintf(stderr, "Invalid positive window ID or PID: %s\n", text);
exit(2);
}
return value;
}
int main(int argc, char **argv) {
if (argc != 3) {
fprintf(stderr, "Usage: %s WINDOW_ID EXPECTED_PID\n", argv[0]);
return 2;
}
Window window = parse_id(argv[1]);
unsigned long expected_pid = parse_id(argv[2]);
Display *display = XOpenDisplay(NULL);
if (!display) {
fprintf(stderr, "Cannot open DISPLAY\n");
return 1;
}
Atom type;
int format;
unsigned long count, remaining;
unsigned char *data = NULL;
int result = XGetWindowProperty(display, window,
XInternAtom(display, "_NET_WM_PID", False), 0, 1, False, XA_CARDINAL,
&type, &format, &count, &remaining, &data);
int matches = result == Success && type == XA_CARDINAL && format == 32 &&
count == 1 && data && *(unsigned long *)data == expected_pid;
if (data) XFree(data);
if (!matches) {
fprintf(stderr, "Window does not belong to expected PID %lu\n", expected_pid);
XCloseDisplay(display);
return 1;
}
Atom protocol = XInternAtom(display, "WM_DELETE_WINDOW", False);
Atom *protocols = NULL;
int protocol_count = 0, supports_close = 0;
if (XGetWMProtocols(display, window, &protocols, &protocol_count)) {
for (int i = 0; i < protocol_count; i++)
supports_close |= protocols[i] == protocol;
XFree(protocols);
}
if (!supports_close) {
fprintf(stderr, "Window does not advertise WM_DELETE_WINDOW\n");
XCloseDisplay(display);
return 1;
}
XEvent event = {0};
event.xclient.type = ClientMessage;
event.xclient.window = window;
event.xclient.message_type = XInternAtom(display, "WM_PROTOCOLS", False);
event.xclient.format = 32;
event.xclient.data.l[0] = protocol;
event.xclient.data.l[1] = CurrentTime;
int sent = XSendEvent(display, window, False, NoEventMask, &event);
XSync(display, False);
XCloseDisplay(display);
printf("WM_DELETE_WINDOW sent=%d window=%lu pid=%lu\n", sent, window, expected_pid);
return sent ? 0 : 1;
}