diff --git a/Furious/Application/DesktopApplication.py b/Furious/Application/DesktopApplication.py index 173cc96..5135450 100644 --- a/Furious/Application/DesktopApplication.py +++ b/Furious/Application/DesktopApplication.py @@ -484,17 +484,16 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): if AppSettings.get('SystemProxyMode') == AppBuiltinProxyMode.Auto.value: SystemProxy.off() - SystemProxy.daemonOn_() - self._cleanupStack.register( - 'automatic system proxy', self._cleanupSystemProxy - ) + # sysproxy's change-notification daemon is a Windows-only resource. + # ConnectionController owns the configured OS proxy and turns it + # off during disconnection/shutdown on every platform. + if PLATFORM == 'Windows': + SystemProxy.daemonOn_() - @staticmethod - def _cleanupSystemProxy(): - """Release the exact automatic system-proxy integration.""" - SystemProxy.off() - SystemProxy.daemonOff() + self._cleanupStack.register( + 'Windows system proxy daemon', SystemProxy.daemonOff + ) def _initializeUI(self): """Create and bootstrap the application-owned main window and tray.""" diff --git a/Furious/Core/CoreProcessWorker.py b/Furious/Core/CoreProcessWorker.py index 5c120b8..16be32a 100644 --- a/Furious/Core/CoreProcessWorker.py +++ b/Furious/Core/CoreProcessWorker.py @@ -511,14 +511,12 @@ class ProcessOutputRedirector: time.sleep(MsgQueue.MSG_PRODUCE_THRESHOLD / 1000) - # TODO: try to resolve messages not shown in edge case - time.sleep(2) - msgThread = threading.Thread(target=produceMsg, daemon=True) msgThread.start() - # TODO: try to resolve messages not shown in edge case - time.sleep(2) - + # The reader starts at the beginning of the temporary file, so output + # written before its first scheduling slice is still consumed. Do not + # delay the actual core entry point: the parent startup contract already + # provides its bounded readiness interval. with tmpFileStream: entrypoint() diff --git a/tests/test_architecture_refactors.py b/tests/test_architecture_refactors.py index 828f801..3bd8def 100644 --- a/tests/test_architecture_refactors.py +++ b/tests/test_architecture_refactors.py @@ -30,11 +30,15 @@ from PySide6 import QtCore from types import SimpleNamespace from unittest import TestCase, mock +import importlib import os import sys +import tempfile import textwrap import subprocess +CoreProcessWorkerModule = importlib.import_module('Furious.Core.CoreProcessWorker') + class _Runtime(CoreRuntime): """Record exact runtime ownership without starting a host process.""" @@ -223,6 +227,140 @@ class ApplicationLifecycleTransactionTest(TestCase): runListener.assert_called_once_with() stopListener.assert_called_once_with() + def testSystemIntegrationSkipsWindowsProxyDaemonOnMacOS(self): + """Do not register a nonexistent native proxy-daemon resource on macOS.""" + cleanupStack = _ApplicationCleanupStack() + application = SimpleNamespace( + _cleanupStack=cleanupStack, + _sessionShutdownRequested=SimpleNamespace(emit=mock.Mock()), + setQuitOnLastWindowClosed=mock.Mock(), + ) + + with ( + mock.patch( + 'Furious.Application.DesktopApplication.Win32Session.set', + return_value=False, + ), + mock.patch( + 'Furious.Application.DesktopApplication.AppSettings.get', + return_value='Auto', + ), + mock.patch( + 'Furious.Application.DesktopApplication.PLATFORM', + 'Darwin', + ), + mock.patch( + 'Furious.Application.DesktopApplication.SystemProxy.off' + ) as proxyOff, + mock.patch( + 'Furious.Application.DesktopApplication.SystemProxy.daemonOn_' + ) as daemonOn, + mock.patch( + 'Furious.Application.DesktopApplication.SystemProxy.daemonOff' + ) as daemonOff, + ): + DesktopApplication._initializeSystemIntegration(application) + cleanupStack.close() + + proxyOff.assert_called_once_with() + daemonOn.assert_not_called() + daemonOff.assert_not_called() + + def testSystemIntegrationOwnsOnlyWindowsProxyDaemonCleanup(self): + """Keep OS proxy state under the connection controller's ownership.""" + cleanupStack = _ApplicationCleanupStack() + application = SimpleNamespace( + _cleanupStack=cleanupStack, + _sessionShutdownRequested=SimpleNamespace(emit=mock.Mock()), + setQuitOnLastWindowClosed=mock.Mock(), + ) + + with ( + mock.patch( + 'Furious.Application.DesktopApplication.Win32Session.set', + return_value=False, + ), + mock.patch( + 'Furious.Application.DesktopApplication.AppSettings.get', + return_value='Auto', + ), + mock.patch( + 'Furious.Application.DesktopApplication.PLATFORM', + 'Windows', + ), + mock.patch( + 'Furious.Application.DesktopApplication.SystemProxy.off' + ) as proxyOff, + mock.patch( + 'Furious.Application.DesktopApplication.SystemProxy.daemonOn_' + ) as daemonOn, + mock.patch( + 'Furious.Application.DesktopApplication.SystemProxy.daemonOff' + ) as daemonOff, + ): + DesktopApplication._initializeSystemIntegration(application) + cleanupStack.close() + + proxyOff.assert_called_once_with() + daemonOn.assert_called_once_with() + daemonOff.assert_called_once_with() + + def testProcessOutputRedirectorDoesNotDelayCoreEntrypoint(self): + """Begin the core immediately while its file reader drains early output.""" + + class _Stream: + def __init__(self, descriptor): + self.descriptor = descriptor + + def fileno(self): + return self.descriptor + + def close(self): + pass + + class _TemporaryDir: + def __init__(self, directory): + self.directory = directory + + def isValid(self): + return True + + def filePath(self, name): + return os.path.join(self.directory, name) + + with tempfile.TemporaryDirectory() as directory: + thread = SimpleNamespace(start=mock.Mock()) + entrypoint = mock.Mock() + fakeSys = SimpleNamespace(stdout=_Stream(1), stderr=_Stream(2)) + + with ( + mock.patch.object( + CoreProcessWorkerModule.SystemRuntime, + 'isPythonw', + return_value=False, + ), + mock.patch.object( + CoreProcessWorkerModule.ProcessOutputRedirector, + 'TemporaryDir', + _TemporaryDir(directory), + ), + mock.patch.object(CoreProcessWorkerModule, 'sys', fakeSys), + mock.patch.object(CoreProcessWorkerModule.os, 'dup2'), + mock.patch.object( + CoreProcessWorkerModule.threading, + 'Thread', + return_value=thread, + ), + mock.patch.object(CoreProcessWorkerModule.time, 'sleep') as sleep, + ): + CoreProcessWorkerModule.ProcessOutputRedirector.launch( + mock.Mock(), entrypoint, True + ) + + sleep.assert_not_called() + thread.start.assert_called_once_with() + entrypoint.assert_called_once_with() + def testFailuresAtMeaningfulStagesRollBackOnlyEarlierStages(self): expected = { 'storage': ['cleanup storage', 'cleanup plugins'],