Harden desktop startup and singleton election

Use an atomic lock-backed election for primary-instance startup, distinguish live peers from stale endpoints, and bound handoff retries. Keep the application usable when a desktop has no system tray and cover first-launch races, stale recovery, and visible-window behavior.

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-08-24 18:40:52 +08:00
parent a7acc1ed3b
commit a91c006b87
4 changed files with 859 additions and 55 deletions
+5 -1
View File
@@ -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
+247 -54
View File
@@ -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
+596
View File
@@ -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()
+11
View File
@@ -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)