From 45f22d3c6f44a63b150addbdde446dbb9bd7ccee Mon Sep 17 00:00:00 2001 From: Loren Eteval Date: Sun, 23 Aug 2026 11:26:08 +0800 Subject: [PATCH] Bound long-lived service retention Signed-off-by: Loren Eteval --- Furious/Application/DesktopApplication.py | 53 +++++++-- Furious/Frozenlib/Utility.py | 6 +- Furious/Qt/HttpGetManager.py | 16 ++- Furious/Qt/QtGui.py | 6 +- Furious/Qt/QtWidgets.py | 2 +- Furious/Service/AGENTS.md | 9 ++ Furious/Service/LogManager.py | 131 ++++++++++++++++++++-- Furious/Service/MetricsHistory.py | 17 ++- Furious/Service/SubscriptionManager.py | 30 +++-- Furious/Window/LogPage.py | 8 +- tests/test_frozenlib.py | 18 +++ tests/test_models_and_services.py | 42 +++++++ tests/test_service_runtime.py | 58 +++++++++- tests/test_subscription_manager.py | 26 ++++- 14 files changed, 373 insertions(+), 49 deletions(-) diff --git a/Furious/Application/DesktopApplication.py b/Furious/Application/DesktopApplication.py index 5135450..29e35c0 100644 --- a/Furious/Application/DesktopApplication.py +++ b/Furious/Application/DesktopApplication.py @@ -234,6 +234,11 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): self.mainWindow = None self.systemTray = None + + self._loggingHandlers = tuple() + self._loggingRootLevel = None + self._loggingRaiseExceptions = logging.raiseExceptions + self.connectionController = None self.routingController = None self.settingsController = None @@ -332,16 +337,47 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): fontFamily=self.customFontName, ) - logging.basicConfig( - format='[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s', - level=logging.INFO, - handlers=( - ApplicationLogHandler(self.logManager), - logging.StreamHandler(), - ), + formatter = logging.Formatter( + '[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s' ) + + rootLogger = logging.getLogger() + + self._loggingRootLevel = rootLogger.level + self._loggingHandlers = ( + ApplicationLogHandler(self.logManager), + logging.StreamHandler(), + ) + + rootLogger.setLevel(logging.INFO) + + for handler in self._loggingHandlers: + handler.setFormatter(formatter) + + rootLogger.addHandler(handler) + logging.raiseExceptions = False + self._cleanupStack.register('logging', self._cleanupLogging) + + def _cleanupLogging(self): + """Remove and close exactly the handlers owned by this application.""" + rootLogger = logging.getLogger() + + for handler in self._loggingHandlers: + rootLogger.removeHandler(handler) + + handler.close() + + self._loggingHandlers = tuple() + + if self._loggingRootLevel is not None: + rootLogger.setLevel(self._loggingRootLevel) + + self._loggingRootLevel = None + + logging.raiseExceptions = self._loggingRaiseExceptions + def addCustomFont(self): """Add custom font.""" fontFile = str(DATA_DIR / 'font' / 'CascadiaMono') @@ -731,6 +767,9 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): try: self.addStorage() except Exception: + # Any non-exit exceptions + + # Roll back resources acquired before storage. Mixins.CleanupOnExit.cleanupAll() raise diff --git a/Furious/Frozenlib/Utility.py b/Furious/Frozenlib/Utility.py index 7a04a35..249ff34 100644 --- a/Furious/Frozenlib/Utility.py +++ b/Furious/Frozenlib/Utility.py @@ -180,7 +180,7 @@ def classname(ob) -> str: return ob.__class__.__name__ -@functools.lru_cache(None) +@functools.lru_cache(1024) def isValidIPAddress(address) -> bool: """Return whether valid ip address.""" try: @@ -230,13 +230,13 @@ def runExternalCommand(*args, **kwargs): return subprocess.run(*args, **kwargs) -@functools.lru_cache(None) +@functools.lru_cache(256) def absolutePath(path) -> pathlib.Path: """Return the absolute path value used by the application.""" return pathlib.Path(path) if os.path.isabs(path) else ROOT_DIR / path -@functools.lru_cache(None) +@functools.lru_cache(128) def versionToValue(version: str) -> int: """Return the version to value value used by the application.""" diff --git a/Furious/Qt/HttpGetManager.py b/Furious/Qt/HttpGetManager.py index deca6bf..619853a 100644 --- a/Furious/Qt/HttpGetManager.py +++ b/Furious/Qt/HttpGetManager.py @@ -41,6 +41,7 @@ class HttpGetManager(AppQNetworkAccessManager): """Initialize the HttpGetManager.""" super().__init__(parent) + self.transferTimeout = max(int(kwargs.pop('transferTimeout', 60_000)), 1) self.actionMessage = actionMessage self.completionRunsOnce = kwargs.pop('completionRunsOnce', True) @@ -94,7 +95,7 @@ class HttpGetManager(AppQNetworkAccessManager): if isinstance(networkReply, QNetworkReply): self.handleReadyReadByNetworkReply( networkReply, - **self._replyContexts.get(id(networkReply), {}), + **self._replyContexts.get(networkReply, {}), ) @QtCore.Slot() @@ -105,7 +106,7 @@ class HttpGetManager(AppQNetworkAccessManager): if not isinstance(networkReply, QNetworkReply): return - kwargs = self._replyContexts.pop(id(networkReply), {}) + kwargs = self._replyContexts.pop(networkReply, {}) self.handleFinishedByNetworkReply(networkReply, **kwargs) @@ -159,13 +160,16 @@ class HttpGetManager(AppQNetworkAccessManager): def webGET(self, request: Union[QNetworkRequest, str], **kwargs) -> QNetworkReply: """Start an HTTP GET request managed by this instance.""" if isinstance(request, QNetworkRequest): - networkReply = self.get(request) + request = QNetworkRequest(request) else: - networkReply = self.get(QNetworkRequest(QtCore.QUrl(request))) + request = QNetworkRequest(QtCore.QUrl(request)) - key = id(networkReply) + if request.transferTimeout() <= 0: + request.setTransferTimeout(self.transferTimeout) - self._replyContexts[key] = dict(kwargs) + networkReply = self.get(request) + + self._replyContexts[networkReply] = dict(kwargs) networkReply.readyRead.connect(self._handleReadyRead) networkReply.finished.connect(self._handleFinished) diff --git a/Furious/Qt/QtGui.py b/Furious/Qt/QtGui.py index e26d3e9..0da5e46 100644 --- a/Furious/Qt/QtGui.py +++ b/Furious/Qt/QtGui.py @@ -73,13 +73,13 @@ def setIconAsMask(icon): return icon -@functools.lru_cache(None) +@functools.lru_cache(256) def bootstrapIconMask(name): """Return the bootstrap icon mask value used by the application.""" return setIconAsMask(bootstrapIconWhite(name)) -@functools.lru_cache(None) +@functools.lru_cache(256) def bootstrapIconWithOpacity(name, opacity, isMask=False): """Return the bootstrap icon with opacity value used by the application.""" sourceIcon = bootstrapIconWhite(name) @@ -223,7 +223,7 @@ class AppQAction(Mixins.QTranslatable, Mixins.ThemeAware, QAction): return self.textEnglish == compare @staticmethod - @functools.lru_cache(None) + @functools.lru_cache(256) def getIconFileName(fileName): """Return icon file name.""" try: diff --git a/Furious/Qt/QtWidgets.py b/Furious/Qt/QtWidgets.py index 1440691..586b4bb 100644 --- a/Furious/Qt/QtWidgets.py +++ b/Furious/Qt/QtWidgets.py @@ -1962,7 +1962,7 @@ class AppQPushButton(Mixins.QTranslatable, Mixins.ThemeAware, QPushButton): self.setIcon(icon) @staticmethod - @functools.lru_cache(None) + @functools.lru_cache(256) def getIconFileName(fileName): """Return icon file name.""" try: diff --git a/Furious/Service/AGENTS.md b/Furious/Service/AGENTS.md index 473c640..f944bd3 100644 --- a/Furious/Service/AGENTS.md +++ b/Furious/Service/AGENTS.md @@ -25,6 +25,15 @@ independently; terminal reply paths abort or finish once and schedule deletion once. - Bound network, DNS, process, host, and worker work where the provider permits it. Executor callbacks use weak or otherwise bounded ownership and must not retain a manager forever after shutdown; GUI updates cross via signals. +- Log storage has independent count, total-character, and per-entry hard bounds even when automatic clearing is + disabled. High-rate producers must coalesce GUI notifications; hiding a page may defer rendering but must never defer + draining a process pipe or bounded transport queue. +- Metric history has both a time horizon and a defensive sample-count ceiling. Derive graph buckets on demand rather + than retaining a second ever-growing history. +- Every network reply has a finite transfer timeout unless a documented caller supplies a stricter one. Track replies + by exact object identity, remove every context on the terminal path, and schedule the reply for deletion exactly once. +- Per-subscription versions exist only while the persisted subscription, its timer, or an active reply needs them; + repeated create/delete cycles must not grow bookkeeping dictionaries. - Logging and metrics collect while pages are hidden but avoid hidden-page rendering. Raw time-series samples are immutable; stable timestamp buckets are derived display data. - Log retention is owned by `LogManager`, not a text document. Category pruning/reset notifications must keep lazy UI diff --git a/Furious/Service/LogManager.py b/Furious/Service/LogManager.py index b6bd180..166b909 100644 --- a/Furious/Service/LogManager.py +++ b/Furious/Service/LogManager.py @@ -57,10 +57,16 @@ class LogManager(QtCore.QObject): DefaultMaximumEntries = 10_000 DefaultAutoClearMaximumEntries = 5_000 + DefaultMaximumCharacters = 8 * 1024 * 1024 + DefaultMaximumEntryCharacters = 64 * 1024 + TruncationMarker = '\n... [log entry truncated]' categoryRegistered = QtCore.Signal(object) entryAdded = QtCore.Signal(object) entriesCleared = QtCore.Signal(object) + entriesChanged = QtCore.Signal(int) + + _entriesChangedRequested = QtCore.Signal() def __init__( self, @@ -69,6 +75,8 @@ class LogManager(QtCore.QObject): maximumEntries=DefaultMaximumEntries, autoClearMaximumEntries=DefaultAutoClearMaximumEntries, autoClearEnabled=True, + maximumCharacters=DefaultMaximumCharacters, + maximumEntryCharacters=DefaultMaximumEntryCharacters, ): """Initialize the category registry and thread-safe entry collection.""" super().__init__(parent) @@ -80,6 +88,23 @@ class LogManager(QtCore.QObject): ): raise ValueError('maximumEntries must be a positive integer') + if ( + isinstance(maximumCharacters, bool) + or not isinstance(maximumCharacters, int) + or maximumCharacters <= 0 + ): + raise ValueError('maximumCharacters must be a positive integer') + + if ( + isinstance(maximumEntryCharacters, bool) + or not isinstance(maximumEntryCharacters, int) + or maximumEntryCharacters <= 0 + ): + raise ValueError('maximumEntryCharacters must be a positive integer') + + if maximumEntryCharacters > maximumCharacters: + raise ValueError('maximumEntryCharacters cannot exceed maximumCharacters') + if ( isinstance(autoClearMaximumEntries, bool) or not isinstance(autoClearMaximumEntries, int) @@ -90,11 +115,20 @@ class LogManager(QtCore.QObject): self._lock = threading.RLock() self._categories: dict[str, LogCategory] = {} self._maximumEntries = maximumEntries + self._maximumCharacters = maximumCharacters + self._maximumEntryCharacters = maximumEntryCharacters self._autoClearMaximumEntries = autoClearMaximumEntries self._autoClearEnabled = bool(autoClearEnabled) - self._entries: deque[LogEntry] = deque(maxlen=maximumEntries) + self._entries: deque[LogEntry] = deque() self._categoryEntryCounts: dict[str, int] = {} + self._retainedCharacters = 0 self._sequence = 0 + self._changeNotificationPending = False + + self._entriesChangedRequested.connect( + self._publishEntriesChanged, + QtCore.Qt.ConnectionType.QueuedConnection, + ) self.registerCategory( LogCategory( @@ -124,6 +158,22 @@ class LogManager(QtCore.QObject): """Return the maximum number of structured entries retained in memory.""" return self._maximumEntries + @property + def maximumCharacters(self) -> int: + """Return the hard character budget for retained log messages.""" + return self._maximumCharacters + + @property + def maximumEntryCharacters(self) -> int: + """Return the maximum number of characters retained from one entry.""" + return self._maximumEntryCharacters + + @property + def retainedCharacters(self) -> int: + """Return the current retained message-character count in constant time.""" + with self._lock: + return self._retainedCharacters + @property def autoClearMaximumEntries(self) -> int: """Return the automatic-clear threshold for replaceable runtime logs.""" @@ -141,14 +191,73 @@ class LogManager(QtCore.QObject): def _removeCategoriesLocked(self, categoryIds: set[str]): """Remove selected categories while the caller owns ``_lock``.""" - self._entries = deque( - (entry for entry in self._entries if entry.categoryId not in categoryIds), - maxlen=self._maximumEntries, - ) + retainedEntries = deque() + retainedCharacters = 0 + + for entry in self._entries: + if entry.categoryId in categoryIds: + continue + + retainedEntries.append(entry) + retainedCharacters += len(entry.message) + + self._entries = retainedEntries + self._retainedCharacters = retainedCharacters for categoryId in categoryIds: self._categoryEntryCounts[categoryId] = 0 + def _removeOldestLocked(self): + """Remove and account for the oldest entry while holding the lock.""" + entry = self._entries.popleft() + + self._categoryEntryCounts[entry.categoryId] -= 1 + self._retainedCharacters -= len(entry.message) + + def _enforceRetentionLimitsLocked(self): + """Evict the oldest entries until every hard retention limit is met.""" + while self._entries and ( + len(self._entries) > self._maximumEntries + or self._retainedCharacters > self._maximumCharacters + ): + self._removeOldestLocked() + + def _normalizeMessage(self, message) -> str: + """Return one newline-trimmed message within the per-entry hard limit.""" + text = str(message).rstrip('\r\n') + + if len(text) <= self._maximumEntryCharacters: + return text + + marker = self.TruncationMarker + + if len(marker) >= self._maximumEntryCharacters: + return text[: self._maximumEntryCharacters] + + return text[: self._maximumEntryCharacters - len(marker)] + marker + + def _requestEntriesChanged(self): + """Queue at most one cross-thread presentation notification.""" + shouldNotify = False + + with self._lock: + if not self._changeNotificationPending: + self._changeNotificationPending = True + + shouldNotify = True + + if shouldNotify: + self._entriesChangedRequested.emit() + + @QtCore.Slot() + def _publishEntriesChanged(self): + """Publish the newest sequence once on the manager's Qt thread.""" + with self._lock: + self._changeNotificationPending = False + sequence = self._sequence + + self.entriesChanged.emit(sequence) + def setAutoClearEnabled(self, enabled: bool): """Apply Core-triggered clearing without involving presentation state.""" clearedCategoryIds = set() @@ -257,7 +366,7 @@ class LogManager(QtCore.QObject): self._sequence += 1 entry = LogEntry( - message=str(message).rstrip('\r\n'), + message=self._normalizeMessage(message), timestamp=timestamp, categoryId=category.id, categoryLabel=category.displayName, @@ -280,19 +389,18 @@ class LogManager(QtCore.QObject): self._removeCategoriesLocked(clearedCategoryIds) - if len(self._entries) == self._maximumEntries: - evicted = self._entries[0] - - self._categoryEntryCounts[evicted.categoryId] -= 1 - self._entries.append(entry) self._categoryEntryCounts[entry.categoryId] += 1 + self._retainedCharacters += len(entry.message) + self._enforceRetentionLimitsLocked() if clearedCategoryIds: self.entriesCleared.emit(frozenset(clearedCategoryIds)) self.entryAdded.emit(entry) + self._requestEntriesChanged() + return entry def callback( @@ -368,6 +476,7 @@ class LogManager(QtCore.QObject): changed = bool(self._entries) self._entries.clear() + self._retainedCharacters = 0 for registeredCategoryId in self._categoryEntryCounts: self._categoryEntryCounts[registeredCategoryId] = 0 diff --git a/Furious/Service/MetricsHistory.py b/Furious/Service/MetricsHistory.py index 5ab80ab..9e087f6 100644 --- a/Furious/Service/MetricsHistory.py +++ b/Furious/Service/MetricsHistory.py @@ -82,6 +82,7 @@ class MetricsHistory(QtCore.QObject): MaximumHistorySeconds = 24 * 60 * 60 AutoBucketTarget = 120 + MaximumSampleCount = 50_000 AutoGranularities = ( 1, 2, @@ -98,7 +99,13 @@ class MetricsHistory(QtCore.QObject): 60 * 60, ) - def __init__(self, parent=None, *, maximumHistorySeconds=None): + def __init__( + self, + parent=None, + *, + maximumHistorySeconds=None, + maximumSampleCount=None, + ): """Initialize generic metric definitions and bounded sample storage.""" super().__init__(parent) @@ -107,6 +114,9 @@ class MetricsHistory(QtCore.QObject): 1.0, ) self._samples = deque() + self._maximumSampleCount = max( + int(maximumSampleCount or self.MaximumSampleCount), 1 + ) self._aggregations = {} self.registerMetric(DOWNLOAD_SPEED_METRIC, MEAN_AGGREGATION) @@ -242,7 +252,10 @@ class MetricsHistory(QtCore.QObject): """Remove samples older than the configured in-memory history.""" oldestAllowed = now - self._maximumHistorySeconds - while self._samples and self._samples[0].sampledAt < oldestAllowed: + while self._samples and ( + self._samples[0].sampledAt < oldestAllowed + or len(self._samples) > self._maximumSampleCount + ): self._samples.popleft() def effectiveGranularity(self, rangeSeconds, granularitySeconds=0) -> float: diff --git a/Furious/Service/SubscriptionManager.py b/Furious/Service/SubscriptionManager.py index 6bf136a..66939e5 100644 --- a/Furious/Service/SubscriptionManager.py +++ b/Furious/Service/SubscriptionManager.py @@ -127,6 +127,17 @@ class SubscriptionManager(HttpGetManager): return version + def _pruneRequestVersion(self, unique: str): + """Forget version state once no subscription resource needs it.""" + if ( + unique in Storage.UserSubs() + or unique in self._autoUpdateTimers + or unique in self._replySubscriptions.values() + ): + return + + self._requestVersions.pop(unique, None) + def _isCurrentRequest(self, kwargs) -> bool: """Return whether one completion still targets the current subscription.""" version = kwargs.get('requestVersion') @@ -230,14 +241,18 @@ class SubscriptionManager(HttpGetManager): timer.stop() timer.deleteLater() + self._pruneRequestVersion(unique) + @QtCore.Slot() def _releaseFinishedReply(self): """Forget one exact subscription reply after its completion is dispatched.""" reply = self.sender() - key = id(reply) + unique = self._replySubscriptions.pop(reply, '') - self._activeReplies.pop(key, None) - self._replySubscriptions.pop(key, None) + self._activeReplies.pop(reply, None) + + if unique: + self._pruneRequestVersion(unique) def cancelUpdates(self, unique: str | None = None): """Cancel exact active replies and invalidate their eventual completions.""" @@ -252,8 +267,8 @@ class SubscriptionManager(HttpGetManager): for subscriptionId in subscriptions: self._nextRequestVersion(subscriptionId) - for key, reply in tuple(self._activeReplies.items()): - if unique is None or self._replySubscriptions.get(key) == unique: + for reply in tuple(self._activeReplies): + if unique is None or self._replySubscriptions.get(reply) == unique: reply.abort() def _synchronizeProfiles(self, unique: str, profiles): @@ -474,10 +489,9 @@ class SubscriptionManager(HttpGetManager): ) reply = self.webGET(request, logActionMessage=logActionMessage, **kwargs) - key = id(reply) - self._activeReplies[key] = reply - self._replySubscriptions[key] = str(kwargs.get('unique', '')) + self._activeReplies[reply] = reply + self._replySubscriptions[reply] = str(kwargs.get('unique', '')) reply.finished.connect(self._releaseFinishedReply) diff --git a/Furious/Window/LogPage.py b/Furious/Window/LogPage.py index c5b1cc7..c3234bd 100644 --- a/Furious/Window/LogPage.py +++ b/Furious/Window/LogPage.py @@ -344,7 +344,7 @@ class LogPage(Mixins.QTranslatable, QMainWindow): self.autoScrollSwitch.toggled.connect(self._autoScrollChanged) self.autoClearSwitch.toggled.connect(self._autoClearChanged) self.manager.categoryRegistered.connect(self._categoryRegistered) - self.manager.entryAdded.connect(self._entryAdded) + self.manager.entriesChanged.connect(self._entriesChanged) self.manager.entriesCleared.connect(self._entriesCleared) scrollbar = self.textBrowser.verticalScrollBar() @@ -782,10 +782,10 @@ class LogPage(Mixins.QTranslatable, QMainWindow): self.filterComboBox.findData(category.id) ) - @QtCore.Slot(object) - def _entryAdded(self, entry): + @QtCore.Slot(int) + def _entriesChanged(self, sequence: int): """Coalesce newly collected entries into the visible presentation.""" - if entry.sequence <= self._renderedSequence: + if sequence <= self._renderedSequence: return self._requestRefresh() diff --git a/tests/test_frozenlib.py b/tests/test_frozenlib.py index 4fad6e9..c8edb1a 100644 --- a/tests/test_frozenlib.py +++ b/tests/test_frozenlib.py @@ -283,6 +283,24 @@ class FrozenlibUtilityTest(unittest.TestCase): ('2001:db8::1', '8443'), ) + def testExternallyKeyedUtilityCachesAreBounded(self): + """Prevent address, path, and version inputs from growing global caches.""" + cachedFunctions = ( + UtilityModule.isValidIPAddress, + UtilityModule.absolutePath, + UtilityModule.versionToValue, + ) + + for function in cachedFunctions: + function.cache_clear() + + for index in range(function.cache_info().maxsize + 25): + function(str(index)) + + info = function.cache_info() + self.assertIsNotNone(info.maxsize) + self.assertLessEqual(info.currsize, info.maxsize) + def testTcpingUsesResolverIPv6AndFallsBackToIPv4(self): """Use getaddrinfo candidates rather than IPv4-only gethostbyname.""" candidates = [ diff --git a/tests/test_models_and_services.py b/tests/test_models_and_services.py index fb519d3..4ee29ff 100644 --- a/tests/test_models_and_services.py +++ b/tests/test_models_and_services.py @@ -450,6 +450,34 @@ class LogManagerTest(unittest.TestCase): self.assertEqual(manager.entryCount(TUN2SOCKS_LOG_CATEGORY), 0) self.assertEqual(manager.entryCount(APPLICATION_LOG_CATEGORY), 1) + def testCharacterBudgetsRemainHardWhenAutoClearIsDisabled(self): + """Bound both total text and one hostile entry independently of count.""" + manager = LogManager( + maximumEntries=100, + maximumCharacters=80, + maximumEntryCharacters=32, + autoClearEnabled=False, + ) + + manager.append('a' * 100, CORE_LOG_CATEGORY) + + self.assertEqual(len(manager.entries()[0].message), 32) + self.assertIn('truncated', manager.entries()[0].message) + + for index in range(20): + manager.append(f'{index:02d}-' + ('x' * 17), CORE_LOG_CATEGORY) + + self.assertLessEqual(manager.retainedCharacters, 80) + self.assertLessEqual(manager.entryCount(), 4) + self.assertEqual( + manager.retainedCharacters, + sum(len(entry.message) for entry in manager.entries()), + ) + self.assertEqual( + manager.entryCount(CORE_LOG_CATEGORY), + manager.entryCount(), + ) + class MetricsHistoryTest(unittest.TestCase): """Verify bounded history and metric-specific aggregation semantics.""" @@ -522,6 +550,20 @@ class MetricsHistoryTest(unittest.TestCase): self.assertEqual(manager.rawSamples(), tuple()) self.assertEqual(changed, []) + def testDefensiveSampleCeilingBoundsBurstCadence(self): + """Retain only the newest samples even when time does not advance.""" + manager = MetricsHistory( + maximumHistorySeconds=24 * 60 * 60, + maximumSampleCount=25, + ) + + for index in range(1000): + manager.recordSample({DOWNLOAD_SPEED_METRIC: index}, sampledAt=1) + + self.assertEqual(manager.sampleCount(), 25) + self.assertEqual(manager.rawSamples()[0].values[DOWNLOAD_SPEED_METRIC], 975) + self.assertEqual(manager.rawSamples()[-1].values[DOWNLOAD_SPEED_METRIC], 999) + class TranslationExtractorTest(unittest.TestCase): """Verify static translation extraction and constant interpolation.""" diff --git a/tests/test_service_runtime.py b/tests/test_service_runtime.py index d877ee4..97ad51f 100644 --- a/tests/test_service_runtime.py +++ b/tests/test_service_runtime.py @@ -26,11 +26,13 @@ from Furious.Plugins import ( TrafficStatsMonitor, ) from Furious.Service.ConnectivityManager import ConnectivityManager +from Furious.Qt.HttpGetManager import HttpGetManager from Furious.Service.PluginUIManager import PluginNavigationManager from Furious.Service.TrafficStatsManager import TrafficStatsManager from Furious.Service.UpdateManager import UpdateManager from PySide6 import QtCore +from PySide6.QtNetwork import QNetworkReply from PySide6.QtWidgets import QWidget from shiboken6 import isValid @@ -117,11 +119,63 @@ class UpdateManagerTest(unittest.TestCase): parent.deleteLater() -class _NavigationProvider: - """Return one valid page and one invalid parented QObject.""" +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.""" + + @classmethod + def setUpClass(cls): + application() + + 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() 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 diff --git a/tests/test_subscription_manager.py b/tests/test_subscription_manager.py index 45670e3..93307f5 100644 --- a/tests/test_subscription_manager.py +++ b/tests/test_subscription_manager.py @@ -214,10 +214,10 @@ class SubscriptionManagerTest(TestCase): groupBReply = _AbortableReply() manager._requestVersions.update({'group-a': 1, 'group-b': 4}) manager._activeReplies.update( - {id(groupAReply): groupAReply, id(groupBReply): groupBReply} + {groupAReply: groupAReply, groupBReply: groupBReply} ) manager._replySubscriptions.update( - {id(groupAReply): 'group-a', id(groupBReply): 'group-b'} + {groupAReply: 'group-a', groupBReply: 'group-b'} ) manager.cancelUpdates('group-a') @@ -264,3 +264,25 @@ class SubscriptionManagerTest(TestCase): self.assertEqual(manager._autoUpdateTimers, {}) self.assertFalse(timer.isActive()) manager.deleteLater() + + def testDeletedSubscriptionVersionIsPrunedAfterItsReplyFinishes(self): + """Release stale-request bookkeeping after the last exact owner ends.""" + manager = self._manager() + reply = _AbortableReply() + manager._requestVersions['deleted-group'] = 4 + manager._activeReplies[reply] = reply + manager._replySubscriptions[reply] = 'deleted-group' + + with mock.patch( + 'Furious.Service.SubscriptionManager.Storage.UserSubs', + return_value={}, + ): + manager._pruneRequestVersion('deleted-group') + self.assertIn('deleted-group', manager._requestVersions) + + manager._activeReplies.pop(reply) + manager._replySubscriptions.pop(reply) + manager._pruneRequestVersion('deleted-group') + + self.assertNotIn('deleted-group', manager._requestVersions) + manager.deleteLater()