# Copyright (C) 2024–present Loren Eteval & contributors # # This file is part of Furious. # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Provide process utilities for app main process.""" from __future__ import annotations from Furious.Frozenlib import * from Furious.Interface import * from typing import Callable import os import sys import time import signal import logging import datetime import operator import functools import traceback import multiprocessing __all__ = ['AppMainProcess'] logger = logging.getLogger(__name__) if PLATFORM == 'Windows': ProcessContext = multiprocessing else: ProcessContext = multiprocessing.get_context('spawn') class AppMainProcess(ProcessContext.Process): """Represent app main process.""" ShutdownTimeout = 10.0 ShutdownPollInterval = 0.1 KillJoinTimeout = 3.0 def __init__(self, func: Callable[[], ApplicationRunner], **kwargs): """Initialize the AppMainProcess.""" super().__init__(**kwargs) self.startupTime = str(datetime.datetime.now()).replace(':', '') self.logFileName = f'{self.startupTime}.log' # Shared scalars need neither a manager process nor a response from the # child's potentially blocked event loop during shutdown. self.fileWritten = ProcessContext.Value('b', False, lock=False) self._shutdownDeadline = ProcessContext.Value('d', 0.0, lock=False) self._shutdownExitCode = ProcessContext.Value('i', 0, lock=False) self.func = func self.application = None self.forcedExit = False def beginShutdown(self, exitcode=None): """Publish the semantic result and arm one deadline before cleanup starts.""" if exitcode is not None: self._shutdownExitCode.value = int(exitcode) if not self._shutdownDeadline.value: self._shutdownDeadline.value = time.monotonic() + self.ShutdownTimeout def waitForExit(self): """Join this child, forcing exit once its shutdown grace period expires.""" while self.is_alive(): deadline = self._shutdownDeadline.value if deadline and time.monotonic() >= deadline: exitcode = self._shutdownExitCode.value self.forcedExit = True logger.warning('application shutdown timed out; forcing process exit') # terminate() is SIGTERM on POSIX, which this child handles by # requesting graceful shutdown again. kill() cannot be caught. self.kill() self.join(self.KillJoinTimeout) if self.is_alive(): raise RuntimeError('application process survived forced shutdown') return exitcode self.join(self.ShutdownPollInterval) self.join(0) return self.exitcode def exceptHook(self, exceptionType, exceptionValue, tb, *, raiseSystemExit=False): """Handle except hook for the app main process.""" if logger.level < logging.CRITICAL: traceback.print_exception(exceptionType, exceptionValue, tb) if isinstance(exceptionValue, AssertionError): exitcode = ApplicationRunner.ExitCode.AssertionError.value else: exitcode = ApplicationRunner.ExitCode.UnknownException.value self.beginShutdown(exitcode) logger.error(f'stopped with exitcode {exitcode}') self.saveCrashLog(exceptionType, exceptionValue, tb) if not raiseSystemExit and APP() is not None: APP().exit(exitcode) else: sys.exit(exitcode) def saveCrashLog(self, exceptionType, exceptionValue, tb): """Save crash log.""" try: os.mkdir(CRASH_LOG_DIR) except FileExistsError: # Directory already exists pass except Exception: # Any non-exit exceptions return try: stackLog = functools.reduce( operator.add, traceback.format_exception(exceptionType, exceptionValue, tb), ) logManager = getattr(APP(), 'logManager', None) crashLog = ( f'{logManager.plainText()}\n{stackLog}' if logManager is not None else f'{stackLog}' ) with open(CRASH_LOG_DIR / self.logFileName, 'w', encoding='utf-8') as file: file.write(crashLog) self.fileWritten.value = True except Exception: # Any non-exit exceptions pass def handler(self, signum, frame): """Handle r.""" self.beginShutdown() logger.info(f'received signal {signal.Signals(signum).name}. Exit application') if self.application is None: sys.exit(ApplicationRunner.ExitCode.ExitSuccess.value) self.application.exit() def run(self): """Run the app main process task.""" sys.excepthook = self.exceptHook try: self.application = self.func() shutdownRequested = getattr(self.application, 'shutdownRequested', None) if shutdownRequested is not None: shutdownRequested.connect(self.beginShutdown) for sig in [signal.SIGTERM, signal.SIGINT]: signal.signal(sig, self.handler) exitcode = self.application.run() self.beginShutdown(exitcode) sys.exit(exitcode) except SystemExit as ex: self.beginShutdown( ex.code if isinstance(ex.code, int) else (0 if ex.code is None else 1) ) raise except Exception: # Any non-exit exceptions # Multiprocessing catches escaping failures before sys.excepthook. # The factory/run frame has ended, so no Qt loop can honor exit(). self.exceptHook(*sys.exc_info(), raiseSystemExit=True)