fix(qt): Qt.quit() was vetoed by our own windows, so Quit never quit
The owner reported it three times and their second wording was the whole
diagnosis: "Quit QBZ debe cerrar la aplicación, NO SOLO LA VENTANA."
ROOT CAUSE. `Qt.quit()` in Qt 6 is a REQUEST, not an order. It posts
QEvent::Quit, and QGuiApplication answers it by calling close() on every
top-level window that has a platform handle — hidden ones included. If any
window refuses its close event, the quit is silently cancelled. Two of ours
refuse:
1. Main.qml's closeOrHide refuses whenever trayLive && closeToTray, and
hides the window on the way out. That IS "cierra la ventana, no la app".
2. MiniWindow's onClosing refuses unconditionally, because for the mini a
close means "leave the mini" — so once the mini had ever been opened,
quit was blocked regardless of any setting.
It also explains "Close to tray does nothing": with the setting ON, the X
hid (correct) and Quit hid too (the bug), so the two were indistinguishable
and the toggle looked inert. Nothing was ever wrong with that plumbing.
FIX. Both quit sites call Qt.exit(0), which exits the event loops directly —
no QEvent::Quit, no window to veto it, and no need to touch the miniplayer's
onClosing, whose refusal is correct for what it means. A one-shot 5 s
watchdog thread, armed only at the three quit sites, flushes the log and
_exit(0)s if anything downstream ever wedges; it cannot fire in a live
session. main() now names each teardown step, so a future hang says which
statement it hung on instead of going quiet.
closeOrHide reports its decision through the bridge — tray_live,
close_to_tray and the arm taken — because a report of "the tray does
nothing" was not previously separable from "the signal arrived and QML
ignored it".
MEASURED, with waitpid on a child rather than /proc (a zombie reads as alive
and that produced a false diagnosis earlier in this session). Old binary:
alive ≥25 s with the window visible, hidden, and with the mini open. Fixed:
exit 0 in 0.35-0.68 s across eleven driven scenarios — the tray Quit in all
three window states, the drawn X and the app-menu Close with the setting
both ON and OFF, the mini's own close button, and the MPRIS Quit method.
One arm remains static-only: a real WM close event with close-to-tray OFF.
VNC delivers no such event, so the owner's first titlebar X is its first
real execution.
cc
This commit is contained in:
6 files changed
+171
-7
No files matched your search
Generated
+1
@@ -6622,6 +6622,7 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
"image",
|
"image",
|
||||||
"ksni",
|
"ksni",
|
||||||
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"open",
|
"open",
|
||||||
"qbz-app",
|
"qbz-app",
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ cxx-qt-lib = { version = "0.7", features = ["qt_full"] }
|
|||||||
|
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
|
# Hard-exit watchdog (main.rs arm_hard_exit_watchdog): `_exit` terminates
|
||||||
|
# without running the atexit chain — a hang inside Qt's atexit destructors is
|
||||||
|
# one of the wedges the watchdog exists to cut short. Already in the lock as
|
||||||
|
# a transitive dep of half the tree; this only makes it direct.
|
||||||
|
libc = "0.2"
|
||||||
rand = { workspace = true }
|
rand = { workspace = true }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
|||||||
@@ -454,7 +454,8 @@ ApplicationWindow {
|
|||||||
// sites, both inside QWindow::event and both gated on the accepted flag, so
|
// sites, both inside QWindow::event and both gated on the accepted flag, so
|
||||||
// hiding never arms Qt's quit check but an ACCEPTED close does.
|
// hiding never arms Qt's quit check but an ACCEPTED close does.
|
||||||
//
|
//
|
||||||
// `onAboutToQuit` is still not a duplicate of it: `Qt.quit()` never raises
|
// `onAboutToQuit` is still not a duplicate of it: neither `Qt.exit(0)`
|
||||||
|
// (what every quit arm calls since 2026-08-04) nor `Qt.quit()` raises
|
||||||
// `closing` (§15 trap 26), so the quit arm of `closeOrHide`, the tray's
|
// `closing` (§15 trap 26), so the quit arm of `closeOrHide`, the tray's
|
||||||
// `quit_requested` handler and any teardown that skips QML reach the
|
// `quit_requested` handler and any teardown that skips QML reach the
|
||||||
// geometry flush only through here. Where both do run the second write is
|
// geometry flush only through here. Where both do run the second write is
|
||||||
@@ -537,14 +538,35 @@ ApplicationWindow {
|
|||||||
// `closeEvent` is the QQuickCloseEvent on the one path that carries one and
|
// `closeEvent` is the QQuickCloseEvent on the one path that carries one and
|
||||||
// null on the other four.
|
// null on the other four.
|
||||||
function closeOrHide(closeEvent) {
|
function closeOrHide(closeEvent) {
|
||||||
if (QbzTray.trayLive && QbzTray.closeToTray) {
|
var hide = QbzTray.trayLive && QbzTray.closeToTray
|
||||||
|
// The evidence line (2026-08-04): logged RUST-side so it lands in
|
||||||
|
// qbz.log — console.log only reaches stderr, which owner reports
|
||||||
|
// never carry. It prints both operands and the arm taken, which is
|
||||||
|
// the whole diagnosis of any future "Close to tray does nothing".
|
||||||
|
QbzTray.closeDecision(hide)
|
||||||
|
if (hide) {
|
||||||
if (closeEvent)
|
if (closeEvent)
|
||||||
closeEvent.accepted = false
|
closeEvent.accepted = false
|
||||||
window.hideToTray()
|
window.hideToTray()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// QUIT — via Qt.exit(0), NEVER Qt.quit(). Qt.quit() delivers
|
||||||
|
// QEvent::Quit, and QGuiApplication's handler first closes every
|
||||||
|
// top-level window, silently CANCELING the whole quit if any window
|
||||||
|
// refuses its close — this very function's hide arm refuses whenever
|
||||||
|
// the tray is live with close-to-tray on, and MiniWindow.qml's
|
||||||
|
// onClosing refuses unconditionally (its close means "exit the
|
||||||
|
// mini"). That veto is how "Quit QBZ" hid the window and left the
|
||||||
|
// process running (owner-reported three times; reproduced under VNC
|
||||||
|
// 2026-08-04: quit requested + Qt.quit() both logged, event loop
|
||||||
|
// still alive 25s later). Qt.exit() stops the event loops directly:
|
||||||
|
// no close events, no veto. Geometry is flushed explicitly below,
|
||||||
|
// and onAboutToQuit still runs when exec() returns.
|
||||||
|
if (closeEvent)
|
||||||
|
closeEvent.accepted = true
|
||||||
|
QbzTray.armQuitWatchdog()
|
||||||
window.persistWindowGeometryOnExit()
|
window.persistWindowGeometryOnExit()
|
||||||
Qt.quit()
|
Qt.exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The tray's four signals (src/tray_bridge.rs:87-100). Rust owns no window
|
// The tray's four signals (src/tray_bridge.rs:87-100). Rust owns no window
|
||||||
@@ -582,8 +604,15 @@ ApplicationWindow {
|
|||||||
function onQuitRequested() {
|
function onQuitRequested() {
|
||||||
console.log("[qml] tray -> quit: persisting geometry")
|
console.log("[qml] tray -> quit: persisting geometry")
|
||||||
window.persistWindowGeometryOnExit()
|
window.persistWindowGeometryOnExit()
|
||||||
console.log("[qml] tray -> quit: calling Qt.quit()")
|
console.log("[qml] tray -> quit: calling Qt.exit(0)")
|
||||||
Qt.quit()
|
// Qt.exit(0), NEVER Qt.quit() — see closeOrHide's quit arm: a
|
||||||
|
// Qt.quit() here is vetoable by any window's onClosing (the
|
||||||
|
// close-to-tray arm, the mini's unconditional refuse), and that
|
||||||
|
// veto is exactly how the tray Quit hid the window and left the
|
||||||
|
// process alive (2026-08-04, three owner reports). The hard-exit
|
||||||
|
// watchdog for this path was armed Rust-side in tray_qt::quit(),
|
||||||
|
// before the signal was even emitted.
|
||||||
|
Qt.exit(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2325,6 +2325,67 @@ fn apply_renderer_preference() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================ Shutdown guarantee ==========================
|
||||||
|
|
||||||
|
/// One-shot latch for [`arm_hard_exit_watchdog`].
|
||||||
|
static HARD_EXIT_ARMED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// Hard-exit watchdog — the guarantee that QUITTING ALWAYS ENDS THE PROCESS.
|
||||||
|
///
|
||||||
|
/// Owner-reported three times (2026-08-04): "Quit QBZ" closed the window and
|
||||||
|
/// left the process alive, with no way out but `kill`. Root cause, reproduced
|
||||||
|
/// under `QT_QPA_PLATFORM=vnc` the same day: in Qt 6, `Qt.quit()` delivers
|
||||||
|
/// `QEvent::Quit`, and `QGuiApplication::event` first tries to CLOSE every
|
||||||
|
/// top-level window — any window refusing its close event silently CANCELS
|
||||||
|
/// the whole quit (qtbase `QGuiApplicationPrivate::tryCloseAllWindows`). Two
|
||||||
|
/// windows here refuse: Main.qml's close-to-tray arm (whenever
|
||||||
|
/// `trayLive && closeToTray`) and MiniWindow.qml's `onClosing`
|
||||||
|
/// (unconditionally — its close means "exit the mini", never "quit the app").
|
||||||
|
/// The log signature of the bug: `[tray] quit requested` and the QML
|
||||||
|
/// handler's `Qt.quit()` both fire, and `event loop exited` never appears.
|
||||||
|
///
|
||||||
|
/// The FIX is that every quit path now calls `Qt.exit(0)`, which exits the
|
||||||
|
/// event loops directly with no window veto. THIS watchdog is the backstop
|
||||||
|
/// behind it: armed at the moment quit is REQUESTED (and again, idempotently,
|
||||||
|
/// when the loop exits), it hard-terminates the process if any later stage —
|
||||||
|
/// signal delivery, QML, the loop itself, the cast shutdown, a Qt/QML/Wayland
|
||||||
|
/// destructor, an atexit handler — wedges.
|
||||||
|
///
|
||||||
|
/// `libc::_exit` on purpose: `std::process::exit` runs libc `exit()`, whose
|
||||||
|
/// atexit chain is where Qt's `Q_GLOBAL_STATIC` destructors live — a hang
|
||||||
|
/// there is one of the wedges being guarded against — and `abort()` would
|
||||||
|
/// SIGABRT/core-dump a healthy shutdown.
|
||||||
|
///
|
||||||
|
/// SAFE BY DESIGN, not by luck: everything the app owes the user is already
|
||||||
|
/// on disk before any quit path arms this — the geometry flushes in the QML
|
||||||
|
/// quit handlers BEFORE `Qt.exit(0)`, prefs/settings persist on change, and
|
||||||
|
/// the stores write at mutation time. Skipping C++ teardown loses nothing the
|
||||||
|
/// user can observe. Do NOT "clean this up" into a graceful join or remove
|
||||||
|
/// the `_exit`: an app the owner cannot close is the bug this ends, and it
|
||||||
|
/// already survived one round of fixes.
|
||||||
|
pub(crate) fn arm_hard_exit_watchdog(source: &'static str) {
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
if HARD_EXIT_ARMED.swap(true, Ordering::SeqCst) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log::info!("[shutdown] hard-exit watchdog armed ({source}); force-exit in 5s if shutdown wedges");
|
||||||
|
let _ = std::thread::Builder::new()
|
||||||
|
.name("qbz-exit-watchdog".into())
|
||||||
|
.spawn(move || {
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(5));
|
||||||
|
log::error!(
|
||||||
|
"[shutdown] quit did not complete within 5s of {source}; forcing _exit(0). \
|
||||||
|
The last '[shutdown]' line above this one names the statement that wedged. \
|
||||||
|
(All user state was persisted before the quit was requested.)"
|
||||||
|
);
|
||||||
|
log::logger().flush();
|
||||||
|
// SAFETY: `_exit` terminates the process immediately, without
|
||||||
|
// running atexit handlers or destructors — that is the point;
|
||||||
|
// see the header above.
|
||||||
|
unsafe { libc::_exit(0) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
qbz_log::install("info");
|
qbz_log::install("info");
|
||||||
// i18n (phase 20): honor the persisted ui_prefs language; "auto"/missing
|
// i18n (phase 20): honor the persisted ui_prefs language; "auto"/missing
|
||||||
@@ -2382,6 +2443,11 @@ fn main() {
|
|||||||
// user cannot do anything but wait, so NOTHING on this path may block
|
// user cannot do anything but wait, so NOTHING on this path may block
|
||||||
// without a bound.
|
// without a bound.
|
||||||
log::info!("[qbz-qt] event loop exited; shutting down");
|
log::info!("[qbz-qt] event loop exited; shutting down");
|
||||||
|
// The backstop for everything below AND for Qt's own teardown (engine
|
||||||
|
// + QGuiApplication destructors, atexit chain): if any of it wedges,
|
||||||
|
// the watchdog `_exit(0)`s the process. Idempotent — the quit paths
|
||||||
|
// arm it earlier, at the moment quit was requested.
|
||||||
|
arm_hard_exit_watchdog("event-loop exit");
|
||||||
// Same reason as logout: leaving the app must stop the renderer.
|
// 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
|
// BOUNDED. This is a `block_on` on the main thread after the UI is
|
||||||
@@ -2410,4 +2476,16 @@ fn main() {
|
|||||||
}
|
}
|
||||||
log::info!("[qbz-qt] shutdown complete");
|
log::info!("[qbz-qt] shutdown complete");
|
||||||
}
|
}
|
||||||
|
// Explicit, INSTRUMENTED drops (2026-08-04 quit incident). Rust would run
|
||||||
|
// these same two destructors implicitly in this same order — the point of
|
||||||
|
// spelling them out is the log lines: if a Qt/QML/Wayland destructor ever
|
||||||
|
// wedges again, the LAST line printed names the exact statement, instead
|
||||||
|
// of a silent ghost process nobody can diagnose. The watchdog armed above
|
||||||
|
// still ends the process either way. KEEP these lines — they are cheap,
|
||||||
|
// and this bug already cost multiple rounds.
|
||||||
|
log::info!("[shutdown] dropping the QML engine");
|
||||||
|
drop(engine);
|
||||||
|
log::info!("[shutdown] engine down; dropping QGuiApplication");
|
||||||
|
drop(app);
|
||||||
|
log::info!("[shutdown] QGuiApplication down; returning from main");
|
||||||
}
|
}
|
||||||
@@ -82,6 +82,21 @@ pub mod qbz_tray {
|
|||||||
#[qinvokable]
|
#[qinvokable]
|
||||||
fn set_window_shown(self: Pin<&mut QbzTray>, shown: bool);
|
fn set_window_shown(self: Pin<&mut QbzTray>, shown: bool);
|
||||||
|
|
||||||
|
/// `closeOrHide`'s evidence line (2026-08-04): logs the two predicate
|
||||||
|
/// operands and the arm taken, RUST-side, so it lands in qbz.log —
|
||||||
|
/// QML's console.log only reaches stderr. One line that settles every
|
||||||
|
/// future "toggling Close to tray changes nothing" report: it shows
|
||||||
|
/// exactly what `trayLive` and `closeToTray` held at close time.
|
||||||
|
#[qinvokable]
|
||||||
|
fn close_decision(self: Pin<&mut QbzTray>, hiding: bool);
|
||||||
|
|
||||||
|
/// Arms the hard-exit watchdog from the QML quit arm — the quit paths
|
||||||
|
/// that do NOT pass through `tray_qt::quit` (window close / app-menu
|
||||||
|
/// Close / kiosk X / mini closeApp, all with close-to-tray off).
|
||||||
|
/// Idempotent; see `main.rs::arm_hard_exit_watchdog`.
|
||||||
|
#[qinvokable]
|
||||||
|
fn arm_quit_watchdog(self: Pin<&mut QbzTray>);
|
||||||
|
|
||||||
/// Show + raise + focus the main window -> `Main.qml`'s
|
/// Show + raise + focus the main window -> `Main.qml`'s
|
||||||
/// `showFromTray()`.
|
/// `showFromTray()`.
|
||||||
#[qsignal]
|
#[qsignal]
|
||||||
@@ -95,7 +110,10 @@ pub mod qbz_tray {
|
|||||||
#[qsignal]
|
#[qsignal]
|
||||||
fn mini_present_requested(self: Pin<&mut QbzTray>);
|
fn mini_present_requested(self: Pin<&mut QbzTray>);
|
||||||
/// Quit the app -> `Main.qml`'s
|
/// Quit the app -> `Main.qml`'s
|
||||||
/// `persistWindowGeometryOnExit(); Qt.quit()`.
|
/// `persistWindowGeometryOnExit(); Qt.exit(0)`. `Qt.exit`, not
|
||||||
|
/// `Qt.quit` — a `Qt.quit()` is vetoable by any window's close
|
||||||
|
/// handler and that veto is the 2026-08-04 "Quit closes the window,
|
||||||
|
/// not the app" bug (see `tray_qt::quit`).
|
||||||
#[qsignal]
|
#[qsignal]
|
||||||
fn quit_requested(self: Pin<&mut QbzTray>);
|
fn quit_requested(self: Pin<&mut QbzTray>);
|
||||||
}
|
}
|
||||||
@@ -168,4 +186,21 @@ impl qbz_tray::QbzTray {
|
|||||||
pub fn set_window_shown(self: Pin<&mut Self>, shown: bool) {
|
pub fn set_window_shown(self: Pin<&mut Self>, shown: bool) {
|
||||||
crate::tray_qt::set_window_shown(shown);
|
crate::tray_qt::set_window_shown(shown);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The close-choreography evidence line — see the declaration above. The
|
||||||
|
/// two operands are read off THIS object, i.e. the exact values QML's
|
||||||
|
/// predicate evaluated, so the line cannot disagree with the decision.
|
||||||
|
pub fn close_decision(self: Pin<&mut Self>, hiding: bool) {
|
||||||
|
log::info!(
|
||||||
|
"[tray] closeOrHide: tray_live={} close_to_tray={} -> {}",
|
||||||
|
self.as_ref().tray_live(),
|
||||||
|
self.as_ref().close_to_tray(),
|
||||||
|
if hiding { "hide to tray" } else { "quit" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// See the declaration above and `main.rs::arm_hard_exit_watchdog`.
|
||||||
|
pub fn arm_quit_watchdog(self: Pin<&mut Self>) {
|
||||||
|
crate::arm_hard_exit_watchdog("QML quit arm");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -469,13 +469,29 @@ pub(crate) fn set_window_shown(shown: bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Quit the whole app from a tray action (any thread) → `Main.qml`'s
|
/// Quit the whole app from a tray action (any thread) → `Main.qml`'s
|
||||||
/// `persistWindowGeometryOnExit(); Qt.quit()`.
|
/// `persistWindowGeometryOnExit(); Qt.exit(0)`.
|
||||||
|
///
|
||||||
|
/// `Qt.exit(0)`, NEVER `Qt.quit()` — the difference IS the 2026-08-04 bug the
|
||||||
|
/// owner reported three times ("Quit QBZ closes the window, not the app"):
|
||||||
|
/// `Qt.quit()` delivers `QEvent::Quit`, whose `QGuiApplication` handler first
|
||||||
|
/// tries to close every top-level window and silently CANCELS the quit when
|
||||||
|
/// any window refuses its close event — which Main.qml's close-to-tray arm
|
||||||
|
/// does whenever `trayLive && closeToTray`, and MiniWindow.qml does
|
||||||
|
/// unconditionally. So with close-to-tray on, "Quit" degenerated into
|
||||||
|
/// hide-to-tray (window gone, process alive — reproduced under
|
||||||
|
/// `QT_QPA_PLATFORM=vnc`, see `arm_hard_exit_watchdog`'s header). `Qt.exit`
|
||||||
|
/// stops the event loops directly; no window gets a vote.
|
||||||
|
///
|
||||||
|
/// The watchdog is armed HERE, before the hop, because this is the moment of
|
||||||
|
/// user intent: if the queued signal is dropped (bridge unbooted, loop
|
||||||
|
/// wedged) or anything downstream hangs, the process still dies within 5s.
|
||||||
///
|
///
|
||||||
/// The reference flushes the session first (`session_persist::save_on_exit()`,
|
/// The reference flushes the session first (`session_persist::save_on_exit()`,
|
||||||
/// `tray/mod.rs:300`); the Qt port has no `session_persist` module, so the
|
/// `tray/mod.rs:300`); the Qt port has no `session_persist` module, so the
|
||||||
/// geometry flush is the whole of what is owed (§5.7).
|
/// geometry flush is the whole of what is owed (§5.7).
|
||||||
pub(crate) fn quit() {
|
pub(crate) fn quit() {
|
||||||
log::info!("[tray] quit requested");
|
log::info!("[tray] quit requested");
|
||||||
|
crate::arm_hard_exit_watchdog("tray quit");
|
||||||
crate::tray_bridge::ui(|mut t| {
|
crate::tray_bridge::ui(|mut t| {
|
||||||
t.as_mut().quit_requested();
|
t.as_mut().quit_requested();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user