diff --git a/README.md b/README.md index 548e628..ee0fab4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/crates/lanspread-tauri-deno-ts/SHUTDOWN.md b/crates/lanspread-tauri-deno-ts/SHUTDOWN.md new file mode 100644 index 0000000..17953d1 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/SHUTDOWN.md @@ -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. diff --git a/crates/lanspread-tauri-deno-ts/src-tauri/capabilities/default.json b/crates/lanspread-tauri-deno-ts/src-tauri/capabilities/default.json index a2eb938..d86c67c 100644 --- a/crates/lanspread-tauri-deno-ts/src-tauri/capabilities/default.json +++ b/crates/lanspread-tauri-deno-ts/src-tauri/capabilities/default.json @@ -7,6 +7,7 @@ ], "permissions": [ "core:default", + "core:window:allow-destroy", "core:window:allow-set-focus", "core:webview:allow-create-webview-window", "dialog:default", diff --git a/crates/lanspread-tauri-deno-ts/src-tauri/capabilities/main-logs.json b/crates/lanspread-tauri-deno-ts/src-tauri/capabilities/main-logs.json index 2dc03ee..6b1d6ee 100644 --- a/crates/lanspread-tauri-deno-ts/src-tauri/capabilities/main-logs.json +++ b/crates/lanspread-tauri-deno-ts/src-tauri/capabilities/main-logs.json @@ -6,6 +6,7 @@ "main-logs" ], "permissions": [ - "core:default" + "core:default", + "core:window:allow-destroy" ] } diff --git a/crates/lanspread-tauri-deno-ts/src-tauri/capabilities/unpack-logs.json b/crates/lanspread-tauri-deno-ts/src-tauri/capabilities/unpack-logs.json index 474d22e..8fb391f 100644 --- a/crates/lanspread-tauri-deno-ts/src-tauri/capabilities/unpack-logs.json +++ b/crates/lanspread-tauri-deno-ts/src-tauri/capabilities/unpack-logs.json @@ -6,6 +6,7 @@ "unpack-logs" ], "permissions": [ - "core:default" + "core:default", + "core:window:allow-destroy" ] } diff --git a/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs b/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs index a18768b..c73f6f6 100644 --- a/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs +++ b/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs @@ -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, } +fn application_context() -> tauri::Context { + // 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, diff --git a/tools/send_window_close.c b/tools/send_window_close.c new file mode 100644 index 0000000..5b3cf47 --- /dev/null +++ b/tools/send_window_close.c @@ -0,0 +1,74 @@ +/* Send the close-button protocol to one PID-checked X11 window. */ +#include +#include +#include +#include +#include + +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; +}