From 67c76a48b94f088b3d684d06c9548b83081348e6 Mon Sep 17 00:00:00 2001 From: Loren Eteval Date: Sat, 19 Sep 2026 11:12:26 +0800 Subject: [PATCH] Extend compiled lifetime probes Signed-off-by: Loren Eteval --- tests/README.md | 9 ++ tests/fixtures/editor_lifetime_probe.py | 159 +++++++++++++++++++++++- 2 files changed, 167 insertions(+), 1 deletion(-) diff --git a/tests/README.md b/tests/README.md index 897597b..75d136d 100644 --- a/tests/README.md +++ b/tests/README.md @@ -223,6 +223,15 @@ A null protected-list count means Nuitka does not expose that diagnostic; inspec installed package configuration and require zero live wrappers and registry entries instead. +The same standalone fixture also checks both HTTP clients across normal completion, early reply deletion, and +manager-first deletion, retaining invalid wrappers deliberately to prove request context is released. It also +checks action-owned versus explicitly widget-owned menus and representative asset delete/overwrite confirmations +whose views are destroyed while their containing widget remains valid. Keep this harness self-contained so its +compiled import graph does not pull in the full unittest modules. +`test_qt_lifetime.py` covers 20 cycles for each of five confirmation workflows under each mocked Windows/Linux/macOS +branch, plus 30 action/menu cycles for each ownership model. `test_service_runtime.py` additionally rejects duplicate +HTTP completion and checks 30 early-deletion cycles per client without per-cycle garbage collection. + ### Evolution regressions and CI `test_repository_contracts.py` verifies all-or-nothing hydration and preservation of diff --git a/tests/fixtures/editor_lifetime_probe.py b/tests/fixtures/editor_lifetime_probe.py index 3122829..865838c 100644 --- a/tests/fixtures/editor_lifetime_probe.py +++ b/tests/fixtures/editor_lifetime_probe.py @@ -21,13 +21,25 @@ from __future__ import annotations from Furious.Backends import OFFICIAL_PLUGIN_TYPES from Furious.Backends.Xray.RoutingWindow import RoutingRulesDialog +from Furious.Backends.Xray.AssetListView import XrayAssetListView +import Furious.Backends.Xray.AssetListView as assetModule from Furious.Plugins import blankProfile, initializePluginRegistry -from Furious.Qt import AppQDialog, AppQMessageBox, ThemeTransition, connectWeakly +from Furious.Qt import ( + AppQAction, + AppQMenu, + AppQDialog, + AppQMessageBox, + ThemeTransition, + connectWeakly, +) +from Furious.Qt.HttpGetManager import HttpGetManager +from Furious.Service.EndpointInfoService import ProxyEndpointHttpClient from Furious.Widget.ServerTableView import ServerTableView import PySide6 from PySide6 import QtCore +from PySide6.QtNetwork import QNetworkReply from PySide6.QtWidgets import QWidget from shiboken6 import isValid @@ -44,6 +56,8 @@ from collections import Counter import argparse import json import weakref +from pathlib import Path +import tempfile PROTOCOL_PATTERNS = { 'alternating': ('hysteria2', 'vless'), @@ -236,6 +250,78 @@ class _SignalEndpoint(QtCore.QObject): self.calls += 1 +class _PendingReply(QNetworkReply): + """Exercise request destruction without external network traffic.""" + + def __init__(self, parent): + super().__init__(parent) + self.open(QtCore.QIODevice.OpenModeFlag.ReadOnly) + + def abort(self): + self.setFinished(True) + self.finished.emit() + + def readData(self, maximumLength): + return b'' + + +class _RequestPayload: + """Expose whether pending request context still owns plain operation data.""" + + +def runNetworkProbe(iterations=100): + """Verify native teardown releases both reply registries under compilation.""" + application() + result = {} + + for managerType, contextAttribute in ( + (HttpGetManager, '_replyContexts'), + (ProxyEndpointHttpClient, '_pendingRequests'), + ): + for terminal in ('finished', 'replyDestroyed', 'managerDestroyed'): + references = [] + destroyed = [] + + for _ in range(iterations): + manager = managerType() + payload = _RequestPayload() + reply = _PendingReply(manager) + references.extend((weakref.ref(payload), weakref.ref(reply))) + reply.destroyed.connect(lambda *_args: destroyed.append(True)) + manager.get = lambda _request: reply + + if isinstance(manager, HttpGetManager): + manager.webGET('https://invalid.test', payload=payload) + else: + manager.request('https://invalid.test', payload) + + del manager.get + del payload + + if terminal == 'finished': + reply.finished.emit() + elif terminal == 'replyDestroyed': + reply.deleteLater() + else: + manager.deleteLater() + + processQtEvents() + assert not isValid(reply) + assert not getattr(manager, contextAttribute) + + if isValid(manager): + manager.deleteLater() + processQtEvents() + + del reply, manager + + assert len(destroyed) == iterations + assert all(reference() is None for reference in references) + result[managerType.__name__ + ':' + terminal] = iterations + + return result + + def runInfrastructureProbe(iterations=100): """Check signal, mask, and animation ownership under real Qt destruction.""" application() @@ -385,6 +471,75 @@ def runInfrastructureProbe(iterations=100): return result +def runConfirmationProbe(iterations=100): + """Check native menu ownership and representative view-owned prompts.""" + application() + result = {} + + for explicitOwner in (False, True): + references = [] + destroyed = [] + for _ in range(iterations): + owner = QWidget() + menu = AppQMenu(parent=owner if explicitOwner else None) + action = AppQAction('Menu fixture', menu=menu, parent=owner) + references.append(weakref.ref(menu)) + menu.destroyed.connect(lambda *_args: destroyed.append(True)) + action.deleteLater() + processQtEvents() + assert not isValid(action) + assert isValid(menu) == explicitOwner + owner.deleteLater() + processQtEvents() + del action, menu, owner + + assert len(destroyed) == iterations + assert all(reference() is None for reference in references) + result['widgetOwnedMenu' if explicitOwner else 'actionOwnedMenu'] = iterations + + originalAssetDirectory = assetModule.XRAY_ASSET_DIR + try: + with tempfile.TemporaryDirectory() as directory: + assetModule.XRAY_ASSET_DIR = Path(directory) + asset = Path(directory) / 'fixture.dat' + asset.write_bytes(b'keep') + for overwrite in (False, True): + references = [] + destroyed = [] + for _ in range(iterations): + owner = QWidget() + view = XrayAssetListView(parent=owner) + view.setCurrentIndex(view.model().index(0, 0)) + if overwrite: + view.appendNewItem(str(asset)) + else: + view.deleteSelectedItem() + confirmation = next(iter(AppQDialog._openDialogs.values())) + references.append(weakref.ref(confirmation)) + confirmation.destroyed.connect( + lambda *_args: destroyed.append(True) + ) + view.deleteLater() + processQtEvents() + assert not isValid(view) and not isValid(confirmation) + assert isValid(owner) + assert asset.read_bytes() == b'keep' + owner.deleteLater() + processQtEvents() + del confirmation, view, owner + + assert len(destroyed) == iterations + assert all(reference() is None for reference in references) + assert not AppQDialog._openDialogs + result[ + 'assetOverwriteOwnerFirst' if overwrite else 'assetDeleteOwnerFirst' + ] = iterations + finally: + assetModule.XRAY_ASSET_DIR = originalAssetDirectory + + return result + + def main(): """Run the probe as a standalone source or Nuitka executable.""" parser = argparse.ArgumentParser() @@ -395,6 +550,8 @@ def main(): parser.add_argument('--close-method', choices=CLOSE_METHODS, default='reject') arguments = parser.parse_args() + print(json.dumps(runConfirmationProbe(arguments.iterations), sort_keys=True)) + print(json.dumps(runNetworkProbe(arguments.iterations), sort_keys=True)) print(json.dumps(runInfrastructureProbe(arguments.iterations), sort_keys=True)) print(