Scale QR export without a progress dialog

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-08-31 13:31:15 +08:00
parent fd619845a5
commit 5bbd00eda7
7 changed files with 1124 additions and 43 deletions
+1 -3
View File
@@ -2060,10 +2060,8 @@ class ServerTableView(
return
window = self.qrCodeWindowFactory()
window.initTabByIndex(indexes)
if window.tabCount() > 0:
window.show()
return window.startExportByIndex(indexes)
def exportSelectedItemJSON(self):
"""Export selected item JSON."""
+224 -35
View File
@@ -20,9 +20,14 @@
from __future__ import annotations
from Furious.Frozenlib import APPLICATION_NAME
from Furious.Models import ServerProfile
from Furious.Plugins import exportConfiguration
from Furious.Repository import Storage
from Furious.Qt import AppQMainWindow, AppQTabWidget, connectWeakly
from Furious.Qt import (
AppQMainWindow,
AppQTabWidget,
connectWeakly,
)
from Furious.Qt import gettext as _
from PySide6 import QtCore
@@ -32,10 +37,19 @@ from PySide6.QtWidgets import QLabel, QSizePolicy, QVBoxLayout, QWidget
import segno
import logging
__all__ = ['QRCodeWindow']
from collections.abc import Iterable, Sequence
from dataclasses import dataclass
__all__ = [
'MAXIMUM_QR_EXPORT_PROFILES',
'QRCodeExportItem',
'QRCodeWindow',
'captureQRCodeExportItems',
]
_QR_ERROR_CORRECTION = 'H'
_QR_PAGE_MARGIN = 28
MAXIMUM_QR_EXPORT_PROFILES = 50
logger = logging.getLogger(__name__)
@@ -61,6 +75,62 @@ def createQRCodeImage(data: str) -> QImage:
return image
@dataclass(frozen=True)
class QRCodeExportItem:
"""Capture one profile exactly as selected for later QR export."""
position: int
remark: str
profile: ServerProfile
def captureQRCodeExportItems(
indexes: Iterable[int],
profiles: Sequence[ServerProfile] | None = None,
) -> tuple[QRCodeExportItem, ...]:
"""Snapshot at most the first eligible selected profiles in caller order."""
sourceProfiles = Storage.UserServers() if profiles is None else profiles
items = []
for rawIndex in indexes:
if len(items) >= MAXIMUM_QR_EXPORT_PROFILES:
break
try:
index = int(rawIndex)
except (TypeError, ValueError):
continue
if index < 0 or index >= len(sourceProfiles):
continue
profile = sourceProfiles[index]
try:
snapshot = profile.deepcopy()
except Exception as ex:
# Any non-exit exceptions
# A malformed plugin profile must not abort the remaining export.
logger.warning(
'unable to snapshot profile %d for QR presentation (%s)',
index + 1,
type(ex).__name__,
)
continue
items.append(
QRCodeExportItem(
position=index + 1,
remark=str(profile.itemRemark),
profile=snapshot,
)
)
return tuple(items)
class _QRCodePage(QWidget):
"""Own and responsively present one logical QR image inside a tab page."""
@@ -163,7 +233,7 @@ class _QRCodePage(QWidget):
class QRCodeWindow(AppQMainWindow):
"""Present exported configurations in independent QR-code tabs."""
"""Incrementally present exported configurations in QR-code tabs."""
DEFAULT_WINDOW_SIZE = QtCore.QSize(720, 680)
MINIMUM_WINDOW_SIZE = QtCore.QSize(520, 520)
@@ -176,6 +246,20 @@ class QRCodeWindow(AppQMainWindow):
self.setMinimumSize(self.MINIMUM_WINDOW_SIZE)
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose, True)
self._exportItems = tuple()
self._exportProcessedCount = 0
self._exportGeneratedCount = 0
self._exporting = False
self._exportTimer = QtCore.QTimer(self)
self._exportTimer.setSingleShot(True)
connectWeakly(
self._exportTimer.timeout,
self,
'processNextExportItem',
sender=self._exportTimer,
)
self.tabWidget = AppQTabWidget(parent=self, translatable=False)
self.tabWidget.setTabsClosable(True)
self.tabWidget.setElideMode(QtCore.Qt.TextElideMode.ElideRight)
@@ -192,6 +276,18 @@ class QRCodeWindow(AppQMainWindow):
"""Return the number of successfully generated QR-code tabs."""
return self.tabWidget.count()
def isExporting(self) -> bool:
"""Return whether incremental QR generation still owns pending items."""
return self._exporting
def exportProcessedCount(self) -> int:
"""Return the number of items attempted by the current export."""
return self._exportProcessedCount
def exportGeneratedCount(self) -> int:
"""Return the number of tabs generated by the current export."""
return self._exportGeneratedCount
def _clearTabs(self):
"""Remove and destroy every page currently owned by the tab widget."""
while self.tabWidget.count():
@@ -202,50 +298,143 @@ class QRCodeWindow(AppQMainWindow):
if page is not None:
page.deleteLater()
def appendExportItem(self, item: QRCodeExportItem) -> bool:
"""Generate and append one captured profile without affecting its siblings."""
try:
uri = exportConfiguration(item.profile)
except Exception as ex:
# Any non-exit exceptions
logger.warning(
'unable to export profile %d for QR presentation (%s)',
item.position,
type(ex).__name__,
)
return False
if not uri:
return False
try:
image = createQRCodeImage(uri)
except (segno.DataOverflowError, ValueError) as ex:
logger.warning(
'unable to create QR code for profile %d (%s)',
item.position,
type(ex).__name__,
)
return False
except Exception as ex:
# Any non-exit exceptions
logger.warning(
'unable to create QR code for profile %d (%s)',
item.position,
type(ex).__name__,
)
return False
page = _QRCodePage(image, parent=self.tabWidget)
title = f'{item.position} - {item.remark}'
tabIndex = self.tabWidget.addTab(page, title)
self.tabWidget.setTabToolTip(tabIndex, title)
return True
def initTabByIndex(self, indexes: list[int]):
"""Replace the tabs with QR pages for the selected profile indexes."""
"""Synchronously initialize capped tabs for compatibility callers."""
self.cancelExport()
self._clearTabs()
profiles = Storage.UserServers()
for item in captureQRCodeExportItems(indexes):
self.appendExportItem(item)
for index in indexes:
config = profiles[index]
def startExportByIndex(self, indexes: Iterable[int]):
"""Start one bounded, window-owned incremental QR export."""
items = captureQRCodeExportItems(tuple(indexes))
try:
uri = exportConfiguration(config)
except Exception as ex:
# Any non-exit exceptions
logger.warning(
'unable to export profile %d for QR presentation (%s)',
index + 1,
type(ex).__name__,
)
continue
self.cancelExport()
self._clearTabs()
self._exportProcessedCount = 0
self._exportGeneratedCount = 0
if not uri:
continue
if not items:
self.deleteLater()
try:
image = createQRCodeImage(uri)
except (segno.DataOverflowError, ValueError) as ex:
logger.warning(
'unable to create QR code for profile %d (%s)',
index + 1,
type(ex).__name__,
)
return None
continue
except Exception as ex:
# Any non-exit exceptions
if len(items) == 1:
if self.appendExportItem(items[0]):
self._exportProcessedCount = 1
self._exportGeneratedCount = 1
self.show()
else:
self._exportProcessedCount = 1
self.deleteLater()
continue
return self
page = _QRCodePage(image, parent=self.tabWidget)
title = f'{index + 1} - {config.itemRemark}'
self._exportItems = items
self._exporting = True
self.show()
self._exportTimer.start(0)
tabIndex = self.tabWidget.addTab(page, title)
return self
self.tabWidget.setTabToolTip(tabIndex, title)
def processNextExportItem(self):
"""Attempt one captured profile, then yield to the Qt event loop."""
if not self._exporting:
return
if self._exportProcessedCount >= len(self._exportItems):
self.finishExport()
return
item = self._exportItems[self._exportProcessedCount]
generated = self.appendExportItem(item)
self._exportProcessedCount += 1
self._exportGeneratedCount += int(generated)
if self._exportProcessedCount >= len(self._exportItems):
self.finishExport()
return
self._exportTimer.start(0)
def finishExport(self):
"""Release completed export state and discard an empty result window."""
if not self._exporting:
return
generatedCount = self._exportGeneratedCount
self._exportTimer.stop()
self._exportItems = tuple()
self._exporting = False
if generatedCount == 0:
self.close()
def cancelExport(self):
"""Cancel pending work without removing tabs already generated."""
self._exportTimer.stop()
self._exportItems = tuple()
self._exporting = False
def closeEvent(self, event):
"""Cancel an active export after Qt accepts closing this window."""
super().closeEvent(event)
if event.isAccepted():
self.cancelExport()
@QtCore.Slot(int)
def handleTabCloseRequested(self, index: int):
+20 -4
View File
@@ -59,11 +59,12 @@ strategy in an individual test.
| AppQMainWindow lifecycle, subclass policies, geometry restoration, and migration | `test_main_window_geometry.py` |
| AppQDialog first-presentation geometry, native show paths, centering, and async lifetime | `test_dialog_geometry.py` |
| Editor mappings, lazy log rendering, routing/message-box/connection UI | `test_ui_behavior.py` |
| Bounded, incremental, cancellable QR export and snapshot/lifetime safety | `test_qr_export_scalability.py` |
| Real keyboard/mouse/focus, proxy mapping, shared Home/Settings state, and transient editor input | `test_qt_interactions.py` |
| Direct Qt ownership and destruction across independent UI families | `test_qt_lifetime.py` |
| Batched real/probe Qt object, handle, Python allocation, and RSS trends | `test_qt_stress.py` |
| Batched real/probe Qt object, QR rendering/window lifecycle, handle, Python allocation, and RSS trends | `test_qt_stress.py` |
| Repeated harmless subprocess, pipe, thread, handle, and RSS trends | `test_process_stress.py` |
| Opt-in release-confidence counts (100 app children, 100 external cores, 100k metrics, 40k logs, 20k navigation, 5k plugins, 1k dialogs) | `test_very_heavy.py` |
| Opt-in release-confidence counts (100 app children, 100 external cores, 100k metrics, 40k logs, 20k navigation, 5k plugins, 1k dialogs, 1k real QR tabs) | `test_very_heavy.py` |
The lifecycle tests classify `AppQTransientDialog`, protocol/plugin editors,
routing dialogs, subscription editors, message boxes, QR windows, and TUN
@@ -109,7 +110,7 @@ Then run the desired test tier.
python -m unittest discover -s tests -v
# Regular logic, persistence, plugin, controller, codec, and UI regressions
python -m unittest tests.test_interface tests.test_models_and_services tests.test_repository_contracts tests.test_architecture_refactors tests.test_connection_startup_async tests.test_plugin_architecture tests.test_hysteria1_protocol tests.test_hysteria2_compatibility tests.test_controllers tests.test_subscription_manager tests.test_subscription_sync tests.test_subscription_scalability tests.test_profile_test_jobs tests.test_socks_uri tests.test_shadowsocks_uri tests.test_backend_editor_contract tests.test_xray_asset_download tests.test_native_tun_semantics tests.test_metrics_behavior tests.test_endpoint_info tests.test_service_runtime tests.test_frozenlib tests.test_isolation_and_navigation tests.test_main_window_geometry tests.test_dialog_geometry tests.test_ui_behavior tests.test_qt_interactions tests.test_stylesheet_states tests.test_theme_transition tests.test_public_api -v
python -m unittest tests.test_interface tests.test_models_and_services tests.test_repository_contracts tests.test_architecture_refactors tests.test_connection_startup_async tests.test_plugin_architecture tests.test_hysteria1_protocol tests.test_hysteria2_compatibility tests.test_controllers tests.test_subscription_manager tests.test_subscription_sync tests.test_subscription_scalability tests.test_profile_test_jobs tests.test_socks_uri tests.test_shadowsocks_uri tests.test_backend_editor_contract tests.test_xray_asset_download tests.test_native_tun_semantics tests.test_metrics_behavior tests.test_endpoint_info tests.test_service_runtime tests.test_frozenlib tests.test_isolation_and_navigation tests.test_main_window_geometry tests.test_dialog_geometry tests.test_ui_behavior tests.test_qr_export_scalability tests.test_qt_interactions tests.test_stylesheet_states tests.test_theme_transition tests.test_public_api -v
# Direct Qt/process integration and destruction/lifetime checks
python -m unittest tests.test_application_process tests.test_external_core tests.test_layout_matrix tests.test_qt_lifetime -v
@@ -126,7 +127,7 @@ python -m unittest tests.test_very_heavy -v
python -m unittest tests.test_log_manager_generation.VeryHeavyGenerationLogManagerTest -v
# Shared-state order-independence spot check
python -m unittest tests.test_public_api tests.test_theme_transition tests.test_stylesheet_states tests.test_qt_interactions tests.test_ui_behavior tests.test_dialog_geometry tests.test_main_window_geometry tests.test_isolation_and_navigation tests.test_frozenlib tests.test_service_runtime tests.test_endpoint_info tests.test_metrics_behavior tests.test_native_tun_semantics tests.test_xray_asset_download tests.test_backend_editor_contract tests.test_shadowsocks_uri tests.test_socks_uri tests.test_profile_test_jobs tests.test_subscription_sync tests.test_subscription_manager tests.test_controllers tests.test_hysteria2_compatibility tests.test_hysteria1_protocol tests.test_plugin_architecture tests.test_connection_startup_async tests.test_architecture_refactors tests.test_repository_contracts tests.test_models_and_services tests.test_interface -v
python -m unittest tests.test_public_api tests.test_theme_transition tests.test_stylesheet_states tests.test_qt_interactions tests.test_qr_export_scalability tests.test_ui_behavior tests.test_dialog_geometry tests.test_main_window_geometry tests.test_isolation_and_navigation tests.test_frozenlib tests.test_service_runtime tests.test_endpoint_info tests.test_metrics_behavior tests.test_native_tun_semantics tests.test_xray_asset_download tests.test_backend_editor_contract tests.test_shadowsocks_uri tests.test_socks_uri tests.test_profile_test_jobs tests.test_subscription_sync tests.test_subscription_manager tests.test_controllers tests.test_hysteria2_compatibility tests.test_hysteria1_protocol tests.test_plugin_architecture tests.test_connection_startup_async tests.test_architecture_refactors tests.test_repository_contracts tests.test_models_and_services tests.test_interface -v
python -m unittest discover -s tests -v
```
@@ -155,6 +156,21 @@ final status and exit code as authoritative; expected diagnostic output still en
per group by default and reports decode/parse CPU time, reconciliation preparation, worker wall time, and GUI-thread
commit time. Pass `--url` explicitly to benchmark a live subscription; normal tests never use the network.
`tests/benchmarks/benchmark_qr_export.py` measures the real Segno-to-`QImage` renderer and the complete synchronous or
incremental QR-window pipeline. The 5,000-item UI workloads are intentionally benchmarks rather than correctness tests;
run each mode in a fresh process so allocator and Qt-widget state do not affect the next measurement:
```text
python tests/benchmarks/benchmark_qr_export.py --mode images --count 5000
python tests/benchmarks/benchmark_qr_export.py --mode synchronous --count 5000
python tests/benchmarks/benchmark_qr_export.py --mode asynchronous --count 5000
```
The regular Qt stress tier renders repeated real QR batches at the production cap. The opt-in release-confidence tier
temporarily raises the cap to 1,000 and verifies real generation, event-loop yielding, result-window presentation,
exact tab completion, and window-owned timer/state destruction. These tests intentionally assert behavior and cleanup
rather than machine-dependent elapsed-time thresholds.
## Packaged-build smoke procedure
Packaged/Nuitka behavior is outside the source-level `unittest` fixtures. For an
+264
View File
@@ -0,0 +1,264 @@
# Copyright (C) 2024present Loren Eteval & contributors <loren.eteval@proton.me>
#
# This file is part of Furious.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""Benchmark real QR rendering and synchronous/asynchronous QR windows."""
from __future__ import annotations
import os
import sys
from pathlib import Path
os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen')
repositoryRoot = Path(__file__).resolve().parents[2]
if str(repositoryRoot) not in sys.path:
sys.path.insert(0, str(repositoryRoot))
from Furious.Backends.Xray import XrayPlugin
from Furious.Plugins import PluginRegistry, profileFromAny
from PySide6 import QtCore
from tests.support import application, collectAtBoundary, isolatedSettings
from importlib import import_module
from unittest import mock
import argparse
import time
qrCodeModule = import_module('Furious.Window.QRCodeWindow')
def profiles(count: int, registry: PluginRegistry):
"""Return independent SOCKS profiles handled by the real exporter."""
baseProfile = profileFromAny(
'socks://benchmark.example:1080#Benchmark',
registry=registry,
)
result = []
for index in range(count):
profile = baseProfile.deepcopy()
profile.metadata.displayName = f'Benchmark {index + 1}'
result.append(profile)
return result
def pumpUntil(predicate, timeout: float):
"""Pump the real Qt event loop until completion or a bounded timeout."""
app = application()
deadline = time.perf_counter() + timeout
while time.perf_counter() < deadline:
app.processEvents(QtCore.QEventLoop.ProcessEventsFlag.AllEvents, 20)
if predicate():
return
QtCore.QThread.msleep(1)
raise TimeoutError(f'QR benchmark exceeded {timeout:g} seconds')
class TrackingQRCodeWindow(qrCodeModule.QRCodeWindow):
"""Record first native presentation milestones without changing behavior."""
shownAt = None
paintedAt = None
finishedAt = None
def showEvent(self, event):
"""Record the first result-window show event."""
if type(self).shownAt is None:
type(self).shownAt = time.perf_counter()
super().showEvent(event)
def paintEvent(self, event):
"""Record the first result-window paint event."""
if type(self).paintedAt is None:
type(self).paintedAt = time.perf_counter()
super().paintEvent(event)
def finishExport(self):
"""Record completion of the window-owned incremental export."""
wasExporting = self.isExporting()
super().finishExport()
if wasExporting and not self.isExporting():
type(self).finishedAt = time.perf_counter()
def resetMilestones():
"""Reset class-level measurements before an independent run."""
TrackingQRCodeWindow.shownAt = None
TrackingQRCodeWindow.paintedAt = None
TrackingQRCodeWindow.finishedAt = None
def elapsed(started: float, milestone: float | None):
"""Return one relative duration, preserving an absent milestone as None."""
return None if milestone is None else milestone - started
def benchmarkImages(count: int):
"""Render representative payloads without constructing Qt windows."""
started = time.perf_counter()
lastImage = None
for index in range(count):
payload = (
f'socks://benchmark-user:benchmark-password@node-{index}.example:1080'
f'#Benchmark-QR-Code-{index}'
)
lastImage = qrCodeModule.createQRCodeImage(payload)
duration = time.perf_counter() - started
return {
'mode': 'images',
'count': count,
'duration_s': duration,
'codes_per_s': count / duration,
'last_width': lastImage.width(),
'last_height': lastImage.height(),
}
def benchmarkWindow(mode: str, count: int, timeout: float):
"""Exercise the real exporter, QR renderer, tab pages, and presentation."""
registry = PluginRegistry()
registry.register(XrayPlugin())
try:
setupStarted = time.perf_counter()
serverProfiles = profiles(count, registry)
profileSetupDuration = time.perf_counter() - setupStarted
resetMilestones()
window = TrackingQRCodeWindow()
started = time.perf_counter()
with mock.patch.object(
qrCodeModule,
'MAXIMUM_QR_EXPORT_PROFILES',
count,
), mock.patch.object(
qrCodeModule.Storage,
'UserServers',
return_value=serverProfiles,
), mock.patch.object(
qrCodeModule,
'exportConfiguration',
side_effect=registry.exportConfig,
):
if mode == 'synchronous':
window.initTabByIndex(range(count))
buildFinished = time.perf_counter()
window.show()
pumpUntil(
lambda: TrackingQRCodeWindow.paintedAt is not None,
timeout,
)
finishedAt = time.perf_counter()
result = {
'mode': mode,
'count': count,
'profile_setup_s': profileSetupDuration,
'build_s': buildFinished - started,
'result_show_s': elapsed(started, TrackingQRCodeWindow.shownAt),
'result_first_paint_s': elapsed(
started,
TrackingQRCodeWindow.paintedAt,
),
'duration_s': finishedAt - started,
'tabs': window.tabCount(),
}
else:
window.startExportByIndex(range(count))
returnedAt = time.perf_counter()
pumpUntil(
lambda: TrackingQRCodeWindow.finishedAt is not None,
timeout,
)
result = {
'mode': mode,
'count': count,
'profile_setup_s': profileSetupDuration,
'start_return_s': returnedAt - started,
'result_show_s': elapsed(started, TrackingQRCodeWindow.shownAt),
'result_first_paint_s': elapsed(
started,
TrackingQRCodeWindow.paintedAt,
),
'duration_s': elapsed(
started,
TrackingQRCodeWindow.finishedAt,
),
'tabs': window.tabCount(),
}
if result['tabs'] != count:
raise RuntimeError(f"expected {count} tabs, got {result['tabs']}")
window.close()
collectAtBoundary()
return result
finally:
registry.shutdown()
def main():
"""Run one explicitly selected benchmark workload."""
parser = argparse.ArgumentParser()
parser.add_argument(
'--mode',
choices=('images', 'synchronous', 'asynchronous'),
default='asynchronous',
)
parser.add_argument('--count', type=int, default=5_000)
parser.add_argument('--timeout', type=float, default=900.0)
arguments = parser.parse_args()
if arguments.count < 1:
parser.error('--count must be at least 1')
application()
with isolatedSettings():
if arguments.mode == 'images':
result = benchmarkImages(arguments.count)
else:
result = benchmarkWindow(
arguments.mode,
arguments.count,
max(arguments.timeout, 1.0),
)
print(' '.join(f'{key}={value}' for key, value in result.items()))
if __name__ == '__main__':
main()
+437
View File
@@ -0,0 +1,437 @@
# Copyright (C) 2024present Loren Eteval & contributors <loren.eteval@proton.me>
#
# This file is part of Furious.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""Exercise bounded, incremental QR export with real Qt delivery."""
from __future__ import annotations
from Furious.Models import CoreConfiguration, ServerProfile
from Furious.Window.QRCodeWindow import (
MAXIMUM_QR_EXPORT_PROFILES,
QRCodeWindow,
captureQRCodeExportItems,
)
from PySide6 import QtCore
from PySide6.QtGui import QImage
from shiboken6 import isValid
from unittest import mock
import segno
import unittest
import weakref
from tests.support import (
application,
collectAtBoundary,
isolatedSettings,
processQtEvents,
waitFor,
)
class QRCodeExportScalabilityTest(unittest.TestCase):
"""Verify QR export remains bounded, responsive, stable, and disposable."""
@classmethod
def setUpClass(cls):
"""Create the process-wide offscreen QApplication."""
application()
def setUp(self):
"""Isolate settings used by top-level window presentation."""
self.settingsContext = isolatedSettings()
self.settingsContext.__enter__()
def tearDown(self):
"""Drain deferred window deletion before restoring settings."""
collectAtBoundary()
self.settingsContext.__exit__(None, None, None)
@staticmethod
def profile(index: int) -> ServerProfile:
"""Return one independent deterministic profile fixture."""
return ServerProfile.fromConfiguration(
CoreConfiguration({'type': 'fixture', 'address': f'{index}.example'}),
{'displayName': f'Profile {index + 1}'},
)
@classmethod
def profiles(cls, count: int) -> list[ServerProfile]:
"""Return the requested deterministic profile list."""
return [cls.profile(index) for index in range(count)]
@staticmethod
def qrImage(*_args) -> QImage:
"""Return a small valid square image for orchestration-only tests."""
image = QImage(29, 29, QImage.Format.Format_Grayscale8)
image.fill(255)
return image
def testCaptureLimitForOneFiftyFiftyOneAndThousands(self):
"""Snapshot no more than fifty profiles regardless of selection size."""
for selectedCount in (1, 50, 51, 2000):
with self.subTest(selectedCount=selectedCount):
profiles = []
for index in range(selectedCount):
profile = mock.Mock(itemRemark=f'Profile {index + 1}')
profile.deepcopy.return_value = mock.Mock()
profiles.append(profile)
items = captureQRCodeExportItems(
range(selectedCount),
profiles=profiles,
)
expected = min(selectedCount, MAXIMUM_QR_EXPORT_PROFILES)
self.assertEqual(len(items), expected)
self.assertEqual(
sum(profile.deepcopy.call_count for profile in profiles),
expected,
)
if selectedCount > MAXIMUM_QR_EXPORT_PROFILES:
self.assertTrue(
all(
profile.deepcopy.call_count == 0
for profile in profiles[MAXIMUM_QR_EXPORT_PROFILES:]
)
)
def testSingleProfileExportRemainsImmediate(self):
"""Keep one-profile export synchronous without scheduling a batch."""
profiles = self.profiles(1)
window = QRCodeWindow()
with mock.patch(
'Furious.Window.QRCodeWindow.Storage.UserServers',
return_value=profiles,
), mock.patch(
'Furious.Window.QRCodeWindow.exportConfiguration',
return_value='socks://single.example:1080#Single',
), mock.patch(
'Furious.Window.QRCodeWindow.createQRCodeImage',
side_effect=self.qrImage,
):
result = window.startExportByIndex([0])
self.assertIs(result, window)
self.assertFalse(window.isExporting())
self.assertEqual(window.exportProcessedCount(), 1)
self.assertEqual(window.exportGeneratedCount(), 1)
self.assertEqual(window.tabCount(), 1)
self.assertTrue(window.isVisible())
window.close()
def testOperationAttemptsAtMostTheProductionCap(self):
"""Attempt exactly the captured count and discard an empty result window."""
for selectedCount in (50, 51, 1000):
with self.subTest(selectedCount=selectedCount):
profiles = self.profiles(selectedCount)
window = QRCodeWindow()
appendExportItem = mock.Mock(return_value=False)
window.appendExportItem = appendExportItem
with mock.patch(
'Furious.Window.QRCodeWindow.Storage.UserServers',
return_value=profiles,
):
result = window.startExportByIndex(range(selectedCount))
expected = min(selectedCount, MAXIMUM_QR_EXPORT_PROFILES)
self.assertIs(result, window)
self.assertEqual(appendExportItem.call_count, 0)
self.assertTrue(
waitFor(lambda: not isValid(window), timeout=5.0),
'empty QR operation did not close',
)
self.assertEqual(appendExportItem.call_count, expected)
def testGenerationYieldsToAnUnrelatedQtEvent(self):
"""Deliver unrelated work after one attempt and before batch completion."""
profiles = self.profiles(6)
attempts = []
marker = []
window = QRCodeWindow()
def export(profile):
"""Record one exported snapshot."""
attempts.append(profile.itemRemark)
return f'socks://{len(attempts)}.example:1080#Fixture'
with mock.patch(
'Furious.Window.QRCodeWindow.Storage.UserServers',
return_value=profiles,
), mock.patch(
'Furious.Window.QRCodeWindow.exportConfiguration',
side_effect=export,
), mock.patch(
'Furious.Window.QRCodeWindow.createQRCodeImage',
side_effect=self.qrImage,
):
result = window.startExportByIndex(range(len(profiles)))
QtCore.QTimer.singleShot(0, lambda: marker.append(len(attempts)))
self.assertIs(result, window)
self.assertEqual(attempts, [])
self.assertTrue(window.isVisible())
self.assertTrue(waitFor(lambda: bool(marker)))
self.assertEqual(marker, [1])
self.assertTrue(waitFor(lambda: not window.isExporting()))
self.assertEqual(len(attempts), len(profiles))
self.assertEqual(window.exportProcessedCount(), len(profiles))
self.assertEqual(window.exportGeneratedCount(), len(profiles))
self.assertEqual(window.tabCount(), len(profiles))
window.close()
def testClosingBeforeFirstItemCancelsTheBatch(self):
"""Close the retained result window before its first scheduled attempt."""
profiles = self.profiles(5)
attempts = []
window = QRCodeWindow()
with mock.patch(
'Furious.Window.QRCodeWindow.Storage.UserServers',
return_value=profiles,
), mock.patch(
'Furious.Window.QRCodeWindow.exportConfiguration',
side_effect=lambda profile: attempts.append(profile) or 'fixture',
), mock.patch(
'Furious.Window.QRCodeWindow.createQRCodeImage',
side_effect=self.qrImage,
):
window.startExportByIndex(range(len(profiles)))
self.assertEqual(window.tabCount(), 0)
window.close()
processQtEvents()
self.assertEqual(attempts, [])
self.assertTrue(waitFor(lambda: not isValid(window)))
def testClosingMidwayStopsNewTabs(self):
"""Retain no window after close prevents the next scheduled attempt."""
for closeAfter in (3, 49):
with self.subTest(closeAfter=closeAfter):
profiles = self.profiles(50)
attempts = []
window = QRCodeWindow()
def export(profile):
"""Schedule window close immediately after the target attempt."""
attempts.append(profile.itemRemark)
if len(attempts) == closeAfter:
QtCore.QTimer.singleShot(0, window.close)
return f'socks://{len(attempts)}.example:1080#Fixture'
with mock.patch(
'Furious.Window.QRCodeWindow.Storage.UserServers',
return_value=profiles,
), mock.patch(
'Furious.Window.QRCodeWindow.exportConfiguration',
side_effect=export,
), mock.patch(
'Furious.Window.QRCodeWindow.createQRCodeImage',
side_effect=self.qrImage,
):
window.startExportByIndex(range(len(profiles)))
self.assertTrue(waitFor(lambda: not isValid(window)))
self.assertEqual(len(attempts), closeAfter)
processQtEvents()
self.assertEqual(len(attempts), closeAfter)
def testIndividualFailuresPreserveValidTabs(self):
"""Continue after exporter, empty-data, and QR-overflow failures."""
profiles = self.profiles(4)
attempts = []
window = QRCodeWindow()
def export(profile):
"""Return each representative exporter outcome."""
attempts.append(profile.itemRemark)
if profile.itemRemark == 'Profile 1':
raise RuntimeError('fixture exporter failure')
if profile.itemRemark == 'Profile 2':
return ''
if profile.itemRemark == 'Profile 3':
return 'overflow'
return 'socks://valid.example:1080#Valid'
def createImage(uri):
"""Raise only for the representative oversized payload."""
if uri == 'overflow':
raise segno.DataOverflowError('fixture overflow')
return self.qrImage()
with self.assertLogs(
'Furious.Window.QRCodeWindow', level='WARNING'
), mock.patch(
'Furious.Window.QRCodeWindow.Storage.UserServers',
return_value=profiles,
), mock.patch(
'Furious.Window.QRCodeWindow.exportConfiguration',
side_effect=export,
), mock.patch(
'Furious.Window.QRCodeWindow.createQRCodeImage',
side_effect=createImage,
):
window.startExportByIndex(range(len(profiles)))
self.assertTrue(waitFor(lambda: not window.isExporting()))
self.assertEqual(len(attempts), 4)
self.assertEqual(window.exportProcessedCount(), 4)
self.assertEqual(window.exportGeneratedCount(), 1)
self.assertEqual(window.tabCount(), 1)
self.assertEqual(window.tabWidget.tabText(0), '4 - Profile 4')
window.close()
def testRepositoryMutationCannotRetargetCapturedProfiles(self):
"""Export the initial snapshots after their source collection is replaced."""
profiles = self.profiles(5)
originalRemarks = [profile.itemRemark for profile in profiles]
exportedRemarks = []
window = QRCodeWindow()
def export(profile):
"""Observe the immutable operation snapshot."""
exportedRemarks.append(profile.itemRemark)
return f'socks://{len(exportedRemarks)}.example:1080#Fixture'
with mock.patch(
'Furious.Window.QRCodeWindow.Storage.UserServers',
return_value=profiles,
), mock.patch(
'Furious.Window.QRCodeWindow.exportConfiguration',
side_effect=export,
), mock.patch(
'Furious.Window.QRCodeWindow.createQRCodeImage',
side_effect=self.qrImage,
):
window.startExportByIndex(range(len(profiles)))
for profile in profiles:
profile.metadata.displayName = 'Mutated'
profiles.reverse()
profiles.clear()
self.assertTrue(waitFor(lambda: not window.isExporting()))
self.assertEqual(exportedRemarks, originalRemarks)
self.assertEqual(
[window.tabWidget.tabText(index) for index in range(window.tabCount())],
[f'{index + 1} - {remark}' for index, remark in enumerate(originalRemarks)],
)
window.close()
def testClosingQRCodeWindowCancelsAndDestroysTheOperation(self):
"""Stop after the first tab when the QR window closes during generation."""
profiles = self.profiles(8)
attempts = []
window = QRCodeWindow()
windowReference = weakref.ref(window)
def export(profile):
"""Close the shown window before the next scheduled profile."""
attempts.append(profile.itemRemark)
if len(attempts) == 1:
QtCore.QTimer.singleShot(0, window.close)
return 'socks://close.example:1080#Close'
with mock.patch(
'Furious.Window.QRCodeWindow.Storage.UserServers',
return_value=profiles,
), mock.patch(
'Furious.Window.QRCodeWindow.exportConfiguration',
side_effect=export,
), mock.patch(
'Furious.Window.QRCodeWindow.createQRCodeImage',
side_effect=self.qrImage,
):
window.startExportByIndex(range(len(profiles)))
self.assertTrue(waitFor(lambda: not isValid(window)))
processQtEvents()
self.assertEqual(attempts, ['Profile 1'])
del window
collectAtBoundary()
self.assertIsNone(windowReference())
def testRepeatedWindowOwnedExportsReturnToBaseline(self):
"""Destroy window-owned timers and captured state without retained callbacks."""
iterations = 24
windowReferences = []
windowDestroyed = []
with mock.patch(
'Furious.Window.QRCodeWindow.exportConfiguration',
return_value='socks://lifetime.example:1080#Lifetime',
), mock.patch(
'Furious.Window.QRCodeWindow.createQRCodeImage',
side_effect=self.qrImage,
):
for _index in range(iterations):
profiles = self.profiles(2)
window = QRCodeWindow()
with mock.patch(
'Furious.Window.QRCodeWindow.Storage.UserServers',
return_value=profiles,
):
result = window.startExportByIndex((0, 1))
self.assertIs(result, window)
window.destroyed.connect(lambda *_args: windowDestroyed.append(True))
windowReferences.append(weakref.ref(window))
self.assertTrue(waitFor(lambda: not window.isExporting()))
window.close()
self.assertTrue(waitFor(lambda: not isValid(window)))
del result, window
collectAtBoundary()
self.assertEqual(windowDestroyed, [True] * iterations)
self.assertTrue(all(reference() is None for reference in windowReferences))
if __name__ == '__main__':
unittest.main()
+102
View File
@@ -20,13 +20,20 @@
from __future__ import annotations
from Furious.Frozenlib import Mixins
from Furious.Models import CoreConfiguration, ServerProfile
from Furious.Qt import AppQAction, AppQMenu, AppQTransientDialog
from Furious.Backends.Hysteria2.Editor import Hysteria2Editor
from Furious.Backends.Xray.RoutingWindow import RoutingPreviewDialog
from Furious.Backends.Xray.VlessEditor import VlessEditor
from Furious.Window.QRCodeWindow import (
MAXIMUM_QR_EXPORT_PROFILES,
QRCodeWindow,
)
from PySide6 import QtCore, QtGui
from shiboken6 import isValid
from tests.support import (
application,
collectAtBoundary,
@@ -34,8 +41,11 @@ from tests.support import (
currentNativeHandleCount,
isolatedSettings,
qObjectCount,
waitFor,
)
from unittest import mock
import unittest
import weakref
import tracemalloc
@@ -315,5 +325,97 @@ class QtMemoryStressTest(unittest.TestCase):
)
class QRCodeExportStressTest(unittest.TestCase):
"""Exercise real QR generation, presentation, and cleanup in batches."""
BatchCount = 3
ProfilesPerBatch = MAXIMUM_QR_EXPORT_PROFILES
@classmethod
def setUpClass(cls):
"""Create the process-wide offscreen QApplication."""
application()
def setUp(self):
"""Isolate settings used by top-level window presentation."""
self.settingsContext = isolatedSettings()
self.settingsContext.__enter__()
def tearDown(self):
"""Drain deferred deletion before restoring settings."""
collectAtBoundary()
self.settingsContext.__exit__(None, None, None)
@staticmethod
def profiles(count: int):
"""Return deterministic profiles for a real QR rendering workload."""
return [
ServerProfile.fromConfiguration(
CoreConfiguration(
{'type': 'fixture', 'address': f'node-{index}.example'}
),
{'displayName': f'Profile {index + 1}'},
)
for index in range(count)
]
def testRepeatedRealQRCodeBatchesYieldShowAndRelease(self):
"""Render real QR tabs while keeping events and owners observable."""
collectAtBoundary()
baselineWindows = qObjectCount(QRCodeWindow)
windowReferences = []
windowDestroyed = []
for batch in range(self.BatchCount):
profiles = self.profiles(self.ProfilesPerBatch)
heartbeat = []
window = QRCodeWindow()
window.destroyed.connect(lambda *_args: windowDestroyed.append(True))
with mock.patch(
'Furious.Window.QRCodeWindow.Storage.UserServers',
return_value=profiles,
), mock.patch(
'Furious.Window.QRCodeWindow.exportConfiguration',
side_effect=lambda profile, _batch=batch: (
f'socks://node-{_batch}-{profile.itemRemark}.example:1080'
f'#Batch-{_batch}'
),
):
result = window.startExportByIndex(range(len(profiles)))
QtCore.QTimer.singleShot(
0,
lambda _window=window: heartbeat.append(_window.tabCount()),
)
self.assertTrue(waitFor(lambda: bool(heartbeat), timeout=5.0))
self.assertLess(heartbeat[0], len(profiles))
self.assertIs(result, window)
self.assertTrue(
waitFor(
lambda: window.tabCount() > 0,
timeout=10.0,
)
)
self.assertTrue(window.isVisible())
self.assertTrue(
waitFor(lambda: not window.isExporting(), timeout=30.0),
'real QR batch did not complete',
)
self.assertEqual(window.tabCount(), len(profiles))
windowReferences.append(weakref.ref(window))
window.close()
self.assertTrue(waitFor(lambda: not isValid(window)))
del result, window
collectAtBoundary()
self.assertEqual(windowDestroyed, [True] * self.BatchCount)
self.assertTrue(all(reference() is None for reference in windowReferences))
self.assertEqual(qObjectCount(QRCodeWindow), baselineWindows)
if __name__ == '__main__':
unittest.main()
+76 -1
View File
@@ -21,24 +21,32 @@ from __future__ import annotations
from Furious.Backends.ExternalCore import ConfigExternalCore, ExternalCoreProcess
from Furious.Interface import ApplicationRunner
from Furious.Models import LogCategory
from Furious.Models import CoreConfiguration, LogCategory, ServerProfile
from Furious.Plugins import FuriousPlugin, PluginMetadata, PluginRegistry
from Furious.Qt import AppQDialog, AppQMessageBox, AppQTransientDialog
from Furious.Utility import AppMainProcess
from Furious.Service import APPLICATION_LOG_CATEGORY, LogManager, MetricsHistory
from Furious.Widget import NavigationView
from Furious.Window.QRCodeWindow import QRCodeWindow
from PySide6 import QtCore
from PySide6.QtWidgets import QWidget
from shiboken6 import isValid
from tests.support import (
application,
collectAtBoundary,
isolatedSettings,
processQtEvents,
resourceSnapshot,
veryHeavyEnabled,
waitFor,
)
from importlib import import_module
from pathlib import Path
from unittest import mock
import gc
import multiprocessing
@@ -48,6 +56,8 @@ import threading
import unittest
import weakref
qrCodeModule = import_module('Furious.Window.QRCodeWindow')
class _ReleaseApplication:
"""Provide one picklable successful application-process fixture."""
@@ -352,6 +362,71 @@ class VeryHeavyContractTest(unittest.TestCase):
self.assertTrue(all(reference() is None for reference in references))
self.assertFalse(AppQDialog._openDialogs)
def testOneThousandRealQRCodeTabsYieldShowAndReleaseOwners(self):
"""Render a release-scale QR window without starving or retaining Qt owners."""
count = 1_000
profiles = [
ServerProfile.fromConfiguration(
CoreConfiguration(
{'type': 'fixture', 'address': f'node-{index}.example'}
),
{'displayName': f'Profile {index + 1}'},
)
for index in range(count)
]
heartbeat = []
windowDestroyed = []
with isolatedSettings(), mock.patch.object(
qrCodeModule,
'MAXIMUM_QR_EXPORT_PROFILES',
count,
), mock.patch.object(
qrCodeModule.Storage,
'UserServers',
return_value=profiles,
), mock.patch.object(
qrCodeModule,
'exportConfiguration',
side_effect=lambda profile: (
f'socks://{profile.itemRemark}.example:1080#Release-QR'
),
):
window = QRCodeWindow()
window.destroyed.connect(lambda *_args: windowDestroyed.append(True))
result = window.startExportByIndex(range(count))
windowReference = weakref.ref(window)
QtCore.QTimer.singleShot(
0,
lambda: heartbeat.append(window.tabCount()),
)
self.assertTrue(waitFor(lambda: bool(heartbeat), timeout=5.0))
self.assertLess(heartbeat[0], count)
self.assertIs(result, window)
self.assertTrue(
waitFor(
lambda: window.tabCount() > 0,
timeout=10.0,
)
)
self.assertTrue(window.isVisible())
self.assertTrue(
waitFor(lambda: not window.isExporting(), timeout=300.0),
'release-scale QR export did not complete',
)
self.assertEqual(window.tabCount(), count)
window.close()
self.assertTrue(waitFor(lambda: not isValid(window)))
del result, window
collectAtBoundary()
self.assertEqual(windowDestroyed, [True])
self.assertIsNone(windowReference())
if __name__ == '__main__':
unittest.main()