diff --git a/Furious/Application/AGENTS.md b/Furious/Application/AGENTS.md index f680e79..2067d2f 100644 --- a/Furious/Application/AGENTS.md +++ b/Furious/Application/AGENTS.md @@ -9,8 +9,12 @@ same reverse-order, failure-isolating cleanup stack, and cleanup is idempotent without relying on destructor timing. - Keep graceful shutdown distinct from the final base-Qt event-loop exit. Delayed forced-exit callbacks must never recursively re-enter application cleanup. +- Serialize single-instance election with a short-lived cross-process lock. `QLocalServer.listen()` is the endpoint + claim on Unix, but Qt permits duplicate same-name local servers on Windows. Never remove a possibly stale endpoint + until the election lock is held and endpoint reachability and listening have both been rechecked. - The tray owns its long-lived actions/menus and reflects controller state. Dynamic submenu rebuilds must not retain - stale actions or menus. + stale actions or menus. A system tray is an optional desktop capability, not proof that the operating system is + supported; when it is unavailable, show the main window and let closing the last window quit the application. - Application code may import higher layers to compose them; lower layers must not import `DesktopApplication` to obtain dependencies when injection or a narrow global accessor suffices. - Keep blocking startup checks bounded and event-loop safe. A startup failure must leave enough runtime available to diff --git a/Furious/Application/DesktopApplication.py b/Furious/Application/DesktopApplication.py index 29e35c0..1289ad9 100644 --- a/Furious/Application/DesktopApplication.py +++ b/Furious/Application/DesktopApplication.py @@ -76,11 +76,31 @@ import sys import logging import platform import traceback +from enum import Enum + import darkdetect logger = logging.getLogger(__name__) +class _ExistingInstanceResult(Enum): + """Describe the result of forwarding this launch to a primary instance.""" + + Unreachable = 'unreachable' + CommandForwarded = 'command-forwarded' + CommandDeliveryUncertain = 'command-delivery-uncertain' + RunAsHandoffAccepted = 'run-as-handoff-accepted' + + +class _SingletonStartupResult(Enum): + """Describe whether this process may continue full application startup.""" + + Primary = 'primary' + ExistingInstance = 'existing-instance' + RecoveryRequired = 'recovery-required' + OwnershipUnresolved = 'ownership-unresolved' + + class _ApplicationCleanupStack: """Run acquired application-resource cleanup in reverse order exactly once.""" @@ -115,12 +135,6 @@ class _ApplicationCleanupStack: return True -class SystemTrayUnavailable(Exception): - """Represent system tray unavailable.""" - - pass - - class ApplicationExitHelper(QApplication): """Represent application exit helper.""" @@ -143,6 +157,12 @@ class ApplicationExitHelper(QApplication): class SingletonApplication(ApplicationExitHelper): """Represent singleton application.""" + ExistingInstanceConnectTimeout = 1000 + RunAsHandoffTimeout = 3000 + ExistingEndpointProbeTimeout = 250 + SingletonElectionLockTimeout = 3000 + SingletonElectionLockStaleTime = 30000 + def __init__(self, argv): """Initialize the SingletonApplication.""" super().__init__(argv) @@ -152,54 +172,221 @@ class SingletonApplication(ApplicationExitHelper): self.socket = QLocalSocket(self) self.server = QLocalServer(self) - def shouldExitForExistingInstance(self) -> bool: - """Claim the single-instance endpoint or notify the running instance.""" + def _notifyExistingInstance(self) -> _ExistingInstanceResult: + """Forward this launch command or report that no primary responded.""" + self.socket.abort() self.socket.connectToServer(self.serverName) - if self.socket.waitForConnected(1000): - if len(sys.argv) == 1: - command = AppBuiltinCommand.Empty.value - else: - command = sys.argv[1] + if not self.socket.waitForConnected(self.ExistingInstanceConnectTimeout): + return _ExistingInstanceResult.Unreachable - self.socket.write(command.encode()) - self.socket.flush() + command = AppBuiltinCommand.Empty.value if len(sys.argv) == 1 else sys.argv[1] - if command == AppBuiltinCommand.Empty.value: - # Show tray message in the started instance. Do not start - return True - elif command == AppBuiltinCommand.RunAs.value: - if self.socket.waitForDisconnected(3000): - # The other instance have been exited. Start - return False - else: - # Do not start - return True - else: - # TODO: Not implemented - # Do not start - return True - else: - # Remove the old socket file if it exists - socket_path = QLocalServer.removeServer(self.serverName) + encodedCommand = command.encode() - if socket_path: - logger.info(f'old socket file removed: {self.serverName}') - else: - logger.info(f'no existing socket file found for: {self.serverName}') + if self.socket.write(encodedCommand) == -1: + # A successful connection still proves that another process owns + # the endpoint. Do not compete for ownership merely because the + # one-command delivery failed while that process was disconnecting. + logger.warning( + f'unable to forward startup command to existing instance: ' + f'{self.socket.errorString()}' + ) - # New instance - self.server.newConnection.connect(self.handleNewConnection) + return _ExistingInstanceResult.CommandDeliveryUncertain - if not self.server.listen(self.serverName): - # Do not start - logger.error(f'unable to listen on server: {self.serverName}') + self.socket.flush() - return True + if command == AppBuiltinCommand.RunAs.value: + # A successful disconnect means the old instance accepted the + # command and started exiting. A later listen() still decides + # whether this replacement actually owns the endpoint. + if self.socket.waitForDisconnected(self.RunAsHandoffTimeout): + logger.info('existing instance accepted the RunAs handoff') - # Start + return _ExistingInstanceResult.RunAsHandoffAccepted + + logger.warning( + 'existing instance did not complete the RunAs handoff before ' + 'the deadline' + ) + + return _ExistingInstanceResult.CommandForwarded + + # Empty and currently unsupported commands are handled by the running + # instance; this process must not create another application window. + logger.info('startup command forwarded to the existing instance') + + return _ExistingInstanceResult.CommandForwarded + + def _listenAsPrimaryInstance(self) -> bool: + """Listen as primary after the caller has serialized election.""" + if not self.server.listen(self.serverName): return False + self.server.newConnection.connect(self.handleNewConnection) + + return True + + def _existingEndpointIsReachable(self) -> bool: + """Probe endpoint ownership without forwarding this launch command.""" + self.socket.abort() + self.socket.connectToServer(self.serverName) + + if not self.socket.waitForConnected(self.ExistingEndpointProbeTimeout): + return False + + self.socket.disconnectFromServer() + + if self.socket.state() != QLocalSocket.LocalSocketState.UnconnectedState: + self.socket.waitForDisconnected(self.ExistingEndpointProbeTimeout) + + return True + + def _waitForRunAsEndpointRelease(self) -> bool: + """Wait until the primary that accepted RunAs stops owning the endpoint.""" + deadline = QtCore.QDeadlineTimer(self.RunAsHandoffTimeout) + + while not deadline.hasExpired(): + if not self._existingEndpointIsReachable(): + return True + + QtCore.QThread.msleep(50) + + return False + + def _singletonElectionLock(self): + """Return the short-lived lock that serializes candidate election.""" + lock = QtCore.QLockFile( + os.path.join( + QtCore.QStandardPaths.writableLocation( + QtCore.QStandardPaths.StandardLocation.TempLocation + ), + f'{self.serverName}.lock', + ) + ) + # Election is bounded to a few seconds. The longer stale interval + # prevents age-based recovery from stealing a live election transaction, + # while QLockFile can still clean up a lock left by a crashed process. + lock.setStaleLockTime(self.SingletonElectionLockStaleTime) + + return lock + + @staticmethod + def _logElectionLockFailure(electionLock): + """Log the specific reason singleton election cannot be serialized.""" + error = electionLock.error() + + if error == QtCore.QLockFile.LockError.LockFailedError: + logger.info('singleton election is already owned by another launcher') + elif error == QtCore.QLockFile.LockError.PermissionError: + logger.error( + f'permission denied creating singleton election lock: ' + f'{electionLock.fileName()}' + ) + else: + logger.error( + f'unable to acquire singleton election lock ' + f'{electionLock.fileName()}: {error}' + ) + + def _electPrimaryUnderLock( + self, + initialProbe: _ExistingInstanceResult, + ) -> _SingletonStartupResult: + """Recheck competitors, then claim or classify the endpoint under lock.""" + if initialProbe is _ExistingInstanceResult.RunAsHandoffAccepted: + if not self._waitForRunAsEndpointRelease(): + logger.error( + 'existing instance accepted RunAs but did not release the ' + 'singleton endpoint before the handoff deadline' + ) + + return _SingletonStartupResult.OwnershipUnresolved + elif self._existingEndpointIsReachable(): + # A launcher won while this process was waiting for the election + # lock. A connectivity-only probe avoids forwarding RunAs twice. + logger.info('another launcher completed singleton election first') + + return _SingletonStartupResult.ExistingInstance + + # On Unix this is the authoritative ownership claim. Qt explicitly + # permits multiple same-name local servers on Windows, so the election + # lock and preceding reachability barrier provide exclusivity there. + if self._listenAsPrimaryInstance(): + logger.info(f'primary instance endpoint claimed: {self.serverName}') + + return _SingletonStartupResult.Primary + + # A non-cooperating process may have appeared despite serialization. + # Recheck before treating a failed Unix listen as a stale socket file. + if self._existingEndpointIsReachable(): + logger.info('singleton endpoint became reachable before recovery') + + return _SingletonStartupResult.ExistingInstance + + logger.info( + f'primary endpoint claim failed; evaluating stale recovery: ' + f'{self.server.errorString()}' + ) + + return _SingletonStartupResult.RecoveryRequired + + def _recoverStaleEndpointAndClaim(self) -> _SingletonStartupResult: + """Recover a confirmed stale endpoint while holding the election lock.""" + logger.info(f'attempting stale endpoint recovery: {self.serverName}') + + if QLocalServer.removeServer(self.serverName): + logger.info(f'stale singleton endpoint removed: {self.serverName}') + else: + logger.warning( + f'singleton endpoint could not be removed or was already absent: ' + f'{self.serverName}' + ) + + if self._listenAsPrimaryInstance(): + logger.info( + f'primary instance endpoint claimed after recovery: ' + f'{self.serverName}' + ) + + return _SingletonStartupResult.Primary + + logger.error( + f'unable to claim singleton endpoint {self.serverName} after ' + f'recovery: {self.server.errorString()}' + ) + + return _SingletonStartupResult.OwnershipUnresolved + + def shouldExitForExistingInstance(self) -> bool: + """Claim the single-instance endpoint or notify the running instance.""" + initialProbe = self._notifyExistingInstance() + + if initialProbe in ( + _ExistingInstanceResult.CommandForwarded, + _ExistingInstanceResult.CommandDeliveryUncertain, + ): + return True + + electionLock = self._singletonElectionLock() + + if not electionLock.tryLock(self.SingletonElectionLockTimeout): + self._logElectionLockFailure(electionLock) + + return True + + try: + result = self._electPrimaryUnderLock(initialProbe) + + if result is _SingletonStartupResult.RecoveryRequired: + result = self._recoverStaleEndpointAndClaim() + + # Fail closed whenever endpoint ownership remains uncertain. + return result is not _SingletonStartupResult.Primary + finally: + electionLock.unlock() + @QtCore.Slot() def handleNewConnection(self): """Handle new connection.""" @@ -536,7 +723,13 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): try: self.applyThemePreference() self.mainWindow = MainWindow() - self.systemTray = TrayIcon(parent=self) + + if TrayIcon.isSystemTrayAvailable(): + self.systemTray = TrayIcon(parent=self) + + self.setQuitOnLastWindowClosed(False) + else: + self.setQuitOnLastWindowClosed(True) if PLATFORM == 'Darwin': if AppSettings.isStateON_('HideDockIcon'): @@ -560,9 +753,16 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): self.applicationStateChanged.connect(onApplicationStateChange) - self.systemTray.show() - self.systemTray.setCustomToolTip() - self.systemTray.bootstrap() + if self.systemTray is None: + logger.warning( + 'system tray unavailable; showing the main window instead' + ) + + self.mainWindow.show() + else: + self.systemTray.show() + self.systemTray.setCustomToolTip() + self.systemTray.bootstrap() except Exception: # Any non-exit exceptions @@ -755,11 +955,6 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): # not what we want. return ApplicationRunner.ExitCode.ExitSuccess.value - if not TrayIcon.isSystemTrayAvailable(): - raise SystemTrayUnavailable( - 'TrayIcon is not available on this platform' - ) - pluginRegistry = self.addEnviron() self._cleanupStack.register('plugins', pluginRegistry.shutdown) @@ -799,8 +994,6 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): return self._exitCode return self.exec() - except SystemTrayUnavailable: - return ApplicationRunner.ExitCode.PlatformNotSupported.value except Exception: # Any non-exit exceptions diff --git a/tests/test_architecture_refactors.py b/tests/test_architecture_refactors.py index 0806b0c..00685c9 100644 --- a/tests/test_architecture_refactors.py +++ b/tests/test_architecture_refactors.py @@ -19,13 +19,18 @@ from Furious.Application.DesktopApplication import ( DesktopApplication, + SingletonApplication, _ApplicationCleanupStack, + _ExistingInstanceResult, + _SingletonStartupResult, ) +from Furious.Frozenlib import AppBuiltinCommand from Furious.Interface import ApplicationRunner, CoreRuntime from Furious.Qt.AppStyleSheet import AppStyleSheet from Furious.Service.ConnectionManager import ConnectionManager from PySide6 import QtCore +from PySide6.QtNetwork import QLocalServer from types import SimpleNamespace from unittest import TestCase, mock @@ -36,6 +41,8 @@ import sys import tempfile import textwrap import subprocess +import time +import uuid CoreProcessWorkerModule = importlib.import_module('Furious.Core.CoreProcessWorker') @@ -164,6 +171,595 @@ class ApplicationLifecycleTransactionTest(TestCase): finalExit.assert_called_once_with(23) + def testFirstInstanceClaimsEndpointWithoutRemovingSocket(self): + """Serialize election, then continue only after the endpoint is owned.""" + electionLock = mock.Mock() + electionLock.tryLock.return_value = True + application = SimpleNamespace( + serverName='test-server', + _notifyExistingInstance=mock.Mock( + return_value=_ExistingInstanceResult.Unreachable + ), + _singletonElectionLock=mock.Mock(return_value=electionLock), + _electPrimaryUnderLock=mock.Mock( + return_value=_SingletonStartupResult.Primary + ), + _recoverStaleEndpointAndClaim=mock.Mock(), + SingletonElectionLockTimeout=3000, + ) + + shouldExit = SingletonApplication.shouldExitForExistingInstance(application) + + self.assertFalse(shouldExit) + electionLock.tryLock.assert_called_once_with(3000) + electionLock.unlock.assert_called_once_with() + application._electPrimaryUnderLock.assert_called_once_with( + _ExistingInstanceResult.Unreachable + ) + application._recoverStaleEndpointAndClaim.assert_not_called() + + def testSecondInstanceForwardsCommandWithoutClaimingEndpoint(self): + """Exit as secondary after forwarding to a reachable primary.""" + application = SimpleNamespace( + _notifyExistingInstance=mock.Mock( + return_value=_ExistingInstanceResult.CommandForwarded + ), + _singletonElectionLock=mock.Mock(), + ) + + shouldExit = SingletonApplication.shouldExitForExistingInstance(application) + + self.assertTrue(shouldExit) + application._singletonElectionLock.assert_not_called() + + def testConcurrentListenLoserReprobesWinnerWithoutRecovery(self): + """Recognize a launcher that wins while this process waits for the lock.""" + application = SimpleNamespace( + _existingEndpointIsReachable=mock.Mock(return_value=True), + _listenAsPrimaryInstance=mock.Mock(), + ) + + result = SingletonApplication._electPrimaryUnderLock( + application, + _ExistingInstanceResult.Unreachable, + ) + + self.assertIs(result, _SingletonStartupResult.ExistingInstance) + application._listenAsPrimaryInstance.assert_not_called() + + def testElectionRechecksForConcurrentWinner(self): + """Never unlink an endpoint that becomes reachable after failed listen.""" + application = SimpleNamespace( + _existingEndpointIsReachable=mock.Mock(side_effect=(False, True)), + _listenAsPrimaryInstance=mock.Mock(return_value=False), + ) + + with mock.patch( + 'Furious.Application.DesktopApplication.QLocalServer.removeServer' + ) as removeServer: + result = SingletonApplication._electPrimaryUnderLock( + application, + _ExistingInstanceResult.Unreachable, + ) + + self.assertIs(result, _SingletonStartupResult.ExistingInstance) + removeServer.assert_not_called() + application._listenAsPrimaryInstance.assert_called_once_with() + + def testSingletonRecoveryRemovesOnlyConfirmedStaleEndpoint(self): + """Remove a stale socket only inside the serialized recovery section.""" + application = SimpleNamespace( + serverName='test-server', + _listenAsPrimaryInstance=mock.Mock(return_value=True), + ) + + with mock.patch( + 'Furious.Application.DesktopApplication.QLocalServer.removeServer', + return_value=True, + ) as removeServer: + result = SingletonApplication._recoverStaleEndpointAndClaim(application) + + self.assertIs(result, _SingletonStartupResult.Primary) + removeServer.assert_called_once_with('test-server') + + def testRecoveryContinuesWhenStaleEndpointIsAlreadyAbsent(self): + """Allow final listen() to decide ownership when removeServer() returns false.""" + application = SimpleNamespace( + serverName='test-server', + _listenAsPrimaryInstance=mock.Mock(return_value=True), + ) + + with ( + mock.patch( + 'Furious.Application.DesktopApplication.QLocalServer.removeServer', + return_value=False, + ) as removeServer, + self.assertLogs('Furious.Application.DesktopApplication', level='WARNING'), + ): + result = SingletonApplication._recoverStaleEndpointAndClaim(application) + + self.assertIs(result, _SingletonStartupResult.Primary) + removeServer.assert_called_once_with('test-server') + + def testFinalListenFailureFailsClosed(self): + """Never continue full startup when final ownership remains uncertain.""" + application = SimpleNamespace( + serverName='test-server', + server=SimpleNamespace(errorString=mock.Mock(return_value='address busy')), + _listenAsPrimaryInstance=mock.Mock(return_value=False), + ) + + with ( + mock.patch( + 'Furious.Application.DesktopApplication.QLocalServer.removeServer', + return_value=True, + ), + self.assertLogs('Furious.Application.DesktopApplication', level='ERROR'), + ): + result = SingletonApplication._recoverStaleEndpointAndClaim(application) + + self.assertIs(result, _SingletonStartupResult.OwnershipUnresolved) + + def testElectionLockFailuresAreDistinguishedAndFailClosed(self): + """Differentiate contention, permission, and filesystem election failures.""" + errorLevels = ( + (QtCore.QLockFile.LockError.LockFailedError, 'INFO'), + (QtCore.QLockFile.LockError.PermissionError, 'ERROR'), + (QtCore.QLockFile.LockError.UnknownError, 'ERROR'), + ) + + for lockError, logLevel in errorLevels: + with self.subTest(lockError=lockError): + electionLock = SimpleNamespace( + tryLock=mock.Mock(return_value=False), + error=mock.Mock(return_value=lockError), + fileName=mock.Mock(return_value='test.lock'), + unlock=mock.Mock(), + ) + application = SimpleNamespace( + _notifyExistingInstance=mock.Mock( + return_value=_ExistingInstanceResult.Unreachable + ), + _singletonElectionLock=mock.Mock(return_value=electionLock), + _logElectionLockFailure=lambda lock: ( + SingletonApplication._logElectionLockFailure(lock) + ), + SingletonElectionLockTimeout=3000, + ) + + with self.assertLogs( + 'Furious.Application.DesktopApplication', level=logLevel + ): + shouldExit = SingletonApplication.shouldExitForExistingInstance( + application + ) + + self.assertTrue(shouldExit) + electionLock.unlock.assert_not_called() + + def testElectionLockUsesExplicitStaleIntervalInTemporaryDirectory(self): + """Configure Qt's automatic crashed-lock cleanup for this short transaction.""" + electionLock = mock.Mock() + application = SimpleNamespace( + serverName='test-server', + SingletonElectionLockStaleTime=30000, + ) + + with ( + mock.patch( + 'Furious.Application.DesktopApplication.QtCore.QStandardPaths.writableLocation', + return_value='temporary-root', + ), + mock.patch( + 'Furious.Application.DesktopApplication.QtCore.QLockFile', + return_value=electionLock, + ) as lockFactory, + ): + result = SingletonApplication._singletonElectionLock(application) + + self.assertIs(result, electionLock) + lockFactory.assert_called_once_with( + os.path.join('temporary-root', 'test-server.lock') + ) + electionLock.setStaleLockTime.assert_called_once_with(30000) + + def testRunAsWaitsForPrimaryDisconnectBeforeAllowingElection(self): + """Treat a completed RunAs disconnect as permission to attempt listen().""" + socket = mock.Mock() + socket.waitForConnected.return_value = True + socket.waitForDisconnected.return_value = True + application = SimpleNamespace( + serverName='test-server', + socket=socket, + ExistingInstanceConnectTimeout=1000, + RunAsHandoffTimeout=3000, + ) + + with ( + mock.patch( + 'Furious.Application.DesktopApplication.sys.argv', + ['furious', AppBuiltinCommand.RunAs.value], + ), + self.assertLogs('Furious.Application.DesktopApplication', level='INFO'), + ): + result = SingletonApplication._notifyExistingInstance(application) + + self.assertIs(result, _ExistingInstanceResult.RunAsHandoffAccepted) + socket.waitForDisconnected.assert_called_once_with(3000) + socket.write.assert_called_once_with(AppBuiltinCommand.RunAs.value.encode()) + + def testRunAsTimeoutKeepsReplacementFromStarting(self): + """Fail closed while the original primary still owns the endpoint.""" + socket = mock.Mock() + socket.waitForConnected.return_value = True + socket.waitForDisconnected.return_value = False + application = SimpleNamespace( + serverName='test-server', + socket=socket, + ExistingInstanceConnectTimeout=1000, + RunAsHandoffTimeout=3000, + ) + + with ( + mock.patch( + 'Furious.Application.DesktopApplication.sys.argv', + ['furious', AppBuiltinCommand.RunAs.value], + ), + self.assertLogs('Furious.Application.DesktopApplication', level='WARNING'), + ): + result = SingletonApplication._notifyExistingInstance(application) + + self.assertIs(result, _ExistingInstanceResult.CommandForwarded) + + def testRunAsElectionWaitsForActualEndpointRelease(self): + """Do not equate command-channel disconnect with endpoint ownership.""" + application = SimpleNamespace( + serverName='test-server', + _waitForRunAsEndpointRelease=mock.Mock(return_value=True), + _existingEndpointIsReachable=mock.Mock(), + _listenAsPrimaryInstance=mock.Mock(return_value=True), + ) + + result = SingletonApplication._electPrimaryUnderLock( + application, + _ExistingInstanceResult.RunAsHandoffAccepted, + ) + + self.assertIs(result, _SingletonStartupResult.Primary) + application._waitForRunAsEndpointRelease.assert_called_once_with() + application._existingEndpointIsReachable.assert_not_called() + + def testRunAsElectionFailsClosedWhenEndpointIsNotReleased(self): + """Reject overlap when the old process does not finish its handoff.""" + application = SimpleNamespace( + _waitForRunAsEndpointRelease=mock.Mock(return_value=False), + _listenAsPrimaryInstance=mock.Mock(), + ) + + with self.assertLogs('Furious.Application.DesktopApplication', level='ERROR'): + result = SingletonApplication._electPrimaryUnderLock( + application, + _ExistingInstanceResult.RunAsHandoffAccepted, + ) + + self.assertIs(result, _SingletonStartupResult.OwnershipUnresolved) + application._listenAsPrimaryInstance.assert_not_called() + + def testEmptyAndUnsupportedCommandsRemainSecondaryLaunches(self): + """Forward empty and future commands without creating another primary.""" + for arguments, expectedCommand in ( + (['furious'], AppBuiltinCommand.Empty.value), + (['furious', 'future-command'], 'future-command'), + ): + with self.subTest(arguments=arguments): + socket = mock.Mock() + socket.waitForConnected.return_value = True + application = SimpleNamespace( + serverName='test-server', + socket=socket, + ExistingInstanceConnectTimeout=1000, + RunAsHandoffTimeout=3000, + ) + + with mock.patch( + 'Furious.Application.DesktopApplication.sys.argv', arguments + ): + result = SingletonApplication._notifyExistingInstance(application) + + self.assertIs(result, _ExistingInstanceResult.CommandForwarded) + socket.write.assert_called_once_with(expectedCommand.encode()) + + def testUnreachableEndpointDoesNotWriteACommand(self): + """Report no primary when connectToServer cannot establish a channel.""" + socket = mock.Mock() + socket.waitForConnected.return_value = False + application = SimpleNamespace( + serverName='test-server', + socket=socket, + ExistingInstanceConnectTimeout=1000, + ) + + result = SingletonApplication._notifyExistingInstance(application) + + self.assertIs(result, _ExistingInstanceResult.Unreachable) + socket.write.assert_not_called() + + def testDisappearingConnectionFailsClosedWithoutStartingRecovery(self): + """Treat a failed command write as evidence of an existing owner.""" + socket = mock.Mock() + socket.waitForConnected.return_value = True + socket.write.return_value = -1 + socket.errorString.return_value = 'peer closed' + application = SimpleNamespace( + serverName='test-server', + socket=socket, + ExistingInstanceConnectTimeout=1000, + RunAsHandoffTimeout=3000, + ) + + with ( + mock.patch( + 'Furious.Application.DesktopApplication.sys.argv', + ['furious'], + ), + self.assertLogs('Furious.Application.DesktopApplication', level='WARNING'), + ): + probeResult = SingletonApplication._notifyExistingInstance(application) + + self.assertIs( + probeResult, + _ExistingInstanceResult.CommandDeliveryUncertain, + ) + + election = SimpleNamespace( + _notifyExistingInstance=mock.Mock(return_value=probeResult), + _singletonElectionLock=mock.Mock(), + ) + + self.assertTrue(SingletonApplication.shouldExitForExistingInstance(election)) + election._singletonElectionLock.assert_not_called() + + def testStartupResultMapsUncertainOwnershipToExit(self): + """Return the explicit primary state only after listen succeeds.""" + application = SimpleNamespace( + serverName='test-server', + _existingEndpointIsReachable=mock.Mock(return_value=False), + _listenAsPrimaryInstance=mock.Mock(return_value=True), + ) + + result = SingletonApplication._electPrimaryUnderLock( + application, + _ExistingInstanceResult.Unreachable, + ) + + self.assertIs(result, _SingletonStartupResult.Primary) + + def testRecoveryResultControlsFinalStartupDecision(self): + """Enter recovery only for the explicit stale-endpoint state.""" + electionLock = mock.Mock() + electionLock.tryLock.return_value = True + application = SimpleNamespace( + _notifyExistingInstance=mock.Mock( + return_value=_ExistingInstanceResult.Unreachable + ), + _singletonElectionLock=mock.Mock(return_value=electionLock), + _electPrimaryUnderLock=mock.Mock( + return_value=_SingletonStartupResult.RecoveryRequired + ), + _recoverStaleEndpointAndClaim=mock.Mock( + return_value=_SingletonStartupResult.Primary + ), + SingletonElectionLockTimeout=3000, + ) + + self.assertFalse( + SingletonApplication.shouldExitForExistingInstance(application) + ) + application._recoverStaleEndpointAndClaim.assert_called_once_with() + electionLock.unlock.assert_called_once_with() + + def testConcurrentProcessesElectExactlyOnePrimary(self): + """Exercise the real Qt local-server race with isolated processes.""" + serverName = f'furious-singleton-test-{uuid.uuid4()}' + script = textwrap.dedent(r""" + import os + import sys + import time + + os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen') + + from PySide6 import QtCore + + from Furious.Application.DesktopApplication import SingletonApplication + + + class RaceApplication(SingletonApplication): + @QtCore.Slot() + def handleNewConnection(self): + while self.server.hasPendingConnections(): + socket = self.server.nextPendingConnection() + + if socket is None: + continue + + socket.waitForReadyRead(1000) + socket.readAll() + socket.disconnectFromServer() + socket.deleteLater() + QtCore.QTimer.singleShot(100, self.quit) + + + server_name, barrier, participant = sys.argv[1:] + application = RaceApplication([sys.argv[0]]) + application.serverName = server_name + + with open(f'{barrier}.{participant}.ready', 'w', encoding='utf-8'): + pass + + deadline = time.monotonic() + 10 + + while not os.path.exists(barrier): + if time.monotonic() >= deadline: + raise RuntimeError('race barrier was not released') + + time.sleep(0.01) + + should_exit = application.shouldExitForExistingInstance() + print( + 'SINGLETON_RESULT:secondary' + if should_exit + else 'SINGLETON_RESULT:primary', + flush=True, + ) + + if not should_exit: + # Keep the authoritative endpoint alive until the contender + # connects. The longer timer is only a bounded test fallback. + QtCore.QTimer.singleShot(8000, application.quit) + application.exec() + application.server.close() + """) + environment = os.environ.copy() + environment['QT_QPA_PLATFORM'] = 'offscreen' + processes = [] + + with tempfile.TemporaryDirectory() as temporaryDirectory: + barrier = os.path.join(temporaryDirectory, 'start') + + try: + participants = ('one', 'two', 'three', 'four') + + for participant in participants: + processes.append( + subprocess.Popen( + [ + sys.executable, + '-c', + script, + serverName, + barrier, + participant, + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=environment, + text=True, + ) + ) + + deadline = time.monotonic() + 10 + readyPaths = tuple( + f'{barrier}.{participant}.ready' for participant in participants + ) + + while not all(os.path.exists(path) for path in readyPaths): + if time.monotonic() >= deadline: + self.fail('singleton race participants did not become ready') + + time.sleep(0.01) + + with open(barrier, 'w', encoding='utf-8'): + pass + + outputs = [] + + for process in processes: + stdout, stderr = process.communicate(timeout=15) + outputs.append(stdout) + self.assertEqual( + process.returncode, + 0, + f'child failed:\n{stdout}{stderr}', + ) + finally: + for process in processes: + if process.poll() is None: + process.terminate() + + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + + QLocalServer.removeServer(serverName) + + results = [ + line + for output in outputs + for line in output.splitlines() + if line.startswith('SINGLETON_RESULT:') + ] + + self.assertCountEqual( + results, + ( + 'SINGLETON_RESULT:primary', + 'SINGLETON_RESULT:secondary', + 'SINGLETON_RESULT:secondary', + 'SINGLETON_RESULT:secondary', + ), + outputs, + ) + + def testUnavailableTrayShowsMainWindowAndEnablesWindowQuit(self): + """Run normally without a desktop tray instead of rejecting Linux.""" + mainWindow = mock.Mock() + trayFactory = mock.Mock() + trayFactory.isSystemTrayAvailable.return_value = False + application = SimpleNamespace( + applyThemePreference=mock.Mock(), + mainWindow=None, + systemTray=None, + setQuitOnLastWindowClosed=mock.Mock(), + _cleanupUI=mock.Mock(), + ) + + with ( + mock.patch( + 'Furious.Application.DesktopApplication.MainWindow', + return_value=mainWindow, + ), + mock.patch('Furious.Application.DesktopApplication.TrayIcon', trayFactory), + mock.patch('Furious.Application.DesktopApplication.PLATFORM', 'Linux'), + ): + DesktopApplication._initializeUI(application) + + application.setQuitOnLastWindowClosed.assert_called_once_with(True) + mainWindow.show.assert_called_once_with() + self.assertIsNone(application.systemTray) + trayFactory.assert_not_called() + + def testAvailableTrayPreservesBackgroundApplicationBehavior(self): + """Retain the existing tray-owned startup path when a tray is present.""" + mainWindow = mock.Mock() + tray = mock.Mock() + trayFactory = mock.Mock(return_value=tray) + trayFactory.isSystemTrayAvailable.return_value = True + application = SimpleNamespace( + applyThemePreference=mock.Mock(), + mainWindow=None, + systemTray=None, + setQuitOnLastWindowClosed=mock.Mock(), + _cleanupUI=mock.Mock(), + ) + + with ( + mock.patch( + 'Furious.Application.DesktopApplication.MainWindow', + return_value=mainWindow, + ), + mock.patch('Furious.Application.DesktopApplication.TrayIcon', trayFactory), + mock.patch('Furious.Application.DesktopApplication.PLATFORM', 'Linux'), + ): + DesktopApplication._initializeUI(application) + + application.setQuitOnLastWindowClosed.assert_called_once_with(False) + mainWindow.show.assert_not_called() + tray.show.assert_called_once_with() + tray.setCustomToolTip.assert_called_once_with() + tray.bootstrap.assert_called_once_with() + def testSuccessfulRunAcquiresEveryStageOnceAndCleansUpInReverse(self): application, calls = self._application() diff --git a/tests/test_main_window_geometry.py b/tests/test_main_window_geometry.py index 4623ffc..434546c 100644 --- a/tests/test_main_window_geometry.py +++ b/tests/test_main_window_geometry.py @@ -27,6 +27,7 @@ from Furious.Controllers.SettingsController import SettingsController from Furious.Frozenlib import AppSettings from Furious.Service.LogManager import LogManager from Furious.Qt import AppQMainWindow +from Furious.Widget.NavigationView import NavigationView from Furious.Window.HomePage import HomePage from Furious.Window.LogPage import LogPage from Furious.Window.MainWindow import MainWindow @@ -268,6 +269,16 @@ class MainWindowGeometryTest(unittest.TestCase): with patch('Furious.Qt.QtWidgets.moveToCenter') as moveToCenter: window.show() + self.assertEqual( + sum( + isinstance(widget, MainWindow) + for widget in app.topLevelWidgets() + ), + 1, + ) + self.assertEqual(len(window.findChildren(NavigationView)), 1) + self.assertIs(window.centralWidget(), window.navigationView) + self.assertEqual(window.size(), window.DEFAULT_WINDOW_SIZE) moveToCenter.assert_called_once_with(window)