mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-10-05 05:17:59 +03:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user