fix(qt): never let the shutdown path outlive the UI, and log the tray hop

An owner smoke reported no way to close the app but killing the process.
That was not reproduced here — driven through D-Bus exactly as a panel does
it, the tray's Quit exits in 0.4s and the app-menu Close with close-to-tray
OFF exits in 0.5s, both code 0 — so this is insurance and instrumentation,
not a claimed cure.

The insurance: once the event loop is gone the process has no UI, so nothing
on that path may block without a bound. The cast shutdown ran unbounded and
takes a tokio Mutex the cast poll task also holds, aborted only AFTER the
lock is acquired. Two seconds is the cap; a cast device that keeps playing
that much longer beats an app that cannot be closed.

The instrumentation: tray_qt logs when it SENDS, and nothing logged on the
QML side, so "the tray does nothing" could not be told apart from "the
signal arrived and QML ignored it". The three handlers now say which. That
ambiguity cost a diagnosis today; one line each ends it.

cc
This commit is contained in:
vicrodh committed 2026-08-04 08:42:03 -06:00
1 parent 088387f2c1
commit a64c20b09f
2 files changed
+44 -3

No files matched your search

+16 -2
View File
@@ -551,10 +551,22 @@ ApplicationWindow {
// (§2.3), so every tray click hops ksni thread -> tray_qt verb ->
// tray_bridge::ui() -> qsignal -> here, and this block is where it finally
// reaches one.
// Each handler logs ONE line, and that is not debug litter: this hop is the
// only part of the tray that Rust cannot see. `tray_qt` logs when it SENDS
// (e.g. "[tray] quit requested"), so without a line on this side a report of
// "the tray does nothing" cannot distinguish "the click never left ksni"
// from "the signal arrived and QML did nothing with it" — which is exactly
// the ambiguity that cost a diagnosis on 2026-08-04.
Connections {
target: QbzTray
function onWindowShowRequested() { window.showFromTray() }
function onWindowHideRequested() { window.hideToTray() }
function onWindowShowRequested() {
console.log("[qml] tray -> showFromTray")
window.showFromTray()
}
function onWindowHideRequested() {
console.log("[qml] tray -> hideToTray")
window.hideToTray()
}
// The tray's left-click degenerates to present() while the mini is open
// (§5.8.2, §13-D21): raise the MINI, and do NOT bring the main window up
// beside it. No setWindowShown — the mini is not the main window, and
@@ -568,7 +580,9 @@ ApplicationWindow {
}
}
function onQuitRequested() {
console.log("[qml] tray -> quit: persisting geometry")
window.persistWindowGeometryOnExit()
console.log("[qml] tray -> quit: calling Qt.quit()")
Qt.quit()
}
}
+28 -1
View File
@@ -2365,9 +2365,36 @@ fn main() {
if let Some(app) = app.as_mut() {
app.exec();
// The event loop is gone: from here on the process has no UI and the
// user cannot do anything but wait, so NOTHING on this path may block
// without a bound.
log::info!("[qbz-qt] event loop exited; shutting down");
// Same reason as logout: leaving the app must stop the renderer.
//
// BOUNDED. This is a `block_on` on the main thread after the UI is
// already gone, and it awaits a tokio `Mutex` that the cast poll task
// also takes (`cast_qt.rs:1111`, aborted only AFTER the lock is
// acquired). If anything holds that lock, the process survives with no
// window, no tray response and no way out but `kill -9` — which is
// exactly what an owner hit on 2026-08-04. A cast device that keeps
// playing for two seconds longer is a far smaller failure than an app
// that cannot be closed, so the timeout wins the tie.
if let Some(rt) = TOKIO.get() {
rt.block_on(async { cast_qt::service().shutdown().await });
let stopped = rt.block_on(async {
tokio::time::timeout(
std::time::Duration::from_secs(2),
cast_qt::service().shutdown(),
)
.await
.is_ok()
});
if !stopped {
log::warn!(
"[qbz-qt] cast shutdown did not finish within 2s; exiting anyway \
(a cast device may keep playing until it times out)"
);
}
}
log::info!("[qbz-qt] shutdown complete");
}
}