Improve connection diagnostics

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-10-06 18:49:44 +08:00
parent e123cdb855
commit 364ba76809
8 changed files with 317 additions and 18 deletions
+7 -2
View File
@@ -25,8 +25,13 @@ owned commands and presentation without becoming a workflow authority.
- `AppQAction.callback` is a deliberate strong reference. The action owner must not outlive a captured receiver, and a
transient/repeated receiver uses the weak named-method facilities required by `Furious/Qt/AGENTS.md`.
- Clipboard text, files, QR images, share links, and plugin results may contain credentials. A character limit is
not redaction: avoid echoing secret-bearing input in failure dialogs as well as logs. Inspect error presentation
independently from parser diagnostics; controlled validation failure must not disclose the rejected payload.
not redaction: never log rejected payloads. Clipboard import-error dialogs retain their existing length-limited
preview by explicit product choice; do not change that preview policy without a request. Inspect other error
presentation independently from parser diagnostics.
Connection-error diagnostics retain only the displayed failure text for explicit copying; copying leaves the dialog
open. Open Logs resolves the current application window and rechecks native validity after dialog completion and
navigation. It opens the existing log page without retrying a stale connection or acquiring another workflow owner.
`DialogBehaviorTest` in `tests/test_ui_behavior.py` and the compiled lifetime fixture verify these actions.
- Screen capture and QR decoding currently run synchronously; batching the resulting imports does not make capture
interruptible. If moved to workers, transfer data through an owned GUI-thread continuation without retaining
transient windows. Each screen-capture action owns a separate native capture handle, including separate tray/page
+74 -10
View File
@@ -24,17 +24,21 @@ from Furious.Controllers.ConnectionController import (
ConnectionState,
)
from Furious.Frozenlib import (
APP,
AppConnectionController,
AppSettings,
AppSystemTray,
)
from Furious.Qt import AppQAction, AppQMessageBox, bootstrapIcon
from Furious.Qt import AppQAction, AppQMessageBox, bootstrapIcon, connectWeakly
from Furious.Qt import gettext as _
from Furious.Widget.ConnectionProgressWidget import ConnectionProgressWidget
from PySide6 import QtCore
from PySide6.QtWidgets import QApplication
__all__ = ['ConnectAction']
from shiboken6 import isValid
__all__ = ['ConnectAction', 'ConnectionErrorMessageBox']
_TRANSLATABLE_CONNECTION_STATES = (
_('Connect'),
@@ -44,6 +48,73 @@ _TRANSLATABLE_CONNECTION_STATES = (
)
class ConnectionErrorMessageBox(AppQMessageBox):
"""Present one captured connection failure with copy and log-navigation actions."""
def __init__(self, error: ConnectionError):
super().__init__(
icon=self.Icon.Critical,
heading=error.title,
text=error.message,
buttons=self.StandardButton.Close,
)
self.setInformativeText(error.details)
self.textLabel.setTextFormat(QtCore.Qt.TextFormat.PlainText)
self.informativeLabel.setTextFormat(QtCore.Qt.TextFormat.PlainText)
self._errorText = '\n\n'.join(
text for text in (error.title, error.message, error.details) if text
)
self.copyErrorButton = self.addButton(
_('Copy Error'), self.ButtonRole.ActionRole, closeOnClick=False
)
self.openLogsButton = self.addButton(_('Open Logs'), self.ButtonRole.ActionRole)
connectWeakly(self.copyErrorButton.clicked, self, '_copyError')
connectWeakly(self.openLogsButton.clicked, self, '_openLogs')
window = getattr(APP(), 'mainWindow', None)
self.openLogsButton.setEnabled(window is not None and isValid(window))
self.setDefaultButton(self.StandardButton.Close)
self.setEscapeButton(self.StandardButton.Close)
@QtCore.Slot(bool)
def _copyError(self, _checked=False):
"""Copy the original failure while keeping its dialog open."""
QApplication.clipboard().setText(self._errorText)
@QtCore.Slot(bool)
def _openLogs(self, _checked=False):
"""Resolve the application window after the error dialog has completed."""
window = getattr(APP(), 'mainWindow', None)
if window is None or not isValid(window):
return
if window.isMinimized():
window.showNormal()
else:
window.show()
if not isValid(window):
return
window.showLogPage()
if not isValid(window):
return
window.raise_()
if isValid(window):
window.activateWindow()
class ConnectAction(AppQAction):
"""Adapt connection state and operations to a tray QAction."""
@@ -130,14 +201,7 @@ class ConnectAction(AppQAction):
if not isinstance(error, ConnectionError):
return
mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Critical)
mbox.setHeading(error.title)
mbox.setText(error.message)
if error.details:
mbox.setInformativeText(error.details)
mbox.open()
ConnectionErrorMessageBox(error).open()
def triggeredCallback(self, checked):
"""Delegate the requested operation to the shared controller."""
+16
View File
@@ -3628,5 +3628,21 @@ TRANSLATION = {
"RU": "IP-адреса в обход интерфейса TUN (через запятую)",
"ZH": "绕过TUN适配器接口的IP地址(以逗号分隔)",
"isReviewed": "True"
},
"Copy Error": {
"source": [
"Furious.Actions.Connection"
],
"RU": "Скопировать ошибку",
"ZH": "复制错误信息",
"isReviewed": "True"
},
"Open Logs": {
"source": [
"Furious.Actions.Connection"
],
"RU": "Открыть журнал",
"ZH": "打开日志",
"isReviewed": "True"
}
}
+4
View File
@@ -36,6 +36,10 @@ behavior, and lifetime primitives; pages and services consume them without creat
an unparented wrapper; `AppQMainWindow.show()` adds its own visible-window retention until accepted close. Do not
solve ambiguity by global retention, indiscriminate delete-on-close, routine `gc.collect()`, or broad
deleted-wrapper suppression.
- Message-box inline actions may use `addButton(..., closeOnClick=False)` to publish the existing click signal while
leaving the dialog open. Ordinary buttons retain their existing result/dismissal behavior. Keep callbacks owned by
the button's native lifetime and capture only required plain context, not the transient box; connection-error copy
and normal close/navigation cases in `tests/test_ui_behavior.py` exercise this contract.
## Signals, threads, and async Qt work
+17 -4
View File
@@ -1514,14 +1514,16 @@ class AppQMessageBox(AppQTransientDialog):
"""Return the currently displayed icon pixmap."""
return self.iconLabel.pixmap()
def addButton(self, button, role=None):
"""Add a standard button or a custom text/role button."""
def addButton(self, button, role=None, *, closeOnClick=True):
"""Add a button, optionally keeping the dialog open for inline actions."""
if isinstance(button, self.StandardButton):
standardButton = button
existing = self._standardButtonMap.get(standardButton)
if existing is not None:
existing.setProperty('messageBoxCloseOnClick', bool(closeOnClick))
return existing
standardRole = {
@@ -1547,11 +1549,14 @@ class AppQMessageBox(AppQTransientDialog):
self._standardButtons |= standardButton
return self._createButton(
createdButton = self._createButton(
self._standardButtonText(standardButton),
standardRole,
standardButton,
)
createdButton.setProperty('messageBoxCloseOnClick', bool(closeOnClick))
return createdButton
if isinstance(button, QAbstractButton):
customButton = button
@@ -1561,12 +1566,17 @@ class AppQMessageBox(AppQTransientDialog):
self._registerButton(customButton, role)
self._rebuildButtonLayout()
customButton.setProperty('messageBoxCloseOnClick', bool(closeOnClick))
return customButton
if role is None:
raise TypeError('a custom button requires a QMessageBox.ButtonRole')
return self._createButton(button, role)
createdButton = self._createButton(button, role)
createdButton.setProperty('messageBoxCloseOnClick', bool(closeOnClick))
return createdButton
@QtCore.Slot(object, bool)
def _handleButtonClicked(self, button, _checked=False):
@@ -1688,6 +1698,9 @@ class AppQMessageBox(AppQTransientDialog):
if not isValid(self):
return
if isValid(button) and button.property('messageBoxCloseOnClick') is False:
return
standardButton = self.standardButton(button)
if standardButton != self.StandardButton.NoButton:
+3 -1
View File
@@ -114,7 +114,7 @@ worker. Choose tests by the changed contract rather than by filename alone.
| [test_main_window_geometry.py](test_main_window_geometry.py) | First-show lifecycle, restored/default/legacy geometry, main/routing window reuse, session-only navigation state, protection of never-shown windows' saved geometry. |
| [test_dialog_geometry.py](test_dialog_geometry.py) | Dialog show/open/exec preparation, sizing/centering, failure cleanup, specialized message-box geometry and transient destruction. |
| [test_layout_matrix.py](test_layout_matrix.py) | Navigation and message-box layouts in fresh processes at scale factors 1, 1.25, 1.5, and 2, under both themes. |
| [test_ui_behavior.py](test_ui_behavior.py) | Translation and editor mappings, settings organization, stable server/routing moves, real QR rendering/decoding, incremental log filtering/tail behavior, connected-routing change notices, message boxes, shared Home/tray state. |
| [test_ui_behavior.py](test_ui_behavior.py) | Translation and editor mappings, settings organization, stable server/routing moves, real QR rendering/decoding, incremental log filtering/tail behavior, connected-routing change notices, message boxes and connection-error copy/log navigation, shared Home/tray state. |
| [test_qt_interactions.py](test_qt_interactions.py) | Real keyboard/mouse/focus and proxy mapping, scoped shortcuts, sorting/selection, stable subscription deletion confirmations, debounced Home search, Tests-menu selection color, shared settings, batched profile mutation and cancellation. |
| [test_qr_export_scalability.py](test_qr_export_scalability.py) | Production capture cap, immediate single export, incremental yielding, failure/cancel/close paths, immutable snapshots, window-owned state destruction. |
| [test_stylesheet_states.py](test_stylesheet_states.py) | Targeted rendering/alpha/geometry assertions for table/list insets, popup corners, clear buttons, focus/disabled states, and stylesheet composition. |
@@ -333,6 +333,8 @@ masks, reopen generations, animations, menus, and view-owned confirmations. It r
JSON diagnostics and asserts captured Qt callback exceptions are absent.
It also checks destruction during action/theme/startup callbacks, clearing borrowed
menus in either destruction order, and bounded sender replacement registrations.
Connection recovery dialogs exercise Copy Error without dismissal and Open Logs
through native destruction, with weak-wrapper and compiled callback-retention checks.
For compiler-sensitive work, compile this fixture separately with Nuitka's
PySide6 plugin, its imported support code, and required data, then repeat the
+71
View File
@@ -25,9 +25,11 @@ from Furious.Backends.Xray.AssetListView import XrayAssetListView
import Furious.Backends.Xray.AssetListView as assetModule
import Furious.Actions.Import as importModule
from Furious.Actions.Routing import RoutingAction
from Furious.Actions.Connection import ConnectionErrorMessageBox
from Furious.Application.TrayIcon import TrayIcon
from Furious.Application.DesktopApplication import DesktopApplication
from Furious.Controllers import ConnectionController, RoutingController
from Furious.Controllers.ConnectionController import ConnectionError
from Furious.Plugins import RoutingOption
from Furious.Plugins import blankProfile, initializePluginRegistry
from Furious.Qt import (
@@ -36,6 +38,7 @@ from Furious.Qt import (
AppQMenuPushButton,
AppQDialog,
AppQMessageBox,
AppQMainWindow,
ThemeTransition,
connectWeakly,
)
@@ -97,6 +100,73 @@ PROTOCOL_PATTERNS = {
CLOSE_METHODS = ('accept', 'close', 'reject')
def runConnectionRecoveryProbe(iterations=100):
"""Check compiled diagnostic-button dispatch and transient destruction."""
app = application()
hadWindow = hasattr(app, 'mainWindow')
previousWindow = getattr(app, 'mainWindow', None)
previousClipboard = app.clipboard().text()
window = AppQMainWindow()
logPageOpens = []
window.showLogPage = lambda: logPageOpens.append(True)
app.mainWindow = window
references = []
destroyed = []
protectedMethods = getattr(
sys.modules.get('PySide6-postLoad', PySide6), '_protected', None
)
protectedBefore = len(protectedMethods) if protectedMethods is not None else None
try:
for _ in range(iterations):
box = ConnectionErrorMessageBox(
ConnectionError('Unable to connect', 'Fixture failure', 'Native detail')
)
references.append(weakref.ref(box))
box.destroyed.connect(lambda *_args: destroyed.append(True))
box.open()
processQtEvents()
box.copyErrorButton.click()
assert box.isVisible()
assert (
app.clipboard().text()
== 'Unable to connect\n\nFixture failure\n\nNative detail'
)
box.openLogsButton.click()
processQtEvents()
assert not isValid(box)
del box
assert len(logPageOpens) == iterations
assert len(destroyed) == iterations
assert all(reference() is None for reference in references)
assert not AppQDialog._openDialogs
growth = (
len(protectedMethods) - protectedBefore
if protectedBefore is not None
else None
)
assert growth in (None, 0), growth
return {
'connectionRecoveryDialogs': iterations,
'protectedMethodGrowth': growth,
}
finally:
app.clipboard().setText(previousClipboard)
if hadWindow:
app.mainWindow = previousWindow
else:
del app.mainWindow
deleteQObject(window)
processQtEvents()
class _ReentrantAction(AppQAction):
"""Make stale activation visible as a forbidden native operation."""
@@ -1191,6 +1261,7 @@ def main():
)
try:
print(json.dumps(runConnectionRecoveryProbe(arguments.iterations)))
print(json.dumps(runReentrantLifetimeProbe(arguments.iterations)))
print(json.dumps(runSingletonIPCProbe(arguments.iterations)))
print(json.dumps(runThreadOwnershipProbe(arguments.iterations)))
+125 -1
View File
@@ -65,7 +65,7 @@ from Furious.Backends.Xray.VmessEditor import (
GuiVMessGroupBoxBasic,
VmessEditor,
)
from Furious.Actions.Connection import ConnectAction
from Furious.Actions.Connection import ConnectAction, ConnectionErrorMessageBox
from Furious.Controllers.ConnectionController import (
ConnectionError,
ConnectionController,
@@ -140,6 +140,8 @@ from PySide6.QtWidgets import (
QWidget,
)
from shiboken6 import isValid, delete as deleteQObject
from tests.support import (
application,
collectAtBoundary,
@@ -3564,6 +3566,128 @@ class DialogBehaviorTest(unittest.TestCase):
messageBox.close()
messageBox.deleteLater()
def testInlineMessageBoxButtonsPublishClicksWithoutChangingDefaultDismissal(self):
for kind in ('text', 'standard', 'widget'):
with self.subTest(kind=kind):
messageBox = AppQMessageBox(buttons=AppQMessageBox.StandardButton.Close)
if kind == 'standard':
button = messageBox.addButton(
AppQMessageBox.StandardButton.Help, closeOnClick=False
)
elif kind == 'widget':
button = messageBox.addButton(
QToolButton(),
AppQMessageBox.ButtonRole.ActionRole,
closeOnClick=False,
)
else:
button = messageBox.addButton(
'Inline action',
AppQMessageBox.ButtonRole.ActionRole,
closeOnClick=False,
)
clicked = QSignalSpy(messageBox.buttonClicked)
finished = QSignalSpy(messageBox.finished)
destroyed = QSignalSpy(messageBox.destroyed)
messageBox.open()
processQtEvents()
button.click()
self.assertEqual(clicked.count(), 1)
self.assertEqual(finished.count(), 0)
self.assertTrue(messageBox.isVisible())
messageBox.button(AppQMessageBox.StandardButton.Close).click()
processQtEvents()
self.assertEqual(finished.count(), 1)
self.assertEqual(destroyed.count(), 1)
def testConnectionErrorCopyKeepsDialogOpenAndNavigationReleasesIt(self):
app = application()
originalClipboard = app.clipboard().text()
window = QWidget()
window.showLogPage = mock.Mock()
error = ConnectionError(
'Unable to connect', 'Failed to start', 'Native detail <tag>'
)
try:
with mock.patch.object(app, 'mainWindow', window, create=True):
for _index in range(30):
messageBox = ConnectionErrorMessageBox(error)
reference = weakref.ref(messageBox)
destroyed = QSignalSpy(messageBox.destroyed)
messageBox.open()
processQtEvents()
messageBox.copyErrorButton.click()
self.assertEqual(
app.clipboard().text(),
'\n\n'.join((error.title, error.message, error.details)),
)
self.assertTrue(messageBox.isVisible())
self.assertEqual(messageBox.informativeText(), error.details)
self.assertEqual(
messageBox.informativeLabel.textFormat(), QtCore.Qt.PlainText
)
messageBox.openLogsButton.click()
processQtEvents()
self.assertEqual(destroyed.count(), 1)
self.assertTrue(window.isVisible())
del destroyed, messageBox
self.assertIsNone(reference())
self.assertEqual(window.showLogPage.call_count, 30)
finally:
app.clipboard().setText(originalClipboard)
window.close()
window.deleteLater()
def testConnectionErrorWithoutWindowStillCopiesAndClosesNormally(self):
app = application()
originalClipboard = app.clipboard().text()
try:
with mock.patch.object(app, 'mainWindow', None, create=True):
messageBox = ConnectionErrorMessageBox(
ConnectionError('Error', 'Failed')
)
messageBox.open()
processQtEvents()
self.assertFalse(messageBox.openLogsButton.isEnabled())
messageBox.copyErrorButton.click()
self.assertEqual(app.clipboard().text(), 'Error\n\nFailed')
self.assertTrue(messageBox.isVisible())
QTest.keyClick(messageBox, QtCore.Qt.Key.Key_Escape)
processQtEvents()
finally:
app.clipboard().setText(originalClipboard)
def testOpeningLogsToleratesWindowDestructionDuringDialogCompletion(self):
app = application()
window = QWidget()
window.showLogPage = mock.Mock()
with mock.patch.object(app, 'mainWindow', window, create=True):
messageBox = ConnectionErrorMessageBox(ConnectionError('Error', 'Failed'))
messageBox.finished.connect(lambda _result: deleteQObject(window))
messageBox.open()
processQtEvents()
with mock.patch('sys.excepthook') as exceptionHook:
messageBox.openLogsButton.click()
processQtEvents()
exceptionHook.assert_not_called()
self.assertFalse(isValid(window))
window.showLogPage.assert_not_called()
def testMessageBoxHeadingSupportsLongContentIconsAndThemes(self):
"""Keep the semantic stack responsive across themes and icon variants."""
app = application()