Unify asynchronous dialog ownership

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-08-25 14:21:36 +08:00
parent 471fdbbd98
commit adfb5e1151
8 changed files with 27 additions and 59 deletions
@@ -360,9 +360,9 @@ Reusable dialogs follow a different policy. A reusable dialog is normally hidden
`WA_DeleteOnClose` merely to make cleanup uniform. `WA_DeleteOnClose` merely to make cleanup uniform.
Furious implements these policies through `AppQDialog`, `AppQTransientDialog`, and Furious implements these policies through `AppQDialog`, `AppQTransientDialog`, and
`AppQMessageBox`. `AppQMessageBox` currently has an additional registry because its `AppQMessageBox`. Their asynchronous `open()` paths share the `AppQDialog` lifetime
QMessageBox-compatible `open()` path bypasses the base implementation; its cleanup must registry; message-box presentation behavior must delegate ownership to that base path
remain synchronized with the base dialog policy or be deliberately consolidated. rather than introduce a parallel registry.
## 14. Packaged and Compiled Builds Require Extra Caution ## 14. Packaged and Compiled Builds Require Extra Caution
+2 -2
View File
@@ -9,8 +9,8 @@ Use the `manage-qt-pyside6-lifetimes` skill for Qt ownership or lifecycle work.
- Pass source text at construction when a control retains it for retranslation. Preserve focus, keyboard, shortcuts, - Pass source text at construction when a control retains it for retranslation. Preserve focus, keyboard, shortcuts,
accessibility, translated-text growth, high-DPI, responsive layout, and both themes. accessibility, translated-text growth, high-DPI, responsive layout, and both themes.
- `AppQMessageBox.windowTitle` is native metadata. Visible hierarchy is `heading`, `text`, then `informativeText`. - `AppQMessageBox.windowTitle` is native metadata. Visible hierarchy is `heading`, `text`, then `informativeText`.
`AppQMessageBox.open()` bypasses `AppQDialog.open()`, so its separate message-box registry is its actual async owner; `AppQMessageBox.open()` delegates asynchronous ownership to `AppQDialog.open()`; do not add a parallel message-box
keep that registry and the base destroyed cleanup balanced. registry or release a transient box before native destruction completes.
- `AppStyleSheet` is the public style authority. `StyleSheets` contains internal QSS fragments that consume the semantic - `AppStyleSheet` is the public style authority. `StyleSheets` contains internal QSS fragments that consume the semantic
palette; application code does not import fragments directly. palette; application code does not import fragments directly.
+3 -42
View File
@@ -943,12 +943,6 @@ class AppQMessageBox(AppQTransientDialog):
MultipleActionBaseWidth = 520 MultipleActionBaseWidth = 520
MaximumSurfaceWidth = 720 MaximumSurfaceWidth = 720
# AppQDialog already wires a cleanup callback for its asynchronous registry.
# This message-box-specific registry repeats that cleanup so open() can bypass
# AppQDialog.open() while preserving QMessageBox-compatible show behavior.
# The duplicate cleanup is harmless and idempotent, but future changes must
# keep both registries synchronized or consolidate them deliberately.
_openMessageBoxes = {}
_standardButtonOrder = ( _standardButtonOrder = (
StandardButton.Ok, StandardButton.Ok,
StandardButton.Save, StandardButton.Save,
@@ -970,19 +964,6 @@ class AppQMessageBox(AppQTransientDialog):
StandardButton.RestoreDefaults, StandardButton.RestoreDefaults,
) )
@staticmethod
def _releaseOpenMessageBox(key, *_args):
"""Release an asynchronously opened message box after destruction."""
AppQMessageBox._openMessageBoxes.pop(key, None)
@staticmethod
def _scheduleOpenMessageBoxRelease(key, *_args):
"""Release an async message box after destroyed-signal dispatch."""
QtCore.QTimer.singleShot(
0,
functools.partial(AppQMessageBox._releaseOpenMessageBox, key),
)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""Initialize the message box while preserving QMessageBox arguments.""" """Initialize the message box while preserving QMessageBox arguments."""
windowTitle = kwargs.pop('windowTitle', None) windowTitle = kwargs.pop('windowTitle', None)
@@ -1011,7 +992,6 @@ class AppQMessageBox(AppQTransientDialog):
super().__init__(parent=parent, **kwargs) super().__init__(parent=parent, **kwargs)
self._lifetimeKey = object()
self._windowMask = None self._windowMask = None
self._icon = self.Icon.NoIcon self._icon = self.Icon.NoIcon
self._heading = '' self._heading = ''
@@ -1026,15 +1006,6 @@ class AppQMessageBox(AppQTransientDialog):
self._clickedButton = None self._clickedButton = None
self._handlingButton = False self._handlingButton = False
scheduleRelease = functools.partial(
AppQMessageBox._scheduleOpenMessageBoxRelease,
self._lifetimeKey,
)
# Message boxes are delete-on-close. Their asynchronous owner releases
# them only after native destruction and its signal dispatch complete.
self.destroyed.connect(scheduleRelease)
self.setObjectName('AppMessageBox') self.setObjectName('AppMessageBox')
windowFlags = ( windowFlags = (
@@ -1821,20 +1792,10 @@ class AppQMessageBox(AppQTransientDialog):
return QDialog.exec(self) return QDialog.exec(self)
def open(self): def open(self):
"""Open and retain the message box until it finishes or is destroyed.""" """Prepare and open using the shared transient-dialog lifetime owner."""
key = self._lifetimeKey self._prepareForPresentation()
AppQMessageBox._openMessageBoxes[key] = self
try: return super().open()
self._prepareForPresentation()
return QDialog.open(self)
except Exception:
# Any non-exit exceptions
AppQMessageBox._releaseOpenMessageBox(key)
raise
def done(self, result): def done(self, result):
"""Release the dimming mask before completing the transient dialog.""" """Release the dimming mask before completing the transient dialog."""
+11 -4
View File
@@ -123,11 +123,10 @@ class DialogGeometryTest(unittest.TestCase):
application() application()
def tearDown(self): def tearDown(self):
"""Drain deferred deletion and require both async registries to settle.""" """Drain deferred deletion and require async ownership to settle."""
collectAtBoundary() collectAtBoundary()
self.assertEqual(AppQDialog._openDialogs, {}) self.assertEqual(AppQDialog._openDialogs, {})
self.assertEqual(AppQMessageBox._openMessageBoxes, {})
def dispose(self, dialog): def dispose(self, dialog):
"""Destroy a reusable fixture after its assertion scope.""" """Destroy a reusable fixture after its assertion scope."""
@@ -309,17 +308,25 @@ class DialogGeometryTest(unittest.TestCase):
buttons=AppQMessageBox.StandardButton.Ok, buttons=AppQMessageBox.StandardButton.Ok,
) )
key = openBox._lifetimeKey key = openBox._lifetimeKey
heldAtFinished = []
openBox.finished.connect(
lambda _result: heldAtFinished.append(key in AppQDialog._openDialogs)
)
openBox.open() openBox.open()
processQtEvents() processQtEvents()
self.assertEqual(openEvents, [True]) self.assertEqual(openEvents, [True])
self.assertIn(key, AppQMessageBox._openMessageBoxes) self.assertIn(key, AppQDialog._openDialogs)
openBox.accept() openBox.accept()
self.assertEqual(heldAtFinished, [True])
self.assertIn(key, AppQDialog._openDialogs)
collectAtBoundary() collectAtBoundary()
self.assertNotIn(key, AppQMessageBox._openMessageBoxes) self.assertNotIn(key, AppQDialog._openDialogs)
execEvents = [] execEvents = []
execBox = CountingMessageBox( execBox = CountingMessageBox(
+2 -2
View File
@@ -28,7 +28,7 @@ class IsolatedDisplayMatrixTest(unittest.TestCase):
"""Keep navigation and transient dialogs stable across supported scales.""" """Keep navigation and transient dialogs stable across supported scales."""
Script = r""" Script = r"""
from Furious.Qt import AppQMessageBox, AppStyleSheet from Furious.Qt import AppQDialog, AppQMessageBox, AppStyleSheet
from Furious.Widget import NavigationView from Furious.Widget import NavigationView
from PySide6 import QtCore from PySide6 import QtCore
@@ -96,7 +96,7 @@ navigation.close()
navigation.deleteLater() navigation.deleteLater()
collectAtBoundary() collectAtBoundary()
assert not AppQMessageBox._openMessageBoxes assert not AppQDialog._openDialogs
""" """
def testScaleAndThemeMatrix(self): def testScaleAndThemeMatrix(self):
-1
View File
@@ -130,7 +130,6 @@ class QtLifetimeTest(unittest.TestCase):
collectAtBoundary() collectAtBoundary()
self.assertEqual(AppQDialog._openDialogs, {}) self.assertEqual(AppQDialog._openDialogs, {})
self.assertEqual(AppQMessageBox._openMessageBoxes, {})
def assertAllDestroyed(self, references, destroyed, expected): def assertAllDestroyed(self, references, destroyed, expected):
"""Assert weak wrappers and native destroyed signals agree.""" """Assert weak wrappers and native destroyed signals agree."""
+3 -2
View File
@@ -71,6 +71,7 @@ from Furious.Plugins.API import RoutingOption
from Furious.Qt import ( from Furious.Qt import (
AppHue, AppHue,
AppQComboBox, AppQComboBox,
AppQDialog,
AppQMessageBox, AppQMessageBox,
AppQSwitch, AppQSwitch,
AppStyleSheet, AppStyleSheet,
@@ -1631,7 +1632,7 @@ class DialogBehaviorTest(unittest.TestCase):
dialog.deleteRule() dialog.deleteRule()
dialog.editRule() dialog.editRule()
self.assertEqual(AppQMessageBox._openMessageBoxes, {}) self.assertEqual(AppQDialog._openDialogs, {})
self.assertEqual(dialog.routing['rules'], []) self.assertEqual(dialog.routing['rules'], [])
dialog.closeWindowButton.click() dialog.closeWindowButton.click()
@@ -1911,7 +1912,7 @@ class DialogBehaviorTest(unittest.TestCase):
finished, finished,
[int(AppQMessageBox.StandardButton.Cancel)], [int(AppQMessageBox.StandardButton.Cancel)],
) )
self.assertEqual(AppQMessageBox._openMessageBoxes, {}) self.assertEqual(AppQDialog._openDialogs, {})
class SharedConnectionPresentationTest(unittest.TestCase): class SharedConnectionPresentationTest(unittest.TestCase):
+3 -3
View File
@@ -23,7 +23,7 @@ from Furious.Backends.ExternalCore import ConfigExternalCore, ExternalCoreProces
from Furious.Interface import ApplicationRunner from Furious.Interface import ApplicationRunner
from Furious.Models import LogCategory from Furious.Models import LogCategory
from Furious.Plugins import FuriousPlugin, PluginMetadata, PluginRegistry from Furious.Plugins import FuriousPlugin, PluginMetadata, PluginRegistry
from Furious.Qt import AppQMessageBox, AppQTransientDialog from Furious.Qt import AppQDialog, AppQMessageBox, AppQTransientDialog
from Furious.Utility import AppMainProcess from Furious.Utility import AppMainProcess
from Furious.Service import APPLICATION_LOG_CATEGORY, LogManager, MetricsHistory from Furious.Service import APPLICATION_LOG_CATEGORY, LogManager, MetricsHistory
from Furious.Widget import NavigationView from Furious.Widget import NavigationView
@@ -329,7 +329,7 @@ class VeryHeavyContractTest(unittest.TestCase):
self.assertEqual(resourceSnapshot()['threads'], baseline['threads']) self.assertEqual(resourceSnapshot()['threads'], baseline['threads'])
def testFiveHundredMessageBoxesLeaveNoRegistryEntries(self): def testFiveHundredMessageBoxesLeaveNoRegistryEntries(self):
"""Exercise the specialized asynchronous registry at release scale.""" """Exercise shared asynchronous dialog ownership at release scale."""
references = [] references = []
for index in range(500): for index in range(500):
@@ -350,7 +350,7 @@ class VeryHeavyContractTest(unittest.TestCase):
collectAtBoundary() collectAtBoundary()
self.assertTrue(all(reference() is None for reference in references)) self.assertTrue(all(reference() is None for reference in references))
self.assertFalse(AppQMessageBox._openMessageBoxes) self.assertFalse(AppQDialog._openDialogs)
if __name__ == '__main__': if __name__ == '__main__':