diff --git a/Furious/Core/MultiprocessingRuntime.py b/Furious/Core/MultiprocessingRuntime.py index 0e6de97..2a0580e 100644 --- a/Furious/Core/MultiprocessingRuntime.py +++ b/Furious/Core/MultiprocessingRuntime.py @@ -135,9 +135,11 @@ class MultiprocessingRuntime(CoreRuntime): raise RuntimeStartError('Runtime is already running') self._closeProcess() + self._stopRequested = False self._exitPublished = False self._lastExit = None + self.setState(RuntimeState.Starting) try: @@ -229,6 +231,7 @@ class MultiprocessingRuntime(CoreRuntime): def stop(self): """Idempotently terminate execution without disposing this object.""" self._stopRequested = True + self._monitor.stop() self._output.stopTimer() @@ -237,6 +240,7 @@ class MultiprocessingRuntime(CoreRuntime): if process is None: if self.state not in (RuntimeState.Created, RuntimeState.Disposed): self.setState(RuntimeState.Exited) + return self.setState(RuntimeState.Stopping) diff --git a/Furious/Core/ProcessOutput.py b/Furious/Core/ProcessOutput.py index 6d07a3e..36138d8 100644 --- a/Furious/Core/ProcessOutput.py +++ b/Furious/Core/ProcessOutput.py @@ -193,17 +193,21 @@ class ProcessOutputRedirector: or SystemRuntime.isPythonw() ): entrypoint() + return temporaryFile = ProcessOutputRedirector.TemporaryDir.filePath(str(uuid.uuid4())) tmpFileStream = open(temporaryFile, 'w+b') + stdoutFileno = sys.stdout.fileno() stderrFileno = sys.stderr.fileno() sys.stdout.close() sys.stderr.close() + os.dup2(tmpFileStream.fileno(), stdoutFileno) os.dup2(tmpFileStream.fileno(), stderrFileno) + sys.stdout = tmpFileStream sys.stderr = tmpFileStream diff --git a/Furious/Qt/QtWidgets.py b/Furious/Qt/QtWidgets.py index 36934c3..bdc7bb1 100644 --- a/Furious/Qt/QtWidgets.py +++ b/Furious/Qt/QtWidgets.py @@ -1817,6 +1817,7 @@ class AppQMessageBox(AppQTransientDialog): baseSurfaceWidth = self.MultipleActionBaseWidth baseSurfaceWidth = min(baseSurfaceWidth, maximumSurfaceWidth) + hasIcon = not self.iconLabel.isHidden() iconSpace = 58 if hasIcon else 0 horizontalChrome = 56 + iconSpace @@ -1843,6 +1844,7 @@ class AppQMessageBox(AppQTransientDialog): naturalTextWidth, ) textWidth = min(maximumTextWidth, preferredTextWidth) + textHeight = self._textContentHeight(textWidth) maximumTextHeight = max( 72, diff --git a/Furious/Widget/ServerTableView.py b/Furious/Widget/ServerTableView.py index 4d9229e..8942ab1 100644 --- a/Furious/Widget/ServerTableView.py +++ b/Furious/Widget/ServerTableView.py @@ -1523,6 +1523,7 @@ class ServerTableView( """Apply one repository reorder and preserve active and selected identities.""" selectedProfileIds = self._selectedProfileIds() currentProfileId = self._currentProfileId() + activatedIndex = Storage.UserActivatedItemIndex() profiles = Storage.UserServers() activatedProfileId = ( @@ -1621,6 +1622,7 @@ class ServerTableView( for profile in Storage.UserServers() if profile.metadata.profileId in selected } + destinations = [(_('No subscription'), '')] destinations.extend( ( diff --git a/tests/fixtures/editor_lifetime_probe.py b/tests/fixtures/editor_lifetime_probe.py index 4285eb8..a56756d 100644 --- a/tests/fixtures/editor_lifetime_probe.py +++ b/tests/fixtures/editor_lifetime_probe.py @@ -350,6 +350,7 @@ def runNetworkProbe(iterations=100): def runButtonOwnershipProbe(iterations=100): """Exercise button detach/reuse, native removal, and owner-first callbacks.""" application() + references = [] destroyed = [] @@ -358,37 +359,47 @@ def runButtonOwnershipProbe(iterations=100): button = QPushButton('Fixture') results = [] box.finished.connect(results.append) + for item in (box, button): references.append(weakref.ref(item)) item.destroyed.connect(lambda *_args: destroyed.append(True)) + del item for _detach in range(3): box.addButton(button, box.ButtonRole.AcceptRole) box.setDefaultButton(button) box.setEscapeButton(button) + box.removeButton(button) button.click() + assert not results assert box.defaultButton() is None and box.escapeButton() is None assert button.receivers(QtCore.SIGNAL('clicked()')) == 0 box.addButton(button, box.ButtonRole.AcceptRole) box.addButton(button, box.ButtonRole.AcceptRole) + box.open() button.click() processQtEvents() + assert results == [int(AppQDialog.DialogCode.Accepted)] assert not isValid(box) and not isValid(button) + del box, button box = AppQMessageBox() button = box.addButton(box.StandardButton.Yes) box.setDefaultButton(button) box.setEscapeButton(button) + deleteQObject(button) + assert not box.buttons() assert box.defaultButton() is None and box.escapeButton() is None + deleteQObject(box) del box, button @@ -396,14 +407,19 @@ def runButtonOwnershipProbe(iterations=100): box = AppQMessageBox(parent=owner) button = box.addButton(box.StandardButton.Yes) box.buttonClicked.connect(lambda *_args: deleteQObject(owner)) + button.click() + assert not isValid(box) and not isValid(button) + del box, button, owner processQtEvents() + assert len(destroyed) == iterations * 2 assert all(reference() is None for reference in references) assert not AppQDialog._openDialogs + return { 'buttonDetachReuse': iterations, 'nativeButtonRemoval': iterations, @@ -677,6 +693,7 @@ def main(): '--pattern', choices=tuple(PROTOCOL_PATTERNS), default='alternating' ) parser.add_argument('--close-method', choices=CLOSE_METHODS, default='reject') + arguments = parser.parse_args() callbackErrors = [] @@ -684,6 +701,7 @@ def main(): sys.excepthook = lambda kind, value, traceback: callbackErrors.append( (kind.__name__, str(value)) ) + try: print(json.dumps(runButtonOwnershipProbe(arguments.iterations), sort_keys=True)) print(json.dumps(runConfirmationProbe(arguments.iterations), sort_keys=True)) diff --git a/tests/test_log_manager_generation.py b/tests/test_log_manager_generation.py index 31645b7..b60f8e9 100644 --- a/tests/test_log_manager_generation.py +++ b/tests/test_log_manager_generation.py @@ -923,6 +923,7 @@ class GenerationLogManagerContractTest(unittest.TestCase): autoClearEnabled=False, ) manager.appendMany(tuple(f'initial {index}' for index in range(200))) + fullReads = [] suffixReads = [] originalFullRead = manager._entriesLocked @@ -938,9 +939,11 @@ class GenerationLogManagerContractTest(unittest.TestCase): manager._entriesLocked = fullRead manager._entriesAfterLocked = suffixRead + page = LogPage(manager=manager) page.resize(900, 420) page.show() + self.assertTrue(waitFor(lambda: not page._entriesDirty)) self.assertEqual(len(fullReads), 1) @@ -954,6 +957,7 @@ class GenerationLogManagerContractTest(unittest.TestCase): page.plainText().splitlines(), [entry.message for entry in manager.entries()], ) + page.close() page.deleteLater() collectAtBoundary() @@ -1208,8 +1212,10 @@ class GenerationLogManagerContractTest(unittest.TestCase): def onAdded(entry): added.append(entry.sequence) + manager.snapshot() manager.entryCount() + if not lockWasFreeDuringSignal: completed = threading.Event() @@ -1221,21 +1227,27 @@ class GenerationLogManagerContractTest(unittest.TestCase): worker.start() lockWasFreeDuringSignal.append(completed.wait(2)) worker.join(2) + if entry.categoryId == CORE_LOG_CATEGORY: manager.clear(runtimeOnly=True) manager.entryAdded.connect(onAdded) manager.entriesCleared.connect(cleared.append) manager.entriesChanged.connect(changed.append) + manager.append('core', CORE_LOG_CATEGORY) + for index in range(20): manager.append(f'application {index}') + processQtEvents() + self.assertEqual(len(added), 21) self.assertEqual(cleared, [manager._runtimeCategoryIds]) self.assertEqual(changed, [21]) self.assertEqual(lockWasFreeDuringSignal, [True]) self.assertEqual(manager.entryCount(CORE_LOG_CATEGORY), 0) + _assertManagerInvariants(self, manager) def testAppendAndClearCannotSplitOneAtomicMutation(self): diff --git a/tests/test_profile_test_jobs.py b/tests/test_profile_test_jobs.py index 998fe0c..43865be 100644 --- a/tests/test_profile_test_jobs.py +++ b/tests/test_profile_test_jobs.py @@ -697,10 +697,12 @@ class ProfileTestServiceTest(unittest.TestCase): self.assertTrue( server.listen(QtNetwork.QHostAddress.SpecialAddress.LocalHost, 0) ) + first = self._profile('first', '127.0.0.1', server.serverPort()) second = self._profile('second', '127.0.0.1', server.serverPort()) manager = self._manager((first, second)) scheduler = manager._latencyScheduler + guiEventDelivered = threading.Event() try: @@ -714,6 +716,7 @@ class ProfileTestServiceTest(unittest.TestCase): ) self.assertIs(scheduler.tcpingEngine.thread(), scheduler.tcpingThread) self.assertIsNot(scheduler.tcpingThread, application().thread()) + self.assertTrue(waitFor(lambda: not scheduler.tcpingRequests, timeout=2000)) self.assertTrue(guiEventDelivered.is_set()) self.assertRegex(first.metadata.latency, r'^\d+ms$') diff --git a/tests/test_qt_interactions.py b/tests/test_qt_interactions.py index 310467b..55a9719 100644 --- a/tests/test_qt_interactions.py +++ b/tests/test_qt_interactions.py @@ -249,24 +249,32 @@ class ServerTableQtInteractionTest(unittest.TestCase): """The fast row projection retains regex, literal and live-cell semantics.""" with isolatedSettings(): table = self._table(('alpha[1]', 'beta')) + try: table.search('alpha[1]', regex=False) self.assertEqual(table.proxyModel.rowCount(), 1) + table.search('ALPHA', caseSensitive=True) self.assertEqual(table.proxyModel.rowCount(), 0) + table.search('ALPHA') self.assertEqual(table.proxyModel.rowCount(), 1) + table.search('23 ms') self.assertEqual(table.proxyModel.rowCount(), 0) + profile = Storage.UserServers()[1] profile.metadata.latency = '23 ms' table.sourceModel.emitRowChanged(1) + self.assertEqual(table.proxyModel.rowCount(), 1) self.assertEqual( table.sourceRowFromProxyIndex(table.proxyModel.index(0, 0)), 1 ) + profile.metadata.latency = '42 ms' table.sourceModel.emitRowChanged(1) + self.assertEqual(table.proxyModel.rowCount(), 0) finally: self._destroyTable(table) diff --git a/tests/test_qt_lifetime.py b/tests/test_qt_lifetime.py index e5ce3b9..8ceb08f 100644 --- a/tests/test_qt_lifetime.py +++ b/tests/test_qt_lifetime.py @@ -152,20 +152,25 @@ class QtLifetimeTest(unittest.TestCase): def testRemovedMessageBoxButtonDisconnectsAndCanBeReused(self): """Detaching a button ends only the box-owned signal and role lifetime.""" application() + with isolatedSettings(): box = AppQMessageBox() button = QPushButton('Reusable button') finished = [] externalClicks = [] + box.finished.connect(finished.append) button.clicked.connect(lambda: externalClicks.append(True)) + try: for _ in range(30): box.addButton(button, box.ButtonRole.AcceptRole) box.setDefaultButton(button) box.setEscapeButton(button) + box.removeButton(button) button.click() + self.assertEqual(finished, []) self.assertIsNone(box.defaultButton()) self.assertIsNone(box.escapeButton()) @@ -173,27 +178,34 @@ class QtLifetimeTest(unittest.TestCase): self.assertEqual(button.receivers(QtCore.SIGNAL('clicked()')), 1) self.assertEqual(len(externalClicks), 30) + box.addButton(button, box.ButtonRole.AcceptRole) box.addButton(button, box.ButtonRole.AcceptRole) + box.open() button.click() processQtEvents() + self.assertEqual(finished, [int(AppQDialog.DialogCode.Accepted)]) self.assertFalse(isValid(box)) self.assertFalse(isValid(button)) finally: if isValid(box): deleteQObject(box) + if isValid(button): deleteQObject(button) + processQtEvents() def testReplacingMessageBoxButtonsReleasesOldDefaultAndEscape(self): """A retained dialog must not retain removed standard-button wrappers.""" application() + with isolatedSettings(): box = AppQMessageBox() references = [] + try: for _ in range(30): box.setStandardButtons(box.StandardButton.Yes) @@ -201,11 +213,14 @@ class QtLifetimeTest(unittest.TestCase): references.append(weakref.ref(button)) box.setDefaultButton(button) box.setEscapeButton(button) + box.setStandardButtons(box.StandardButton.No) del button processQtEvents() + self.assertIsNone(box.defaultButton()) self.assertIsNone(box.escapeButton()) + self.assertTrue(all(reference() is None for reference in references)) finally: deleteQObject(box) @@ -214,14 +229,18 @@ class QtLifetimeTest(unittest.TestCase): def testNativeButtonDestructionRemovesMessageBoxRegistrations(self): """Deleting an attached button cannot leave invalid wrapper roles behind.""" application() + with isolatedSettings(): box = AppQMessageBox() + try: for _ in range(30): button = box.addButton(box.StandardButton.Yes) box.setDefaultButton(button) box.setEscapeButton(button) + deleteQObject(button) + self.assertEqual(box.buttons(), []) self.assertIsNone(box.defaultButton()) self.assertIsNone(box.escapeButton()) @@ -233,14 +252,17 @@ class QtLifetimeTest(unittest.TestCase): def testMessageBoxCallbackMayDestroyItsOwner(self): """Button activation cannot finish a box destroyed by its own listener.""" application() + with isolatedSettings(): owner = QWidget() box = AppQMessageBox(parent=owner) button = box.addButton(box.StandardButton.Yes) box.buttonClicked.connect(lambda *_args: deleteQObject(owner)) + with mock.patch('sys.excepthook') as exceptionHook: button.click() processQtEvents() + exceptionHook.assert_not_called() self.assertFalse(isValid(box)) self.assertFalse(isValid(button)) diff --git a/tests/test_service_runtime.py b/tests/test_service_runtime.py index dc6a66d..99cdb0c 100644 --- a/tests/test_service_runtime.py +++ b/tests/test_service_runtime.py @@ -165,6 +165,7 @@ class HttpGetManagerLifetimeTest(unittest.TestCase): for _ in range(20): manager = managerType() reply = _ManagedReply(manager) + destroyed = [] reply.destroyed.connect(lambda *_a: destroyed.append(True)) completion = [] @@ -178,21 +179,25 @@ class HttpGetManagerLifetimeTest(unittest.TestCase): 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] @@ -200,17 +205,22 @@ class HttpGetManagerLifetimeTest(unittest.TestCase): 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, {}) diff --git a/tests/test_subscription_manager.py b/tests/test_subscription_manager.py index f189fdf..bd342fe 100644 --- a/tests/test_subscription_manager.py +++ b/tests/test_subscription_manager.py @@ -1380,12 +1380,15 @@ class SubscriptionManagerTest(TestCase): manager = self._manager() started = threading.Event() release = threading.Event() + context = {'unique': 'group-a', 'batchId': 1, 'requestVersion': 1} subscriptions = {'group-a': self._subscription()} + manager._requestVersions['group-a'] = 1 context['webURL'] = subscriptions['group-a']['webURL'] manager._batches[1] = _SubscriptionBatchState({('group-a', 1)}, True, [], []) manager._handleImportedResult = mock.Mock() + completed = [] manager.updateCompleted.connect(completed.append) @@ -1402,6 +1405,7 @@ class SubscriptionManagerTest(TestCase): try: manager._startPreparationJob('import', context, work) self.assertTrue(started.wait(2)) + manager.stopUpdates() self.assertEqual(manager._batches, {}) @@ -1410,6 +1414,7 @@ class SubscriptionManagerTest(TestCase): release.set() self.assertTrue(waitFor(lambda: not manager._preparationJobs)) + manager._handleImportedResult.assert_not_called() self.assertEqual(completed, []) finally: @@ -1649,6 +1654,7 @@ class SubscriptionManagerTest(TestCase): manager = self._manager({}) manager.ShutdownWarningMilliseconds = 1 manager._preparationPool.setMaxThreadCount(1) + started = threading.Event() release = threading.Event() queuedStarted = threading.Event() @@ -1666,21 +1672,26 @@ class SubscriptionManagerTest(TestCase): self.assertTrue( all(job.cancelled.is_set() for job in manager._preparationJobs.values()) ) + release.set() try: manager._startPreparationJob('import', {}, work) self.assertTrue(started.wait(2)) + manager._startPreparationJob('import', {}, lambda _: queuedStarted.set()) + with mock.patch( 'Furious.Service.SubscriptionManager.logger.warning', side_effect=afterWarning, ) as warning: manager.shutdown() manager.shutdown() + warning.assert_called_once() processQtEvents() + self.assertFalse(queuedStarted.is_set()) self.assertEqual(manager._preparationPool.activeThreadCount(), 0) self.assertEqual(manager._preparationJobs, {}) @@ -1690,6 +1701,7 @@ class SubscriptionManagerTest(TestCase): release.set() manager.shutdown() manager.deleteLater() + processQtEvents() def testShutdownRejectsNewWorkAndVersionlessCompletions(self): diff --git a/tests/test_ui_behavior.py b/tests/test_ui_behavior.py index 25618b7..9c3633d 100644 --- a/tests/test_ui_behavior.py +++ b/tests/test_ui_behavior.py @@ -1627,14 +1627,17 @@ class UnifiedLogPageTest(unittest.TestCase): page = LogPage(manager=manager) self.addCleanup(self.disposePage, page) self.addCleanup(QTest.keyRelease, page.textBrowser, QtCore.Qt.Key_Control) + manager.append('alpha log', APPLICATION_LOG_CATEGORY) page.show() page.activateWindow() self.assertRendered(page) + page.textBrowser.setFocus() processQtEvents() QTest.keyClick(page.textBrowser, QtCore.Qt.Key_F, QtCore.Qt.ControlModifier) self.assertTrue(waitFor(page.searchLineEdit.hasFocus)) + QTest.keyClicks(page.searchLineEdit, 'alpha') QTest.keyClick( page.searchLineEdit, QtCore.Qt.Key_A, QtCore.Qt.ControlModifier @@ -1642,12 +1645,15 @@ class UnifiedLogPageTest(unittest.TestCase): QTest.keyClick( page.searchLineEdit, QtCore.Qt.Key_C, QtCore.Qt.ControlModifier ) + self.assertEqual(application().clipboard().text(), 'alpha') self.assertFalse(page.textBrowser.textCursor().hasSelection()) self.assertRendered(page) + page.textBrowser.setFocus() QTest.keyClick(page.textBrowser, QtCore.Qt.Key_A, QtCore.Qt.ControlModifier) QTest.keyClick(page.textBrowser, QtCore.Qt.Key_C, QtCore.Qt.ControlModifier) + self.assertEqual(application().clipboard().text(), 'alpha log') def testFindShortcutFollowsTheVisiblePage(self):