Large refactor + stability fixes for the external process control logic.

This commit is contained in:
Fabio Manganiello 2023-08-15 11:12:21 +02:00
parent 46245e851f
commit f51beb271e
Signed by untrusted user: blacklight
GPG key ID: D90FBA7F76362774
13 changed files with 273 additions and 60 deletions

View file

@ -1,10 +1,5 @@
import sys import sys
from platypush.app import main from platypush.runner import main
if __name__ == '__main__':
main(*sys.argv[1:]) main(*sys.argv[1:])
# vim:sw=4:ts=4:et:

View file

@ -0,0 +1,4 @@
from ._app import Application, main
__all__ = ["Application", "main"]

View file

@ -0,0 +1,7 @@
import sys
from ._app import main
if __name__ == '__main__':
sys.exit(main(*sys.argv[1:]))

View file

@ -1,24 +1,26 @@
from contextlib import contextmanager
import logging import logging
import os import os
import signal
import subprocess import subprocess
import sys import sys
from typing import Optional, Sequence from typing import Optional, Sequence
from .bus import Bus from platypush.bus import Bus
from .bus.redis import RedisBus from platypush.bus.redis import RedisBus
from .cli import parse_cmdline from platypush.cli import parse_cmdline
from .commands import CommandStream from platypush.commands import CommandStream
from .config import Config from platypush.config import Config
from .context import register_backends, register_plugins from platypush.context import register_backends, register_plugins
from .cron.scheduler import CronScheduler from platypush.cron.scheduler import CronScheduler
from .entities import init_entities_engine, EntitiesEngine from platypush.entities import init_entities_engine, EntitiesEngine
from .event.processor import EventProcessor from platypush.event.processor import EventProcessor
from .logger import Logger from platypush.logger import Logger
from .message.event import Event from platypush.message.event import Event
from .message.event.application import ApplicationStartedEvent from platypush.message.event.application import ApplicationStartedEvent
from .message.request import Request from platypush.message.request import Request
from .message.response import Response from platypush.message.response import Response
from .utils import get_enabled_plugins, get_redis_conf from platypush.utils import get_enabled_plugins, get_redis_conf
log = logging.getLogger('platypush') log = logging.getLogger('platypush')
@ -87,10 +89,6 @@ class Application:
""" """
self.pidfile = pidfile self.pidfile = pidfile
if pidfile:
with open(pidfile, 'w') as f:
f.write(str(os.getpid()))
self.bus: Optional[Bus] = None self.bus: Optional[Bus] = None
self.redis_queue = redis_queue or RedisBus.DEFAULT_REDIS_QUEUE self.redis_queue = redis_queue or RedisBus.DEFAULT_REDIS_QUEUE
self.config_file = config_file self.config_file = config_file
@ -98,7 +96,9 @@ class Application:
self._logsdir = ( self._logsdir = (
os.path.abspath(os.path.expanduser(logsdir)) if logsdir else None os.path.abspath(os.path.expanduser(logsdir)) if logsdir else None
) )
Config.init(self.config_file) Config.init(self.config_file)
Config.set('ctrl_sock', ctrl_sock)
if workdir: if workdir:
Config.set('workdir', os.path.abspath(os.path.expanduser(workdir))) Config.set('workdir', os.path.abspath(os.path.expanduser(workdir)))
@ -244,36 +244,66 @@ class Application:
def stop(self): def stop(self):
"""Stops the backends and the bus.""" """Stops the backends and the bus."""
from .plugins import RunnablePlugin from platypush.plugins import RunnablePlugin
log.info('Stopping the application') log.info('Stopping the application')
backends = (self.backends or {}).copy().values()
runnable_plugins = [
plugin
for plugin in get_enabled_plugins().values()
if isinstance(plugin, RunnablePlugin)
]
if self.backends: for backend in backends:
for backend in self.backends.values():
backend.stop() backend.stop()
for plugin in get_enabled_plugins().values(): for plugin in runnable_plugins:
if isinstance(plugin, RunnablePlugin):
plugin.stop() plugin.stop()
for backend in backends:
backend.wait_stop()
for plugin in runnable_plugins:
plugin.wait_stop()
if self.entities_engine:
self.entities_engine.stop()
self.entities_engine.wait_stop()
self.entities_engine = None
if self.cron_scheduler:
self.cron_scheduler.stop()
self.cron_scheduler.wait_stop()
self.cron_scheduler = None
if self.bus: if self.bus:
self.bus.stop() self.bus.stop()
self.bus = None self.bus = None
if self.cron_scheduler:
self.cron_scheduler.stop()
self.cron_scheduler = None
if self.entities_engine:
self.entities_engine.stop()
self.entities_engine = None
if self.start_redis: if self.start_redis:
self._stop_redis() self._stop_redis()
def run(self): log.info('Exiting application')
"""Start the daemon."""
from . import __version__ @contextmanager
def _open_pidfile(self):
if self.pidfile:
try:
with open(self.pidfile, 'w') as f:
f.write(str(os.getpid()))
except OSError as e:
log.warning('Failed to create PID file %s: %s', self.pidfile, e)
yield
if self.pidfile:
try:
os.remove(self.pidfile)
except OSError as e:
log.warning('Failed to remove PID file %s: %s', self.pidfile, e)
def _run(self):
from platypush import __version__
if not self.no_capture_stdout: if not self.no_capture_stdout:
sys.stdout = Logger(log.info) sys.stdout = Logger(log.info)
@ -312,9 +342,17 @@ class Application:
self.bus.poll() self.bus.poll()
except KeyboardInterrupt: except KeyboardInterrupt:
log.info('SIGINT received, terminating application') log.info('SIGINT received, terminating application')
# Ignore other SIGINT signals
signal.signal(signal.SIGINT, signal.SIG_IGN)
finally: finally:
self.stop() self.stop()
def run(self):
"""Run the application."""
with self._open_pidfile():
self._run()
def main(*args: str): def main(*args: str):
""" """
@ -327,12 +365,7 @@ def main(*args: str):
except KeyboardInterrupt: except KeyboardInterrupt:
pass pass
log.info('Application stopped')
return 0 return 0
if __name__ == '__main__':
sys.exit(main(*sys.argv[1:]))
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View file

