Files
LorenEteval_Furious/tests/test_service_runtime.py
T
2026-10-07 16:25:47 +08:00

1294 lines
47 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Copyright (C) 2024–present 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 service work, validation, and plugin UI ownership."""
from __future__ import annotations
from Furious.Plugins import (
CapabilityKind,
NavigationPageDescriptor,
TrafficCounters,
TrafficStatsMonitor,
)
from Furious.Service.ConnectivityManager import ConnectivityManager
from Furious.Service.EndpointInfoService import ProxyEndpointHttpClient
from Furious.Qt.HttpGetManager import HttpGetManager
from Furious.Qt import AppQDialog
from Furious.Service.PluginUIManager import PluginNavigationManager
from Furious.Service.SubscriptionManager import SubscriptionManager
from Furious.Service.DnsResolver import DnsResolver
from Furious.Repository import Storage
from Furious.Service.TrafficStatsManager import TrafficStatsManager
from Furious.Service.UpdateManager import UpdateManager
from Furious.Controllers.ConnectionController import ConnectionController
from Furious.Window.HomePage import AppConnectivityManager, HomePage
from PySide6 import QtCore
from PySide6.QtNetwork import QNetworkReply
from PySide6.QtWidgets import QWidget, QMainWindow
from shiboken6 import isValid, delete as deleteQObject
from tests.support import processQtEvents, application, collectAtBoundary, waitFor
from types import SimpleNamespace
from unittest.mock import Mock, patch
import json
import unittest
import weakref
import threading
class _ResponseBody:
"""Provide the QByteArray-compatible API consumed by UpdateManager."""
def __init__(self, data):
"""Store one deterministic response body."""
self._data = data
def data(self):
"""Return the stored response bytes."""
return self._data
class _Response:
"""Expose one complete fake network response."""
def __init__(self, payload):
"""Encode *payload* as one JSON response."""
self._data = json.dumps(payload).encode('utf-8')
def readAll(self):
"""Return a QByteArray-compatible response wrapper."""
return _ResponseBody(self._data)
class UpdateManagerTest(unittest.TestCase):
"""Verify update data is validated before it reaches UI callbacks."""
@classmethod
def setUpClass(cls):
"""Create the process-wide headless QApplication."""
application()
def testMalformedSuccessfulResponseUsesControlledFailurePath(self):
"""Reject missing and untrusted release fields without raw exceptions."""
manager = UpdateManager()
parent = QWidget()
versions = []
with patch.object(manager, 'showErrorMessageBox') as showError:
manager.successCallback(
_Response(
{
'tag_name': '999.0.0',
'html_url': 'javascript:alert(1)',
}
),
parent=parent,
hasNewVersionCallback=versions.append,
)
showError.assert_called_once_with(parent)
self.assertEqual(versions, [])
manager.deleteLater()
parent.deleteLater()
def testFailureForwardsDialogParent(self):
"""Keep update failures modal to the initiating window when supplied."""
manager = UpdateManager()
parent = QWidget()
with patch.object(manager, 'showErrorMessageBox') as showError:
manager.failureCallback(None, parent=parent)
showError.assert_called_once_with(parent)
manager.deleteLater()
parent.deleteLater()
def testDefaultUpdateManagerDiesWithItsController(self):
"""An invalid controller wrapper cannot keep its update request alive."""
controller = ConnectionController(coreManager=SimpleNamespace(runtimes=[]))
manager = controller._updatesManager
reply = _ManagedReply(manager)
payload = _ResponseBody(b'fixture')
reference = weakref.ref(payload)
try:
with patch.object(manager, 'get', lambda _request: reply):
manager.webGET('https://invalid.test', payload=payload)
del payload
deleteQObject(controller)
self.assertFalse(isValid(manager))
self.assertFalse(isValid(reply))
self.assertFalse(manager._replyContexts)
self.assertIsNone(reference())
finally:
if isValid(manager):
deleteQObject(manager)
if isValid(controller):
deleteQObject(controller)
def testInjectedUpdateManagerKeepsItsExistingOwner(self):
"""Controller composition must not steal a borrowed network manager."""
owner = QtCore.QObject()
manager = UpdateManager(owner)
controller = ConnectionController(
coreManager=SimpleNamespace(runtimes=[]), updatesManager=manager
)
try:
deleteQObject(controller)
self.assertTrue(isValid(manager))
self.assertIs(manager.parent(), owner)
finally:
deleteQObject(owner)
def testReleaseNotificationCanDestroyManagerOrDialogParent(self):
"""A release callback ending ownership must not create a stale dialog."""
for target in ('manager', 'parent'):
with self.subTest(target=target):
for _ in range(20):
manager = UpdateManager()
parent = QWidget()
baseline = set(AppQDialog._openDialogs)
def notified(_version):
deleteQObject(manager if target == 'manager' else parent)
try:
manager.successCallback(
_Response(
{
'tag_name': '999.0.0',
'html_url': 'https://github.com/LorenEteval/Furious/releases',
}
),
parent=parent,
hasNewVersionCallback=notified,
)
self.assertEqual(set(AppQDialog._openDialogs), baseline)
finally:
if isValid(manager):
deleteQObject(manager)
if isValid(parent):
deleteQObject(parent)
processQtEvents()
def testUpdateResponsesDoNotPresentUnderAnAlreadyDestroyedParent(self):
"""Successful, malformed and failed requests may outlive their UI parent."""
for result in ('new', 'current', 'malformed', 'failure'):
with self.subTest(result=result):
manager = UpdateManager()
parent = QWidget()
deleteQObject(parent)
baseline = set(AppQDialog._openDialogs)
try:
if result == 'failure':
manager.failureCallback(None, parent=parent)
else:
payload = {
'tag_name': '999.0.0' if result == 'new' else '0.0.0',
'html_url': 'https://github.com/LorenEteval/Furious/releases',
}
if result == 'malformed':
payload = {}
manager.successCallback(_Response(payload), parent=parent)
self.assertEqual(set(AppQDialog._openDialogs), baseline)
finally:
deleteQObject(manager)
processQtEvents()
class _ManagedReply(QNetworkReply):
"""Provide a hermetic reply object with real Qt lifecycle signals."""
def __init__(self, parent=None):
super().__init__(parent)
self.open(QtCore.QIODevice.OpenModeFlag.ReadOnly)
def abort(self):
self.setFinished(True)
def readData(self, maximumLength):
return bytes()
class _CapturingHttpGetManager(HttpGetManager):
"""Capture normalized requests without performing network I/O."""
def __init__(self):
super().__init__()
self.reply = _ManagedReply(self)
self.request = None
def get(self, request):
self.request = request
return self.reply
class HttpGetManagerLifetimeTest(unittest.TestCase):
"""Verify every request receives a timeout and releases exact context."""
def testReentrantReplyCompletionRunsTerminalCleanupOnce(self):
"""A nested finished signal cannot repeat once-only resource teardown."""
for _ in range(30):
manager = HttpGetManager()
replies = [_ManagedReply(manager), _ManagedReply(manager)]
resource = QtCore.QTimer(manager)
completed, errors = [], []
destroyed = []
resource.destroyed.connect(lambda *_args: destroyed.append(True))
def complete(**context):
completed.append(context['marker'])
resource.stop()
deleteQObject(resource)
if context['marker'] == 'first':
replies[1].finished.emit()
manager.completionCallback = complete
try:
with patch.object(manager, 'get', side_effect=replies):
manager.webGET('https://invalid.test/first', marker='first')
manager.webGET('https://invalid.test/second', marker='second')
with (
patch('sys.excepthook', lambda *args: errors.append(args)),
patch('Furious.Qt.HttpGetManager.logger.error') as callbackError,
):
replies[0].finished.emit()
processQtEvents()
self.assertEqual(completed, ['first'])
callbackError.assert_not_called()
self.assertFalse(errors)
self.assertEqual(destroyed, [True])
self.assertFalse(isValid(resource))
self.assertTrue(all(not isValid(reply) for reply in replies))
self.assertFalse(manager._replyContexts)
self.assertTrue(manager.completionHasRun)
finally:
if isValid(manager):
deleteQObject(manager)
def testReentrantPerReplyCompletionKeepsIndependentCleanup(self):
"""Per-request completion still releases each request's exact resource."""
manager = HttpGetManager(completionRunsOnce=False)
replies = [_ManagedReply(manager), _ManagedReply(manager)]
resources = {marker: QtCore.QTimer(manager) for marker in ('first', 'second')}
completed, errors = [], []
def complete(**context):
marker = context['marker']
completed.append(marker)
deleteQObject(resources[marker])
if marker == 'first':
replies[1].finished.emit()
manager.completionCallback = complete
try:
with patch.object(manager, 'get', side_effect=replies):
manager.webGET('https://invalid.test/first', marker='first')
manager.webGET('https://invalid.test/second', marker='second')
with patch('sys.excepthook', lambda *args: errors.append(args)):
replies[0].finished.emit()
processQtEvents()
self.assertEqual(completed, ['first', 'second'])
self.assertFalse(errors)
self.assertTrue(all(not isValid(item) for item in resources.values()))
self.assertTrue(all(not isValid(reply) for reply in replies))
self.assertFalse(manager._replyContexts)
finally:
if isValid(manager):
deleteQObject(manager)
def testDnsOperationOwnerDestructionAbortsItsRequest(self):
"""A resolver outliving a request must not outlive that request's owner."""
application()
resolver = DnsResolver()
self.addCleanup(resolver.dispose)
for _ in range(30):
parent = QtCore.QObject()
reply = _ManagedReply(resolver)
operation = resolver.resolveAsync('example.test', parent=parent)
results, errors = [], []
operation.finished.connect(lambda *_args: results.append(True))
def abort():
reply.setError(
QNetworkReply.NetworkError.OperationCanceledError, 'cancelled'
)
reply.setFinished(True)
reply.finished.emit()
reply.abort = abort
try:
with (
patch.object(resolver, 'get', lambda _request: reply),
patch('Furious.Service.DnsResolver.logger.error'),
patch('sys.excepthook', lambda *args: errors.append(args)),
):
operation.start()
deleteQObject(parent)
self.assertFalse(isValid(operation))
self.assertFalse(isValid(operation._timer))
self.assertTrue(reply.isFinished())
self.assertFalse(resolver._replyContexts)
self.assertFalse(results)
self.assertFalse(errors)
processQtEvents()
self.assertFalse(isValid(reply))
finally:
if isValid(reply):
reply.abort()
if isValid(parent):
deleteQObject(parent)
def testDnsDisposalToleratesNativeDestructionDuringAbort(self):
"""Reply abort may destroy the resolver before its deferred delete."""
application()
for _ in range(30):
resolver = DnsResolver()
reply = _ManagedReply(resolver)
reference = weakref.ref(reply)
resolver._trackReplyContext(reply, resolver._replyContexts, {})
reply.abort = lambda: deleteQObject(resolver)
resolver.dispose()
self.assertFalse(isValid(resolver))
self.assertFalse(isValid(reply))
self.assertFalse(resolver._replyContexts)
del reply
self.assertIsNone(reference())
@classmethod
def setUpClass(cls):
application()
def testCompletionMayDestroyReplyOrManager(self):
"""Real finished delivery tolerates native deletion from user callbacks."""
for managerType, contextAttribute in (
(HttpGetManager, '_replyContexts'),
(ProxyEndpointHttpClient, '_pendingRequests'),
):
for deleteManager in (False, True):
with self.subTest(manager=managerType.__name__, owner=deleteManager):
for _ in range(20):
manager = managerType()
reply = _ManagedReply(manager)
destroyed = []
reply.destroyed.connect(lambda *_a: destroyed.append(True))
completion = []
def destroyFromCallback(*_args, **_kwargs):
deleteQObject(manager if deleteManager else reply)
try:
with patch.object(manager, 'get', lambda _request: reply):
if isinstance(manager, HttpGetManager):
manager.webGET(
'https://invalid.test', logActionMessage=False
)
manager.successCallback = destroyFromCallback
manager.completionCallback = (
lambda **_k: completion.append(True)
)
else:
manager.request('https://invalid.test', 'fixture')
manager.completed.connect(destroyFromCallback)
with patch('sys.excepthook') as exceptionHook:
reply.finished.emit()
processQtEvents()
exceptionHook.assert_not_called()
self.assertEqual(destroyed, [True])
self.assertFalse(isValid(reply))
self.assertFalse(getattr(manager, contextAttribute))
if isinstance(manager, HttpGetManager):
self.assertEqual(
completion, [] if deleteManager else [True]
)
finally:
if isValid(manager):
deleteQObject(manager)
processQtEvents()
def testEndpointCancellationToleratesOwnerDestructionDuringAbort(self):
"""An abort listener may delete the manager and its other pending replies."""
manager = ProxyEndpointHttpClient()
replies = [_ManagedReply(manager), _ManagedReply(manager)]
for index, reply in enumerate(replies):
with patch.object(manager, 'get', lambda _request: reply):
manager.request('https://invalid.test', index)
replies[0].abort = lambda: deleteQObject(manager)
manager.cancelAll()
self.assertFalse(isValid(manager))
self.assertTrue(all(not isValid(reply) for reply in replies))
self.assertEqual(manager._pendingRequests, {})
def testSubscriptionRepliesReleaseTrackingOnNativeDestruction(self):
"""Early deletion releases all reply keys before later cancellation."""
with patch.object(Storage, 'UserSubs', return_value={}):
manager = SubscriptionManager()
references = []
destroyed = []
try:
for index in range(30):
reply = _ManagedReply(manager)
references.append(weakref.ref(reply))
reply.destroyed.connect(lambda *_args: destroyed.append(True))
with patch.object(manager, 'get', return_value=reply):
manager.updateSubsByWebGET(
webURL='https://invalid.test', unique=str(index)
)
self.assertIn(reply, manager._activeReplies)
reply.deleteLater()
processQtEvents()
self.assertFalse(isValid(reply))
del reply
self.assertFalse(manager._replyContexts)
self.assertFalse(manager._activeReplies)
self.assertFalse(manager._replySubscriptions)
manager.cancelUpdates()
self.assertEqual(len(destroyed), 30)
collectAtBoundary()
self.assertTrue(all(reference() is None for reference in references))
finally:
manager.shutdown()
manager.deleteLater()
processQtEvents()
def testSubscriptionCancellationToleratesOwnerDestructionDuringAbort(self):
"""Reentrant destruction invalidates every remaining snapshot reply."""
for method in ('cancelUpdates', 'shutdown'):
with self.subTest(method=method):
self._cancelSubscriptionWithReentrantDestruction(method)
def _cancelSubscriptionWithReentrantDestruction(self, method):
"""Keep both public teardown entry points on the real native boundary."""
with patch.object(Storage, 'UserSubs', return_value={}):
manager = SubscriptionManager()
replies = [_ManagedReply(manager), _ManagedReply(manager)]
for index, reply in enumerate(replies):
with patch.object(manager, 'get', return_value=reply):
manager.updateSubsByWebGET(
webURL='https://invalid.test', unique=str(index)
)
replies[0].abort = lambda: deleteQObject(manager)
getattr(manager, method)()
self.assertFalse(isValid(manager))
self.assertTrue(all(not isValid(reply) for reply in replies))
self.assertFalse(manager._replyContexts)
self.assertFalse(manager._activeReplies)
self.assertFalse(manager._replySubscriptions)
def testRequestHasFiniteTimeoutAndTerminalPathDropsContext(self):
manager = _CapturingHttpGetManager()
reply = manager.webGET('https://invalid.test/resource', marker='fixture')
self.assertIs(reply, manager.reply)
self.assertEqual(manager.request.transferTimeout(), 60_000)
self.assertEqual(manager._replyContexts[reply], {'marker': 'fixture'})
with patch.object(manager, 'handleFinishedByNetworkReply') as finished:
reply.finished.emit()
self.assertEqual(manager._replyContexts, {})
finished.assert_called_once_with(reply, marker='fixture')
manager.deleteLater()
def testEarlyReplyDestructionReleasesContextAcrossRepeatedRequests(self):
"""Native deletion without finished releases payloads and reply wrappers."""
for managerType, contextAttribute in (
(HttpGetManager, '_replyContexts'),
(ProxyEndpointHttpClient, '_pendingRequests'),
):
with self.subTest(manager=managerType.__name__):
manager = managerType()
references = []
destroyed = []
self.addCleanup(manager.deleteLater)
for _ in range(30):
payload = _ResponseBody(b'fixture')
references.append(weakref.ref(payload))
reply = _ManagedReply(manager)
references.append(weakref.ref(reply))
reply.destroyed.connect(lambda *_args: destroyed.append(True))
with patch.object(manager, 'get', lambda _request: reply):
if isinstance(manager, HttpGetManager):
manager.webGET('https://invalid.test', payload=payload)
else:
manager.request('https://invalid.test', payload)
del payload
reply.deleteLater()
processQtEvents()
self.assertFalse(isValid(reply))
self.assertFalse(getattr(manager, contextAttribute))
del reply
self.assertEqual(len(destroyed), 30)
self.assertTrue(all(reference() is None for reference in references))
def testManagerDestructionReleasesPendingContextWithRetainedWrappers(self):
"""Surviving invalid wrappers cannot keep operation payloads alive."""
for managerType, contextAttribute in (
(HttpGetManager, '_replyContexts'),
(ProxyEndpointHttpClient, '_pendingRequests'),
):
with self.subTest(manager=managerType.__name__):
manager = managerType()
payload = _ResponseBody(b'fixture')
reference = weakref.ref(payload)
reply = _ManagedReply(manager)
with patch.object(manager, 'get', lambda _request: reply):
if isinstance(manager, HttpGetManager):
manager.webGET('https://invalid.test', payload=payload)
else:
manager.request('https://invalid.test', payload)
del payload
manager.deleteLater()
processQtEvents()
self.assertFalse(isValid(manager))
self.assertFalse(isValid(reply))
self.assertFalse(getattr(manager, contextAttribute))
self.assertIsNone(reference())
def testRepeatedFinishedSignalPublishesOnlyOnce(self):
"""A completed reply cannot invoke hooks again before deferred deletion."""
manager = _CapturingHttpGetManager()
self.addCleanup(manager.deleteLater)
reply = manager.webGET('https://invalid.test', marker='fixture')
with patch.object(manager, 'successCallback') as completed:
reply.finished.emit()
reply.finished.emit()
completed.assert_called_once_with(reply, marker='fixture')
processQtEvents()
self.assertFalse(isValid(reply))
capabilityId = 'fixture.navigation'
class _NavigationProvider:
"""Return one valid page and one invalid parented QObject."""
def __init__(self):
"""Initialize construction counters used by idempotence assertions."""
self.validCalls = 0
self.invalidCalls = 0
self.invalidPage = None
def _validPage(self, parent=None):
"""Return one host-owned QWidget."""
self.validCalls += 1
return QWidget(parent)
def _invalidPage(self, parent=None):
"""Return one parented QObject that must be rejected and destroyed."""
self.invalidCalls += 1
self.invalidPage = QtCore.QObject(parent)
return self.invalidPage
def pageDescriptors(self):
"""Return deterministic page descriptors."""
return (
NavigationPageDescriptor(
'valid',
'Valid',
'valid.svg',
self._validPage,
),
NavigationPageDescriptor(
'invalid',
'Invalid',
'invalid.svg',
self._invalidPage,
),
)
class _NavigationRegistry:
"""Expose the registry subset consumed by PluginNavigationManager."""
def __init__(self, provider):
"""Store the only provider returned by this fixture."""
self.provider = provider
self.plugin = object()
def plugins(self):
"""Return one deterministic plugin token."""
return (self.plugin,)
def metadataFor(self, plugin):
"""Return stable plugin metadata."""
return SimpleNamespace(id='fixture')
def capabilities(self, kind, plugin):
"""Return navigation capability only for the requested plugin."""
if kind is CapabilityKind.NavigationPage and plugin is self.plugin:
return (self.provider,)
return tuple()
class _NavigationHost(QWidget):
"""Record pages registered through the host navigation API."""
def __init__(self):
"""Initialize an empty registration list."""
super().__init__()
self.registrations = []
def addPage(self, *args, **kwargs):
"""Record one page registration."""
self.registrations.append((args, kwargs))
class PluginNavigationManagerTest(unittest.TestCase):
"""Protect startup idempotence and invalid QObject cleanup."""
@classmethod
def setUpClass(cls):
"""Create the process-wide headless QApplication."""
application()
def testDestroyedFactoryResultsAreRejectedBeforeRegistration(self):
"""A retained Python wrapper is not proof of a valid plugin page."""
for pageType in (QtCore.QObject, QWidget):
with self.subTest(pageType=pageType):
page = pageType()
page.deleteLater()
processQtEvents()
provider = _NavigationProvider()
provider._invalidPage = lambda parent=None: page
host = _NavigationHost()
manager = PluginNavigationManager(_NavigationRegistry(provider))
try:
with self.assertLogs(
'Furious.Service.PluginUIManager', level='ERROR'
):
pages = manager.registerPages(host)
self.assertEqual(len(pages), 1)
self.assertEqual(len(host.registrations), 1)
finally:
host.deleteLater()
processQtEvents()
def testRegistrationIsIdempotentAndDeletesInvalidQObject(self):
"""Construct each descriptor once and destroy rejected Qt objects."""
provider = _NavigationProvider()
host = _NavigationHost()
manager = PluginNavigationManager(_NavigationRegistry(provider))
first, second = (
manager.registerPages(host),
manager.registerPages(host),
)
self.assertEqual(first, second)
self.assertEqual(provider.validCalls, 1)
self.assertEqual(provider.invalidCalls, 1)
self.assertEqual(len(host.registrations), 1)
collectAtBoundary()
self.assertFalse(isValid(provider.invalidPage))
host.deleteLater()
class _ConnectivityHomePage(HomePage):
"""Supply a native Home owner and its status publication without other services."""
statusPublished = QtCore.Signal()
def __init__(self):
QMainWindow.__init__(self)
def setNetworkState(self, _success, **_kwargs):
self.statusPublished.emit()
def resetNetworkState(self):
self.statusPublished.emit()
class ConnectivityManagerTest(unittest.TestCase):
"""Verify one bounded request is active at a time without live networking."""
@classmethod
def setUpClass(cls):
"""Create the process-wide headless QApplication."""
application()
def testHomeStatusPublicationCanDestroyTheConnectivityOwner(self):
"""Status/reset observers may delete the page and all network children."""
for boundary in ('success', 'failure', 'start', 'disconnect'):
with self.subTest(boundary=boundary):
for _ in range(20):
page = _ConnectivityHomePage()
manager = AppConnectivityManager(page)
reply = _ManagedReply(manager)
destroyed, errors = [], []
manager.destroyed.connect(lambda *_args: destroyed.append(True))
page.statusPublished.connect(lambda: deleteQObject(page))
try:
with patch('sys.excepthook', lambda *args: errors.append(args)):
if boundary in ('success', 'failure'):
with patch.object(
manager, 'get', lambda _request: reply
):
manager.webGET('https://invalid.test')
if boundary == 'failure':
reply.setError(
QNetworkReply.NetworkError.UnknownNetworkError,
'fixture',
)
reply.finished.emit()
elif boundary == 'start':
with patch(
'Furious.Window.HomePage.AppConnectionController',
return_value=SimpleNamespace(
isConnected=lambda: False
),
):
manager.jobArrangeTimer.timeout.emit()
else:
manager.disconnectedCallback()
processQtEvents()
self.assertFalse(errors)
self.assertEqual(destroyed, [True])
self.assertFalse(isValid(page))
self.assertFalse(isValid(manager))
self.assertFalse(isValid(reply))
self.assertFalse(isValid(manager.jobTimeoutTimer))
self.assertFalse(isValid(manager.jobArrangeTimer))
finally:
if isValid(page):
deleteQObject(page)
def testDestroyedActiveReplyReleasesProbeAndAllowsAnotherRequest(self):
"""Native deletion without finished must not strand the probe scheduler."""
manager = ConnectivityManager()
self.addCleanup(manager.deleteLater)
references = []
destroyed = []
with patch(
'Furious.Service.ConnectivityManager.AppSettings.get', return_value=None
):
for _ in range(30):
manager._testingEnabled = True
reply = _ManagedReply(manager)
references.append(weakref.ref(reply))
reply.destroyed.connect(lambda *_args: destroyed.append(True))
requests = []
def get(request):
requests.append(request)
return reply
with patch.object(manager, 'get', get):
manager.startSingleTest()
manager.startSingleTest()
self.assertEqual(len(requests), 1)
deleteQObject(reply)
self.assertIsNone(manager._activeReply)
self.assertFalse(manager._replyContexts)
self.assertFalse(manager.jobTimeoutTimer.isActive())
self.assertTrue(manager.jobArrangeTimer.isActive())
manager.stopTest()
del reply
self.assertEqual(len(destroyed), 30)
self.assertTrue(all(reference() is None for reference in references))
def testOlderReplyDestructionDoesNotRetireTheCurrentProbe(self):
"""A deferred delete from a completed request cannot clear its replacement."""
manager = ConnectivityManager()
self.addCleanup(manager.deleteLater)
manager._testingEnabled = True
oldReply = _ManagedReply(manager)
currentReply = _ManagedReply(manager)
with (
patch.object(manager, 'get', side_effect=[oldReply, currentReply]),
patch(
'Furious.Service.ConnectivityManager.AppSettings.get', return_value=None
),
):
manager.startSingleTest()
oldReply.finished.emit()
manager.startSingleTest()
deleteQObject(oldReply)
self.assertIs(manager._activeReply, currentReply)
self.assertTrue(manager.jobTimeoutTimer.isActive())
manager.stopTest()
def testStopAfterNativeReplyDestructionDoesNotAccessDeletedWrapper(self):
"""Disconnect after reply deletion is harmless even with a retained wrapper."""
manager = ConnectivityManager()
self.addCleanup(manager.deleteLater)
manager._testingEnabled = True
reply = _ManagedReply(manager)
with (
patch.object(manager, 'get', return_value=reply),
patch(
'Furious.Service.ConnectivityManager.AppSettings.get', return_value=None
),
):
manager.startSingleTest()
deleteQObject(reply)
manager.stopTest()
self.assertIsNone(manager._activeReply)
self.assertFalse(manager.jobTimeoutTimer.isActive())
self.assertFalse(manager.jobArrangeTimer.isActive())
def testRapidStartsReuseOneActiveRequest(self):
"""Do not accumulate probes or timeout timers during rapid calls."""
manager = ConnectivityManager()
reply = _ManagedReply(manager)
manager._testingEnabled = True
with (
patch.object(manager, 'webGET', return_value=reply) as webGet,
patch(
'Furious.Service.ConnectivityManager.AppSettings.get', return_value=None
),
):
manager.startSingleTest()
manager.startSingleTest()
webGet.assert_called_once()
self.assertIs(manager._activeReply, reply)
self.assertTrue(manager.jobTimeoutTimer.isActive())
manager.successCallback(reply)
self.assertIsNone(manager._activeReply)
self.assertFalse(manager.jobTimeoutTimer.isActive())
self.assertTrue(manager.jobArrangeTimer.isActive())
manager.stopTest()
manager.deleteLater()
class TrafficStatsManagerTest(unittest.TestCase):
"""Verify blocked queries and reentrant notifications respect manager lifetime."""
def testCollectionEnableDoesNotResumeAfterProviderEndsItsOwner(self):
"""Monitor discovery may destroy, disconnect, or disable its requester."""
for action in ('destroy', 'disconnect', 'disable'):
with self.subTest(action=action):
manager = TrafficStatsManager()
manager._connected = True
manager._collectionEnabled = False
monitor = TrafficStatsMonitor(
query=lambda _target: TrafficCounters(1, 2), target=None
)
def resolveMonitor(_runtimes):
if action == 'destroy':
deleteQObject(manager)
elif action == 'disconnect':
manager.disconnectedCallback()
else:
manager.setCollectionEnabled(False)
return monitor
registry = SimpleNamespace(
trafficStatsMonitorForRuntimes=resolveMonitor
)
try:
with patch(
'Furious.Service.TrafficStatsManager.getPluginRegistry',
return_value=registry,
):
manager.setCollectionEnabled(True)
self.assertIsNone(manager._monitor)
self.assertIsNone(manager._executor)
if isValid(manager):
self.assertFalse(manager._sampleTimer.isActive())
finally:
if isValid(manager):
manager.cleanup()
deleteQObject(manager)
def testNativeOwnerDestructionClosesStatisticsExecutor(self):
"""Retaining an invalid wrapper must not leave its worker thread running."""
for blocked in (False, True):
with self.subTest(blocked=blocked):
for _ in range(20):
parent = QtCore.QObject()
manager = TrafficStatsManager(parent)
started = threading.Event()
release = threading.Event()
updates = []
callbackErrors = []
def query(_target):
started.set()
if blocked:
release.wait(3)
return TrafficCounters(1, 2)
manager.sampleChanged.connect(updates.append)
manager._activateMonitor(
TrafficStatsMonitor(query=query, target=None)
)
executor = manager._executor
try:
self.assertTrue(started.wait(1))
threads = tuple(executor._threads)
updates.clear()
with patch(
'sys.excepthook', lambda *args: callbackErrors.append(args)
):
deleteQObject(parent)
self.assertFalse(isValid(manager))
self.assertFalse(isValid(manager._sampleTimer))
self.assertIsNone(manager._executor)
self.assertIsNone(manager._future)
self.assertIsNone(manager._monitor)
self.assertTrue(executor._shutdown)
release.set()
for thread in threads:
thread.join(2)
processQtEvents()
self.assertFalse(callbackErrors)
self.assertFalse(updates)
self.assertTrue(
all(not thread.is_alive() for thread in threads)
)
finally:
release.set()
executor.shutdown(wait=True, cancel_futures=True)
if isValid(parent):
deleteQObject(parent)
def testReconnectPreparationStopsAfterResetOrProviderEndsItsOwner(self):
"""Do not reactivate statistics after reentrant disconnect or destruction."""
application()
for boundary in ('reset', 'provider'):
for action in ('destroy', 'disconnect'):
with self.subTest(boundary=boundary, action=action):
for _ in range(20):
manager = TrafficStatsManager()
manager._hasConnected = True
manager._collectionEnabled = True
def endConnection(*_args):
if action == 'destroy':
deleteQObject(manager)
else:
manager.disconnectedCallback()
registry = SimpleNamespace(
trafficStatsMonitorForRuntimes=Mock(return_value=None)
)
if boundary == 'reset':
manager.usageHistoryReset.connect(endConnection)
else:
monitor = TrafficStatsMonitor(
query=lambda _target: TrafficCounters(1, 2), target=None
)
def resolveMonitor(_runtimes):
endConnection()
return monitor
registry.trafficStatsMonitorForRuntimes.side_effect = (
resolveMonitor
)
try:
with (
patch.object(
manager,
'_clearUsageOnReconnectEnabled',
return_value=True,
),
patch(
'Furious.Service.TrafficStatsManager.getPluginRegistry',
return_value=registry,
),
patch('sys.excepthook') as qtErrors,
):
manager.connectedCallback()
processQtEvents()
qtErrors.assert_not_called()
if boundary == 'reset':
registry.trafficStatsMonitorForRuntimes.assert_not_called()
if action == 'destroy':
self.assertFalse(isValid(manager))
else:
self.assertFalse(manager._connected)
self.assertFalse(manager._sampleTimer.isActive())
self.assertIsNone(manager._monitor)
self.assertIsNone(manager._executor)
finally:
if isValid(manager):
manager.cleanup()
deleteQObject(manager)
def testSamplePublicationStopsWhenAnObserverDestroysOrDisconnectsManager(self):
"""A signal listener can end this sample before later signals are emitted."""
application()
signalNames = (
'usageHistoryReset',
'usageChanged',
'speedChanged',
'sampleChanged',
)
for signalName in signalNames[:-1]:
for action in ('destroy', 'disconnect'):
with self.subTest(signal=signalName, action=action):
for _ in range(20):
manager = TrafficStatsManager()
delivered = []
with patch.object(
manager, '_clearUsageOnReconnectEnabled', return_value=True
):
manager._consumeResult(
manager._generation, TrafficCounters(100, 100), 1.0
)
for name in signalNames:
getattr(manager, name).connect(
lambda *_args, _name=name: delivered.append(_name)
)
def endSample(*_args):
if action == 'destroy':
deleteQObject(manager)
else:
manager.disconnectedCallback()
getattr(manager, signalName).connect(endSample)
try:
with patch('sys.excepthook') as qtErrors:
manager._sampleReady.emit(
manager._generation,
TrafficCounters(10, 10),
2.0,
)
processQtEvents()
qtErrors.assert_not_called()
expected = signalNames[
: signalNames.index(signalName) + 1
]
self.assertEqual(delivered, list(expected))
if action == 'destroy':
self.assertFalse(isValid(manager))
self.assertFalse(isValid(manager._sampleTimer))
else:
self.assertIsNone(manager._previousCounters)
self.assertFalse(manager._sampleTimer.isActive())
finally:
if isValid(manager):
manager.cleanup()
deleteQObject(manager)
@classmethod
def setUpClass(cls):
"""Create the process-wide headless QApplication."""
application()
def testBlockedQueryCompletionCallbackDoesNotRetainManager(self):
"""Keep a running plugin call detached from the manager's lifetime."""
started = threading.Event()
release = threading.Event()
def query(_target):
"""Block until the test has checked the manager weak reference."""
started.set()
release.wait(2)
return TrafficCounters(uplink=1, downlink=2)
manager = TrafficStatsManager()
manager._activateMonitor(TrafficStatsMonitor(query=query, target=None))
self.assertTrue(started.wait(1))
reference = weakref.ref(manager)
manager.cleanup()
manager.deleteLater()
del manager
collectAtBoundary()
try:
self.assertTrue(waitFor(lambda: reference() is None))
finally:
release.set()
if __name__ == '__main__':
unittest.main()