diff --git a/Furious/Backends/Hysteria1/Editor.py b/Furious/Backends/Hysteria1/Editor.py index 86595d3..8a7996c 100644 --- a/Furious/Backends/Hysteria1/Editor.py +++ b/Furious/Backends/Hysteria1/Editor.py @@ -221,6 +221,7 @@ class GuiHy1ItemTLSInsecure(GuiEditorItemTextSwitch): config['insecure'] = False return True + if oldChecked is False: return False diff --git a/Furious/Backends/Xray/Plugin.py b/Furious/Backends/Xray/Plugin.py index 02bff7f..95471f2 100644 --- a/Furious/Backends/Xray/Plugin.py +++ b/Furious/Backends/Xray/Plugin.py @@ -61,6 +61,7 @@ def fixLogObjectPath(config, attr: str, value: str, log=True): config['log'][attr] = absolutePath(path) result = config['log'][attr] + if result: try: with open(result, 'x', encoding='utf-8'): diff --git a/Furious/Backends/Xray/Stats.py b/Furious/Backends/Xray/Stats.py index ad37c99..b53b32b 100644 --- a/Furious/Backends/Xray/Stats.py +++ b/Furious/Backends/Xray/Stats.py @@ -133,6 +133,7 @@ def configureXrayStats( config['api'] = currentApi configuredServer = currentApi.get('listen') + if not isinstance(configuredServer, str) or not configuredServer.strip(): configuredServer = None @@ -143,6 +144,7 @@ def configureXrayStats( currentApi['listen'] = apiServer services = currentApi.get('services', []) + if not isinstance(services, list): services = [] @@ -159,11 +161,13 @@ def configureXrayStats( config['stats'] = generated['stats'] policy = config.get('policy') + if not isinstance(policy, dict): policy = {} config['policy'] = policy systemPolicy = policy.get('system') + if not isinstance(systemPolicy, dict): systemPolicy = {} policy['system'] = systemPolicy diff --git a/Furious/Plugins/Registry.py b/Furious/Plugins/Registry.py index 40d917e..34eeb0a 100644 --- a/Furious/Plugins/Registry.py +++ b/Furious/Plugins/Registry.py @@ -420,6 +420,7 @@ class PluginRegistry: for configType in configurationTypes: self._configurationFactories[configType] = entry + for runtimeType in runtimeTypes: self._runtimeFactories[runtimeType] = entry elif isinstance(capability, TrafficStatsProvider): diff --git a/tests/fixtures/editor_lifetime_probe.py b/tests/fixtures/editor_lifetime_probe.py index 865838c..fa41828 100644 --- a/tests/fixtures/editor_lifetime_probe.py +++ b/tests/fixtures/editor_lifetime_probe.py @@ -272,6 +272,7 @@ class _RequestPayload: def runNetworkProbe(iterations=100): """Verify native teardown releases both reply registries under compilation.""" application() + result = {} for managerType, contextAttribute in ( @@ -286,8 +287,10 @@ def runNetworkProbe(iterations=100): 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): @@ -306,6 +309,7 @@ def runNetworkProbe(iterations=100): manager.deleteLater() processQtEvents() + assert not isValid(reply) assert not getattr(manager, contextAttribute) @@ -317,6 +321,7 @@ def runNetworkProbe(iterations=100): assert len(destroyed) == iterations assert all(reference() is None for reference in references) + result[managerType.__name__ + ':' + terminal] = iterations return result @@ -325,18 +330,22 @@ def runNetworkProbe(iterations=100): def runInfrastructureProbe(iterations=100): """Check signal, mask, and animation ownership under real Qt destruction.""" application() + result = {} for closeMethod in CLOSE_METHODS: destroyed = [] + for _ in range(iterations): dialog = AppQDialog() dialog.destroyed.connect(lambda *_args: destroyed.append(True)) reference = weakref.ref(dialog) key = dialog._lifetimeKey + dialog.open() getattr(dialog, closeMethod)() dialog.open() + del dialog processQtEvents() @@ -346,37 +355,49 @@ def runInfrastructureProbe(iterations=100): getattr(reference(), closeMethod)() processQtEvents() + assert reference() is None assert key not in AppQDialog._openDialogs assert len(destroyed) == iterations + result['reopenAfter' + closeMethod.title()] = iterations routingReferences = [] routingDestroyed = [] + for _ in range(iterations): dialog = RoutingRulesDialog({'rules': [{'ruleTag': 'keep'}]}) dialog.open() dialog.listView.setCurrentIndex(dialog.listView.rulesModel.index(0, 0)) + dialog.deleteRule() + confirmation = next( item for item in AppQDialog._openDialogs.values() if isinstance(item, AppQMessageBox) ) + for item in (dialog, confirmation): routingReferences.append(weakref.ref(item)) item.destroyed.connect(lambda *_args: routingDestroyed.append(True)) + del item + dialog.deleteLater() processQtEvents() + assert not isValid(dialog) and not isValid(confirmation) + del dialog, confirmation collectAtBoundary() + assert len(routingDestroyed) == iterations * 2 assert all(reference() is None for reference in routingReferences) assert not AppQDialog._openDialogs + result['routingOwnerFirst'] = iterations for senderFirst in (True, False): @@ -403,6 +424,7 @@ def runInfrastructureProbe(iterations=100): counts.append(survivor.receivers(QtCore.SIGNAL('destroyed(QObject*)'))) assert counts == [counts[0]] * iterations, counts + result['senderFirst' if senderFirst else 'receiverFirst'] = iterations survivor.deleteLater() @@ -421,6 +443,7 @@ def runInfrastructureProbe(iterations=100): ) transition.apply(lambda: None) + animation = next(iter(transition._animations)) overlay = window.findChild(QWidget, transition.OverlayObjectName) @@ -435,6 +458,7 @@ def runInfrastructureProbe(iterations=100): else: transition.deleteLater() processQtEvents() + window.deleteLater() processQtEvents() @@ -463,6 +487,7 @@ def runInfrastructureProbe(iterations=100): assert not isValid(mask) assert not AppQDialog._openDialogs + result['messageBoxDeleted'] = iterations finally: owner.deleteLater() @@ -474,63 +499,81 @@ def runInfrastructureProbe(iterations=100): 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 diff --git a/tests/test_log_manager_generation.py b/tests/test_log_manager_generation.py index 2bde100..31645b7 100644 --- a/tests/test_log_manager_generation.py +++ b/tests/test_log_manager_generation.py @@ -1528,6 +1528,7 @@ class VeryHeavyGenerationLogManagerTest(unittest.TestCase): return time.perf_counter_ns() - started matrix = [] + for size in (100, 1_000, 10_000): samples = { name: [] @@ -1544,13 +1545,16 @@ class VeryHeavyGenerationLogManagerTest(unittest.TestCase): 'oldest_eviction', ) } + for _repetition in range(5): manager = LogManager( maximumEntries=size * 2 + 10, autoClearMaximumEntries=size, ) + for index in range(size): manager.append(str(index), CORE_LOG_CATEGORY) + samples['normal_append'].append( elapsed(lambda: manager.append('ordinary')) ) @@ -1559,22 +1563,27 @@ class VeryHeavyGenerationLogManagerTest(unittest.TestCase): ) manager = LogManager(maximumEntries=size + 10, autoClearEnabled=False) + for index in range(size): manager.append(str(index), CORE_LOG_CATEGORY) + samples['category_snapshot'].append( elapsed(lambda: manager.snapshot(CORE_LOG_CATEGORY)) ) samples['runtime_clear'].append( elapsed(lambda: manager.clear(runtimeOnly=True)) ) + with manager._lock: samples['cleanup_64'].append( elapsed(lambda: manager._cleanupRetiredLocked()) ) manager = LogManager(maximumEntries=size + 10, autoClearEnabled=False) + for index in range(size): manager.append(str(index), APPLICATION_LOG_CATEGORY) + samples['sole_category_clear'].append( elapsed(lambda: manager.clear(APPLICATION_LOG_CATEGORY)) ) @@ -1583,14 +1592,17 @@ class VeryHeavyGenerationLogManagerTest(unittest.TestCase): maximumEntries=size * 2 + 10, autoClearEnabled=False, ) + for index in range(size): manager.append(str(index), CORE_LOG_CATEGORY) manager.append('shared', TUN2SOCKS_LOG_CATEGORY) + samples['shared_category_clear'].append( elapsed(lambda: manager.clear(CORE_LOG_CATEGORY)) ) manager = LogManager(maximumEntries=size + 10, autoClearEnabled=False) + for index in range(size): manager.append( str(index), @@ -1600,10 +1612,12 @@ class VeryHeavyGenerationLogManagerTest(unittest.TestCase): TUN2SOCKS_LOG_CATEGORY, )[index % 3], ) + samples['global_snapshot'].append(elapsed(lambda: manager.snapshot())) samples['clear_all'].append(elapsed(lambda: manager.clear())) manager = LogManager(maximumEntries=size, autoClearEnabled=False) + for index in range(size): manager.append( str(index), @@ -1613,6 +1627,7 @@ class VeryHeavyGenerationLogManagerTest(unittest.TestCase): TUN2SOCKS_LOG_CATEGORY, )[index % 3], ) + samples['oldest_eviction'].append( elapsed(lambda: manager.append('evict')) ) @@ -1632,6 +1647,7 @@ class VeryHeavyGenerationLogManagerTest(unittest.TestCase): for name in matrix[0] if name != 'n' } + for name in ( 'normal_append_us', 'core_rollover_us', @@ -1642,6 +1658,7 @@ class VeryHeavyGenerationLogManagerTest(unittest.TestCase): 'oldest_eviction_us', ): self.assertLess(ratios[name], 50) + self.report('operation-scaling-matrix', samples=matrix, ratios=ratios) def testAppendLatencyWithRolloverRetentionAndBacklog(self): @@ -1715,8 +1732,10 @@ class VeryHeavyGenerationLogManagerTest(unittest.TestCase): def testCleanupLatencyDoesNotScaleWithBacklog(self): """Keep append cleanup capped at one for geometrically larger queues.""" results = [] + for backlog in (64, 1_000, 10_000): samples = [] + for _repetition in range(9): manager = LogManager( maximumEntries=backlog + 10, @@ -1726,17 +1745,25 @@ class VeryHeavyGenerationLogManagerTest(unittest.TestCase): ) manager.RetiredCleanupBudget = 64 manager.AppendRetiredCleanupBudget = 1 + for index in range(backlog): manager.append(str(index), CORE_LOG_CATEGORY) + manager.clear(runtimeOnly=True) before = manager.retiredEntryCount + started = time.perf_counter_ns() manager.append('new') samples.append(time.perf_counter_ns() - started) + self.assertEqual(manager.retiredEntryCount, max(0, before - 1)) + results.append({'backlog': backlog, 'median_us': median(samples) / 1_000}) + ratio = results[-1]['median_us'] / max(results[0]['median_us'], 0.001) + self.assertLess(ratio, 20) + self.report('cleanup-backlog-latency', samples=results, endpoint_ratio=ratio) def testAdversarialClearRateCannotGrowPhysicalBacklog(self): @@ -1942,6 +1969,7 @@ class VeryHeavyGenerationLogManagerTest(unittest.TestCase): capture_output=True, text=True, ).stdout + baselineModule = types.ModuleType('tests._baseline_log_manager') previousModule = sys.modules.get(baselineModule.__name__) sys.modules[baselineModule.__name__] = baselineModule @@ -1959,47 +1987,60 @@ class VeryHeavyGenerationLogManagerTest(unittest.TestCase): def benchmark(managerClass): results = {} + manager = managerClass(maximumEntries=50_000, autoClearEnabled=False) + started = time.perf_counter_ns() for index in range(50_000): manager.append(str(index), APPLICATION_LOG_CATEGORY) + results['steady_append_ms'] = (time.perf_counter_ns() - started) / 1e6 manager = managerClass( maximumEntries=50_000, autoClearMaximumEntries=20_000, ) + for index in range(20_000): manager.append(str(index), CORE_LOG_CATEGORY) for index in range(20_000): manager.append(str(index), TUN2SOCKS_LOG_CATEGORY) + started = time.perf_counter_ns() manager.append('trigger', CORE_LOG_CATEGORY) results['core_rollover_us'] = (time.perf_counter_ns() - started) / 1e3 manager = managerClass(maximumEntries=50_000, autoClearEnabled=False) + for index in range(30_000): manager.append( str(index), CORE_LOG_CATEGORY if index % 5 == 0 else APPLICATION_LOG_CATEGORY, ) + started = time.perf_counter_ns() manager.snapshot(CORE_LOG_CATEGORY) results['category_snapshot_us'] = (time.perf_counter_ns() - started) / 1e3 + started = time.perf_counter_ns() manager.snapshot() results['global_snapshot_us'] = (time.perf_counter_ns() - started) / 1e3 manager = managerClass(maximumEntries=1_000, autoClearEnabled=False) + started = time.perf_counter_ns() for index in range(50_000): manager.append(str(index), APPLICATION_LOG_CATEGORY) + results['retention_heavy_ms'] = (time.perf_counter_ns() - started) / 1e6 + return results current = benchmark(LogManager) baseline = benchmark(baselineModule.LogManager) + self.assertEqual(LogManager(maximumEntries=1).entries(), tuple()) + self.report( 'baseline-comparison', baseline_revision=revision, diff --git a/tests/test_qt_lifetime.py b/tests/test_qt_lifetime.py index 169bcbd..350d11f 100644 --- a/tests/test_qt_lifetime.py +++ b/tests/test_qt_lifetime.py @@ -238,6 +238,7 @@ class QtLifetimeTest(unittest.TestCase): 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)) @@ -245,11 +246,13 @@ class QtLifetimeTest(unittest.TestCase): processQtEvents() self.assertFalse(isValid(action)) + try: self.assertEqual(isValid(menu), explicitOwner) finally: if isValid(menu): menu.deleteLater() + owner.deleteLater() processQtEvents() @@ -290,8 +293,10 @@ class QtLifetimeTest(unittest.TestCase): with self.subTest(platform=platform, family=family): references = [] destroyed = [] + for _ in range(20): parent = QWidget() + if family == 'servers': view = ServerTableView( parent=parent, @@ -308,6 +313,7 @@ class QtLifetimeTest(unittest.TestCase): view.setCurrentIndex(view.model().index(0, 0)) module = type(view).__module__ + with mock.patch(module + '.PLATFORM', platform): if family == 'assetOverwrite': view.appendNewItem(str(asset)) @@ -327,9 +333,11 @@ class QtLifetimeTest(unittest.TestCase): try: view.deleteLater() processQtEvents() + self.assertFalse(isValid(view)) self.assertFalse(isValid(confirmation)) self.assertTrue(isValid(parent)) + self.assertEqual(profiles, [profile]) self.assertIn('fixture', subscriptions) self.assertIn('fixture', routings) @@ -337,6 +345,7 @@ class QtLifetimeTest(unittest.TestCase): finally: if isValid(confirmation): confirmation.reject() + parent.deleteLater() processQtEvents() diff --git a/tests/test_service_runtime.py b/tests/test_service_runtime.py index aa9522a..d5c7272 100644 --- a/tests/test_service_runtime.py +++ b/tests/test_service_runtime.py @@ -186,6 +186,7 @@ class HttpGetManagerLifetimeTest(unittest.TestCase): 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)) @@ -202,6 +203,7 @@ class HttpGetManagerLifetimeTest(unittest.TestCase): self.assertFalse(isValid(reply)) self.assertFalse(getattr(manager, contextAttribute)) + del reply self.assertEqual(len(destroyed), 30) @@ -238,6 +240,7 @@ class HttpGetManagerLifetimeTest(unittest.TestCase): """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: @@ -245,7 +248,9 @@ class HttpGetManagerLifetimeTest(unittest.TestCase): reply.finished.emit() completed.assert_called_once_with(reply, marker='fixture') + processQtEvents() + self.assertFalse(isValid(reply)) capabilityId = 'fixture.navigation' diff --git a/tests/test_ui_behavior.py b/tests/test_ui_behavior.py index 0843bf6..25618b7 100644 --- a/tests/test_ui_behavior.py +++ b/tests/test_ui_behavior.py @@ -1655,20 +1655,26 @@ class UnifiedLogPageTest(unittest.TestCase): with isolatedSettings(): stack = QStackedWidget() pages = [LogPage(manager=LogManager(maximumEntries=3)) for _ in range(2)] + for page in pages: stack.addWidget(page) + self.addCleanup(stack.deleteLater) self.addCleanup(stack.close) self.addCleanup(QTest.keyRelease, stack, QtCore.Qt.Key_Control) + stack.show() stack.activateWindow() + for page in (pages[0], pages[1], pages[0]): stack.setCurrentWidget(page) page.textBrowser.setFocus() processQtEvents() + QTest.keyClick( page.textBrowser, QtCore.Qt.Key_F, QtCore.Qt.ControlModifier ) + self.assertTrue(waitFor(page.searchLineEdit.hasFocus)) def testLabelsOwnTheirDynamicTranslation(self):