@ -31,7 +31,7 @@ class CommandStream(ControllableProcess):
"""Close the client socket after this amount of seconds.""" """Close the client socket after this amount of seconds."""
def __init__(self, path: Optional[str] = None): def __init__(self, path: Optional[str] = None):
super().__init__(name='platypush-cmd-stream') super().__init__(name='platypush:cmd:stream')
self.path = os.path.abspath(os.path.expanduser(path or self._default_sock_path)) self.path = os.path.abspath(os.path.expanduser(path or self._default_sock_path))
self._sock: Optional[socket.socket] = None self._sock: Optional[socket.socket] = None
self._cmd_queue: Queue["Command"] = Queue() self._cmd_queue: Queue["Command"] = Queue()
@ -64,6 +64,7 @@ class CommandStream(ControllableProcess):
sock.bind(self.path) sock.bind(self.path)
os.chmod(self.path, 0o600) os.chmod(self.path, 0o600)
sock.listen(1) sock.listen(1)
self.start()
return self return self
def __exit__(self, *_, **__): def __exit__(self, *_, **__):

View file

@ -3,6 +3,5 @@ manifest:
install: install:
pip: pip:
- py-cpuinfo - py-cpuinfo
- psutil
package: platypush.plugins.system package: platypush.plugins.system
type: plugin type: plugin

View file

