diff --git a/Furious/Service/ConnectionManager.py b/Furious/Service/ConnectionManager.py index 8a9e13ed..8c5f16fd 100644 --- a/Furious/Service/ConnectionManager.py +++ b/Furious/Service/ConnectionManager.py @@ -769,6 +769,7 @@ class ConnectionStartOperation(QtCore.QObject): tcpSendBufferSize = settings.userTcpSendBufferSize() tcpReceiveBufferSize = settings.userTcpReceiveBufferSize() tcpAutoTuning = settings.userTcpAutoTuning() == 'True' + interfaceArg = ( APPLICATION_TUN2SOCKS_NETWORK_INTERFACE_NAME if PLATFORM != 'Linux' diff --git a/Furious/Widget/NavigationView.py b/Furious/Widget/NavigationView.py index 3e0f605d..b3b0ddc7 100644 --- a/Furious/Widget/NavigationView.py +++ b/Furious/Widget/NavigationView.py @@ -159,6 +159,7 @@ class NavigationView(Mixins.QTranslatable, Mixins.ThemeAware, QWidget): self.navigationLayout = QVBoxLayout(self.navigationPanel) self.navigationLayout.setContentsMargins(6, 8, 6, 8) self.navigationLayout.setSpacing(4) + self.navigationLayout.addWidget(self.toggleButton) self.pageButtonLayout = QVBoxLayout() @@ -190,6 +191,7 @@ class NavigationView(Mixins.QTranslatable, Mixins.ThemeAware, QWidget): self._layout = QHBoxLayout(self) self._layout.setContentsMargins(0, 0, 0, 0) self._layout.setSpacing(0) + self._layout.addWidget(self.navigationRail) self._layout.addWidget(self.pageStack, 1) diff --git a/Furious/Widget/ServerTableView.py b/Furious/Widget/ServerTableView.py index aa6cf21c..8533c5df 100644 --- a/Furious/Widget/ServerTableView.py +++ b/Furious/Widget/ServerTableView.py @@ -995,10 +995,12 @@ class ServerTableView( self.proxyModel = UserServersSortFilterProxyModel(parent=self) self.proxyModel.setSourceModel(self.sourceModel) + self.setModel(self.proxyModel) self.setIconSize(QtCore.QSize(16, 16)) self._sortSelectionSnapshot = None + self.proxyModel.sortAboutToStart.connect(self._captureSortSelection) self.proxyModel.sortCompleted.connect(self._restoreSortSelection) diff --git a/Furious/Window/LogPage.py b/Furious/Window/LogPage.py index e6ae7d04..ac7dfdef 100644 --- a/Furious/Window/LogPage.py +++ b/Furious/Window/LogPage.py @@ -245,6 +245,7 @@ class LogPage(Mixins.QTranslatable, QMainWindow): highlightLayout = QHBoxLayout(self.highlightOverlay) highlightLayout.setContentsMargins(16, 16, 16, 16) highlightLayout.setSpacing(10) + highlightLayout.addStretch(1) highlightLayout.addWidget(self.highlightSpinner) highlightLayout.addWidget(self.highlightStatusLabel) @@ -282,6 +283,7 @@ class LogPage(Mixins.QTranslatable, QMainWindow): filterLayout = QHBoxLayout() filterLayout.setContentsMargins(0, 0, 0, 0) filterLayout.setSpacing(8) + filterLayout.addWidget(self.pageTitleLabel) filterLayout.addStretch(1) filterLayout.addWidget(self.searchLineEdit, 3) @@ -303,6 +305,7 @@ class LogPage(Mixins.QTranslatable, QMainWindow): centralLayout = QVBoxLayout() centralLayout.setContentsMargins(20, 18, 20, 20) centralLayout.setSpacing(14) + centralLayout.addLayout(filterLayout) centralLayout.addWidget(self.emptyState) centralLayout.addWidget(self.textBrowser) @@ -422,6 +425,7 @@ class LogPage(Mixins.QTranslatable, QMainWindow): actionLayout = QHBoxLayout() actionLayout.setContentsMargins(0, 0, 0, 0) actionLayout.setSpacing(8) + actionLayout.addWidget(self.fileButton) actionLayout.addWidget(self.editButton) actionLayout.addWidget(self.viewButton) diff --git a/tests/benchmarks/benchmark_log_manager.py b/tests/benchmarks/benchmark_log_manager.py index 5dd00d2b..30ce421c 100644 --- a/tests/benchmarks/benchmark_log_manager.py +++ b/tests/benchmarks/benchmark_log_manager.py @@ -106,6 +106,7 @@ def run(entries=50_000): raise ValueError('entries must be an integer of at least 10') application() + measurements = {} with _manager(maximumEntries=entries, autoClearEnabled=False) as manager: @@ -121,6 +122,7 @@ def run(entries=50_000): with _manager(maximumEntries=entries, autoClearMaximumEntries=threshold) as manager: for index in range(threshold): manager.append(str(index), CORE_LOG_CATEGORY) + for index in range(threshold): manager.append(str(index), TUN_LOG_CATEGORY) diff --git a/tests/benchmarks/benchmark_qr_export.py b/tests/benchmarks/benchmark_qr_export.py index d6db0a83..09950819 100644 --- a/tests/benchmarks/benchmark_qr_export.py +++ b/tests/benchmarks/benchmark_qr_export.py @@ -155,6 +155,7 @@ def benchmarkWindow(mode: str, count: int, timeout: float): setupStarted = time.perf_counter() serverProfiles = profiles(count, registry) profileSetupDuration = time.perf_counter() - setupStarted + resetMilestones() window = TrackingQRCodeWindow() started = time.perf_counter() @@ -175,12 +176,14 @@ def benchmarkWindow(mode: str, count: int, timeout: float): 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, @@ -197,6 +200,7 @@ def benchmarkWindow(mode: str, count: int, timeout: float): else: window.startExportByIndex(range(count)) returnedAt = time.perf_counter() + pumpUntil( lambda: TrackingQRCodeWindow.finishedAt is not None, timeout, diff --git a/tests/fixtures/editor_lifetime_probe.py b/tests/fixtures/editor_lifetime_probe.py index e0502c30..a1fdb757 100644 --- a/tests/fixtures/editor_lifetime_probe.py +++ b/tests/fixtures/editor_lifetime_probe.py @@ -1633,6 +1633,7 @@ def runNetworkProbe(iterations=100): def runSubscriptionWorkerTeardownProbe(iterations=100): """Keep the relay alive through cancellation, then reclaim every wrapper.""" application() + references = [] destroyed = [] ownerThread = threading.get_ident() @@ -1642,18 +1643,23 @@ def runSubscriptionWorkerTeardownProbe(iterations=100): for _ in range(iterations): manager = SubscriptionManager() manager._preparationPool.setMaxThreadCount(1) + relay = manager._preparationRelay pool = manager._preparationPool + references.extend(weakref.ref(ob) for ob in (manager, relay, pool)) for ob in (manager, relay, pool): ob.destroyed.connect(lambda *_: destroyed.append(True)) + del ob manager._handleImportedResult = lambda *_: deliveries.append( threading.get_ident() ) + manager._startPreparationJob('import', {}, lambda _: object()) waitFor(lambda: not manager._preparationJobs) + assert deliveries[-1] == ownerThread started = threading.Event() @@ -1663,12 +1669,15 @@ def runSubscriptionWorkerTeardownProbe(iterations=100): def work(isCancelled): started.set() deadline = time.monotonic() + 2 + while not isCancelled() and time.monotonic() < deadline: threading.Event().wait(0.005) + observations.append((isCancelled(), isValid(relay))) manager._startPreparationJob('probe', {}, work) assert started.wait(2) + manager._startPreparationJob('probe', {}, lambda _: queuedStarted.set()) manager._preparationPayloads[1] = b'copied payload' @@ -1680,14 +1689,18 @@ def runSubscriptionWorkerTeardownProbe(iterations=100): assert not manager._preparationPayloads assert not manager._batches assert not isValid(pool) and not isValid(relay) + del manager, relay, pool, work processQtEvents() collectAtBoundary() + retained = sum(reference() is not None for reference in references) + assert not retained, retained assert len(destroyed) == iterations * 3, destroyed assert len(deliveries) == iterations + return { 'subscriptionWorkerCycles': iterations, 'subscriptionObjectsDestroyed': len(destroyed), diff --git a/tests/test_connection_startup_async.py b/tests/test_connection_startup_async.py index 2a40bb03..40c00af4 100644 --- a/tests/test_connection_startup_async.py +++ b/tests/test_connection_startup_async.py @@ -195,16 +195,20 @@ class ConnectionStartupAsyncTest(TestCase): try: probe.start() + probe._elapsed = mock.Mock() probe._elapsed.isValid.return_value = True probe._elapsed.elapsed.return_value = 86400000 + probe._poll() if timeout is None: self.assertEqual(results, []) self.assertTrue(probe._timer.isActive()) + ready[0] = True probe._poll() + self.assertEqual(results, [True]) else: self.assertEqual(results, [False]) @@ -782,10 +786,12 @@ class ConnectionStartupAsyncTest(TestCase): operation.finished.connect(lambda *_args: results.append(True)) operation.destroyed.connect(lambda *_args: destroyed.append(True)) + operation._resultMap['depth'] = 1 operation._resultMap['reference'].append(reply) operation._elapsed = mock.Mock() operation._elapsed.elapsed.return_value = 1 + reply.finished.connect(lambda: deleteQObject(operation)) try: @@ -1378,11 +1384,13 @@ class LinuxTun2socksScriptTest(TestCase): self.assertEqual(manager._pendingReleases, []) finally: released.set() + cleanup = manager.stopAsync() cleanup.start() self.assertTrue(waitFor(lambda: cleanup._terminal)) manager.cleanup() + bar.stop() bar.deleteLater() processQtEvents() diff --git a/tests/test_models_and_services.py b/tests/test_models_and_services.py index b5e92aa6..d9c87004 100644 --- a/tests/test_models_and_services.py +++ b/tests/test_models_and_services.py @@ -534,6 +534,7 @@ class LogManagerTest(unittest.TestCase): for worker in workers: worker.start() + for worker in workers: worker.join(5) diff --git a/tests/test_profile_test_jobs.py b/tests/test_profile_test_jobs.py index 70466c3e..d1effdfd 100644 --- a/tests/test_profile_test_jobs.py +++ b/tests/test_profile_test_jobs.py @@ -562,6 +562,7 @@ class ProfileTestServiceTest(unittest.TestCase): with self.assertLogs('Furious.Service.RuntimeLease', level='ERROR'): with self.assertRaisesRegex(RuntimeError, 'cleanup is incomplete'): manager.shutdown() + processQtEvents() self.assertTrue(runtimes[0].resourceOwned) @@ -1204,6 +1205,7 @@ class ProfileTestServiceTest(unittest.TestCase): try: manager.testTcping(profiles) + requestId = next(iter(scheduler.tcpingRequests)) scheduler.handleTcpingResult(requestId, '5ms') scheduler.drainTcpingResults() diff --git a/tests/test_qt_interactions.py b/tests/test_qt_interactions.py index f3867ef2..76839365 100644 --- a/tests/test_qt_interactions.py +++ b/tests/test_qt_interactions.py @@ -2686,11 +2686,13 @@ class ProfileMutationBatchTest(unittest.TestCase): profile.metadata.subscriptionSource = 'group-a' profile.metadata.subscriptionManaged = True profile.metadata.subscriptionProfileKey = profile.metadata.profileId + originals[0].metadata.favorite = True table.sourceModel.sort(0, QtCore.Qt.DescendingOrder) table.search('000[01]') table.selectAll() + selected = table._selectedProfileIds() inserted = QSignalSpy(table.sourceModel.rowsInserted) @@ -2698,10 +2700,12 @@ class ProfileMutationBatchTest(unittest.TestCase): table, 'reconcileProfileTestJobs', wraps=table.reconcileProfileTestJobs ) as reconcile: table.duplicateSelectedItem() + self.assertEqual(reconcile.call_count, 1) self.assertEqual(inserted.count(), 1) self.assertEqual(inserted.at(0)[1:], [4, 5]) + copies = Storage.UserServers()[4:] self.assertEqual( {profile.itemRemark for profile in copies}, @@ -2726,10 +2730,13 @@ class ProfileMutationBatchTest(unittest.TestCase): if profile.itemRemark == 'profile-0000' ).metadata.favorite ) + copies[0].connection['changed'] = True + self.assertTrue( all('changed' not in profile.connection for profile in originals) ) + self.assertEqual(table._selectedProfileIds(), selected) controller.startDisconnection.assert_not_called() diff --git a/tests/test_qt_lifetime.py b/tests/test_qt_lifetime.py index 11a957ea..88fa1ff9 100644 --- a/tests/test_qt_lifetime.py +++ b/tests/test_qt_lifetime.py @@ -139,8 +139,10 @@ class ToolbarLifetimeTest(unittest.TestCase): finally: if isValid(owner): deleteQObject(owner) + if isValid(action): deleteQObject(action) + if isValid(separator): deleteQObject(separator) @@ -297,6 +299,7 @@ with isolatedSettings(): def testPathBrowseStopsAfterItsFieldTreeIsDestroyed(self): """A pure binding can survive after its native row widgets disappear.""" application() + module = importlib.import_module('Furious.Backends.ExternalCore.Editor') with isolatedSettings(): @@ -309,6 +312,7 @@ with isolatedSettings(): def select(*_args, **_kwargs): self._destroyDuringModalLoop(container) + return '/chosen' if directoryMode else ('/chosen', '') method = ( @@ -325,6 +329,7 @@ with isolatedSettings(): finally: if isValid(binding._title): deleteQObject(binding._title) + if isValid(container): deleteQObject(container) diff --git a/tests/test_sing_tun.py b/tests/test_sing_tun.py index 5ce01ba6..c97ffce4 100644 --- a/tests/test_sing_tun.py +++ b/tests/test_sing_tun.py @@ -1568,6 +1568,7 @@ class SingTUNChildTest(unittest.TestCase): manager = ConnectionManager() runtime = SingTUN({}, hostPlan=plan) router = RuntimeEventRouter() + router.attach(runtime, manager) lease = RuntimeLease(runtime, router, applicationTUN=True) manager._leases.append(lease) @@ -1576,18 +1577,23 @@ class SingTUNChildTest(unittest.TestCase): coreManager=manager, updatesManager=mock.Mock() ) controller._state = ConnectionState.Connecting + plan.begin('prepare', ['192.0.2.1']) try: self.assertTrue(entered.wait(2)) + controller.startDisconnection() + ticks = [] QtCore.QTimer.singleShot(0, lambda: ticks.append(True)) + self.assertTrue(waitFor(lambda: bool(ticks))) self.assertEqual(controller.state, ConnectionState.Disconnecting) self.assertEqual(manager._pendingReleases, [lease]) released.set() + self.assertTrue( waitFor(lambda: controller.state is ConnectionState.Disconnected) ) @@ -1824,15 +1830,19 @@ class SingTUNChildTest(unittest.TestCase): def command(arguments): if not present[0]: return '[]' + if arguments == ['ip', '-N', '-j', 'link', 'show']: return '[{"ifname":"utun101","ifindex":77}]' + if arguments == ['ip', '-4', '-N', '-j', 'rule', 'show']: return '[{"priority":2001,"src":"all","table":123456,"suppress_prefixlen":0,"protocol":0}]' + return '[]' def release(commands, **kwargs): if refused[0]: raise RuntimeError('fixture refuses cleanup') + self.assertIn('suppress_prefixlength', commands[0]) present[0] = False @@ -1846,6 +1856,7 @@ class SingTUNChildTest(unittest.TestCase): manager = ConnectionManager() runtime = SingTUN({}, hostPlan=plan) router = RuntimeEventRouter() + router.attach(runtime, manager) lease = RuntimeLease(runtime, router, applicationTUN=True) manager._leases.append(lease) @@ -1853,26 +1864,33 @@ class SingTUNChildTest(unittest.TestCase): try: with self.assertRaisesRegex(RuntimeError, 'cleanup is incomplete'): manager.stopAll() + self.assertEqual(manager._pendingReleases, [lease]) refused[0] = False AppSettings.set('ApplicationTUNBackend', 'tun2socks') + cleanup = manager.stopAsync() cleanup.start() self.assertTrue(waitFor(lambda: cleanup._terminal)) + operation = manager.startAsync( _Configuration(), 'Global', deepcopy=False ) + self.assertEqual(manager._pendingReleases, []) self.assertEqual(lease.state, RuntimeLeaseState.Released) self.assertEqual(operation.attempt.tunBackend, 'tun2socks') + operation.cancel() finally: refused[0] = False + if manager._pendingReleases: cleanup = manager.stopAsync() cleanup.start() self.assertTrue(waitFor(lambda: cleanup._terminal)) + manager.cleanup() processQtEvents() diff --git a/tests/test_subscription_manager.py b/tests/test_subscription_manager.py index 150debf9..a2d315f3 100644 --- a/tests/test_subscription_manager.py +++ b/tests/test_subscription_manager.py @@ -1691,6 +1691,7 @@ class SubscriptionManagerTest(TestCase): with self.subTest(duringAbort=duringAbort): manager = self._manager({}) manager._preparationPool.setMaxThreadCount(1) + started = threading.Event() queuedStarted = threading.Event() observations = [] @@ -1699,12 +1700,15 @@ class SubscriptionManagerTest(TestCase): def work(isCancelled): started.set() deadline = time.monotonic() + 1 + while not isCancelled() and time.monotonic() < deadline: threading.Event().wait(0.005) + observations.append((isCancelled(), isValid(relay))) manager._startPreparationJob('probe', {}, work) self.assertTrue(started.wait(2)) + manager._startPreparationJob('probe', {}, lambda _: queuedStarted.set()) manager._preparationPayloads[1] = b'copied payload' @@ -1713,6 +1717,7 @@ class SubscriptionManagerTest(TestCase): reply.setParent(manager) reply.abort = lambda: deleteQObject(manager) manager._activeReplies[reply] = reply + manager.shutdown() else: deleteQObject(manager) @@ -1723,6 +1728,7 @@ class SubscriptionManagerTest(TestCase): self.assertFalse(manager._preparationJobs) self.assertFalse(manager._preparationPayloads) self.assertFalse(manager._batches) + processQtEvents() def testRepeatedPreparationTeardownReclaimsWorkersAndDeliversOnOwnerThread(self): diff --git a/tests/test_xray_asset_download.py b/tests/test_xray_asset_download.py index 58dbc789..98f948a7 100644 --- a/tests/test_xray_asset_download.py +++ b/tests/test_xray_asset_download.py @@ -199,6 +199,7 @@ class XrayAssetDownloadTest(TestCase): filepath='missing-asset.dat', downloadCallback=download, ) + del download self.assertTrue(entered.wait(3)) @@ -217,6 +218,7 @@ class XrayAssetDownloadTest(TestCase): if isValid(manager): manager.deleteLater() + pool.deleteLater() processQtEvents()