@ -1,5 +1,5 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from logging import getLogger import logging
from multiprocessing import Event, Process, RLock from multiprocessing import Event, Process, RLock
from os import getpid from os import getpid
from typing import Optional from typing import Optional
@ -17,8 +17,9 @@ class ControllableProcess(Process, ABC):
def __init__(self, *args, timeout: Optional[float] = None, **kwargs): def __init__(self, *args, timeout: Optional[float] = None, **kwargs):
kwargs['name'] = kwargs.get('name', self.__class__.__name__) kwargs['name'] = kwargs.get('name', self.__class__.__name__)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.timeout = timeout self.timeout = timeout
self.logger = getLogger(self.name) self.logger = logging.getLogger(self.name)
self._should_stop = Event() self._should_stop = Event()
self._stop_lock = RLock() self._stop_lock = RLock()
self._should_restart = False self._should_restart = False
@ -60,15 +61,13 @@ class ControllableProcess(Process, ABC):
:param timeout: The maximum time to wait for the process to stop. :param timeout: The maximum time to wait for the process to stop.
""" """
timeout = timeout if timeout is not None else self.timeout timeout = timeout if timeout is not None else self._kill_timeout
with self._stop_lock: with self._stop_lock:
self._should_stop.set() self._should_stop.set()
self.on_stop() self.on_stop()
if self.pid == getpid():
return # Prevent termination deadlock
try: try:
if self.pid != getpid():
self.wait_stop(timeout=timeout) self.wait_stop(timeout=timeout)
except TimeoutError: except TimeoutError:
pass pass
@ -76,6 +75,7 @@ class ControllableProcess(Process, ABC):
self.terminate() self.terminate()
try: try:
if self.pid != getpid():
self.wait_stop(timeout=self._kill_timeout) self.wait_stop(timeout=self._kill_timeout)
except TimeoutError: except TimeoutError:
self.logger.warning( self.logger.warning(

View file

@ -0,0 +1,17 @@
from ._runner import ApplicationRunner
def main(*args: str):
"""
Main application entry point.
This is usually the entry point that you want to use to start your
application, rather than :meth:`platypush.app.main`, as this entry point
wraps the main application in a controllable process.
"""
app_runner = ApplicationRunner()
app_runner.run(*args)
__all__ = ["ApplicationRunner", "main"]

View file

@ -0,0 +1,5 @@
import sys
from . import main
main(sys.argv[1:])

59
platypush/runner/_app.py Normal file
View file

@ -0,0 +1,59 @@
import logging
import os
import signal
import subprocess
import sys
from typing_extensions import override
from platypush.process import ControllableProcess
class ApplicationProcess(ControllableProcess):
"""
Controllable process wrapper interface for the main application.
"""
def __init__(self, *args: str, pidfile: str, **kwargs):
super().__init__(name='platypush', **kwargs)
self.logger = logging.getLogger('platypush')
self.args = args
self.pidfile = pidfile
def __enter__(self) -> "ApplicationProcess":
self.start()
return self
def __exit__(self, *_, **__):
self.stop()
@override
def main(self):
self.logger.info('Starting application...')
with subprocess.Popen(
['python', '-m', 'platypush.app', *self.args],
stdin=sys.stdin,
stdout=sys.stdout,
stderr=sys.stderr,
) as app:
try:
app.wait()
except KeyboardInterrupt:
pass
@override
def on_stop(self):
try:
with open(self.pidfile, 'r') as f:
pid = int(f.read().strip())
except (OSError, ValueError):
pid = None
if not pid:
return
try:
os.kill(pid, signal.SIGINT)
except OSError:
pass

View file

@ -0,0 +1,91 @@
import logging
import sys
from threading import Thread
from typing import Optional
from platypush.cli import parse_cmdline
from platypush.commands import CommandStream
from ._app import ApplicationProcess
class ApplicationRunner:
"""
Runner for the main application.
It wraps the main application and provides an interface to control it
externally.
"""
_default_timeout = 0.5
def __init__(self):
logging.basicConfig(level=logging.INFO, stream=sys.stdout)
self.logger = logging.getLogger('platypush:runner')
self._proc: Optional[ApplicationProcess] = None
def _listen(self, stream: CommandStream):
"""
Listens for external application commands and executes them.
"""
while self.is_running:
cmd = stream.read(timeout=self._default_timeout)
if not cmd:
continue
self.logger.info(cmd)
Thread(target=cmd, args=(self,)).start()
def _print_version(self):
from platypush import __version__
print(__version__)
sys.exit(0)
def _run(self, *args: str) -> None:
parsed_args = parse_cmdline(args)
if parsed_args.version:
self._print_version()
while True:
with (
CommandStream(parsed_args.ctrl_sock) as stream,
ApplicationProcess(
*args, pidfile=parsed_args.pidfile, timeout=self._default_timeout
) as self._proc,
):
try:
self._listen(stream)
except KeyboardInterrupt:
pass
if self.should_restart:
self.logger.info('Restarting application...')
continue
break
def run(self, *args: str) -> None:
try:
self._run(*args)
finally:
self._proc = None
def stop(self):
if self._proc is not None:
self._proc.stop()
def restart(self):
if self._proc is not None:
self._proc.mark_for_restart()
self.stop()
@property
def is_running(self) -> bool:
return bool(self._proc and self._proc.is_alive())
@property
def should_restart(self) -> bool:
return self._proc.should_restart if self._proc is not None else False

View file

@ -11,6 +11,7 @@ frozendict
marshmallow marshmallow
marshmallow_dataclass marshmallow_dataclass
paho-mqtt paho-mqtt
psutil
python-dateutil python-dateutil
python-magic python-magic
pyyaml pyyaml

View file

@ -68,6 +68,7 @@ setup(
'frozendict', 'frozendict',
'marshmallow', 'marshmallow',
'marshmallow_dataclass', 'marshmallow_dataclass',
'psutil',
'python-dateutil', 'python-dateutil',
'python-magic', 'python-magic',
'pyyaml', 'pyyaml',
@ -224,7 +225,7 @@ setup(
# Support for Graphite integration # Support for Graphite integration
'graphite': ['graphyte'], 'graphite': ['graphyte'],
# Support for CPU and memory monitoring and info # Support for CPU and memory monitoring and info
'sys': ['py-cpuinfo', 'psutil'], 'sys': ['py-cpuinfo'],
# Support for nmap integration # Support for nmap integration
'nmap': ['python-nmap'], 'nmap': ['python-nmap'],
# Support for zigbee2mqtt # Support for zigbee2mqtt