#!/usr/bin/env python3 # Libervia: an XMPP client # Copyright (C) 2009-2026 Jérôme Poisson (goffi@goffi.org) # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero 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 Affero General Public License for more details. # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """Script launching Libervia backend""" import sys import os import argparse import shutil import tempfile from argparse import _SubParsersAction from pathlib import Path # We want to install asyncreactor before any import from twisted or Libervia to be sure # that the right reactor is used. import asyncio from twisted.internet import asyncioreactor # FIXME: In Python 3.14+, asyncio.get_event_loop() raises a RuntimeError and no longer # implicitly creates a new loop. This behavior was making the backend launch fail. So to # work around this, we create a new loop here, before installing asyncioreactor. A fix # is present in a non-released version of Twisted (commit ef6160a). Once the new version # is realeased and used in Libervia, we can remove this workaround. try: asyncio.get_event_loop() except RuntimeError: loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) asyncioreactor.install() from libervia.backend.core.constants import Const as C from libervia.backend.tools.config import parse_main_conf from twisted.application import app from twisted.python import usage class LiberviaLogger(app.AppLogger): def start(self, application): # logging is initialised by libervia.baceknd.core.log_config via the Twisted # plugin, nothing to do here self._initialLog() def stop(self): pass class Launcher: APP_NAME = C.APP_NAME APP_NAME_FILE = C.APP_NAME_FILE @property def NOT_RUNNING_MSG(self): return f"{self.APP_NAME} is *NOT* running" def __init__(self): try: self.config = parse_main_conf(log_filenames=True) except Exception as e: print(rf"/!\ Can't read main config! {e}") sys.exit(1) if C.isolation_mode == "tmp": self.clean_tmp_dir() if C.isolation_mode is not None: print( f"Isolation mode '{C.isolation_mode}' active " f"(instance: {C.isolation_name}, " f"config: {C.ISOLATED_CONFIG_DIR})" ) def clean_tmp_dir(self) -> None: """Remove temporary isolation directory on startup.""" tmp_path = C.ISOLATED_LOCAL_DIR if tmp_path is None: return # We want to be sure that we delete a temporary directory, and not accidentally # another local directory. if str(tmp_path).startswith(tempfile.gettempdir()): shutil.rmtree(tmp_path, ignore_errors=True) print(f"Cleaned temp isolation directory: {tmp_path}") def cmd_no_subparser(self, args): """Command launched by default""" args.extra_args = [] self.cmd_background(args) def cmd_background(self, args): self.run_twistd(args) def cmd_foreground(self, args): self.run_twistd(args, twistd_opts=["--nodaemon"]) def cmd_debug(self, args): self.run_twistd(args, twistd_opts=["--debug"]) def cmd_stop(self, args): import signal import time pid_file = self.get_pid_file() if not pid_file.is_file(): print(self.NOT_RUNNING_MSG) sys.exit(0) try: pid = int(pid_file.read_text()) except Exception as e: print(f"Can't read PID file at {pid_file}: {e}") # we use the same exit code as DATA_ERROR in CLI frontend sys.exit(17) print(f"Terminating {self.APP_NAME}…") os.kill(pid, signal.SIGTERM) kill_started = time.time() state = "init" import errno while True: try: os.kill(pid, 0) except OSError as e: if e.errno == errno.ESRCH: break elif e.errno == errno.EPERM: print( f"Can't kill {self.APP_NAME}, the process is owned by an other user", file=sys.stderr, ) sys.exit(18) else: raise e time.sleep(0.2) now = time.time() if state == "init" and now - kill_started > 5: if state == "init": state = "waiting" print(f"Still waiting for {self.APP_NAME} to be terminated…") elif state == "waiting" and now - kill_started > 10: state == "killing" print("Waiting for too long, we kill the process") os.kill(pid, signal.SIGKILL) sys.exit(1) sys.exit(0) def cmd_status(self, args): pid_file = self.get_pid_file() if pid_file.is_file(): import errno try: pid = int(pid_file.read_text()) except Exception as e: print(f"Can't read PID file at {pid_file}: {e}") # we use the same exit code as DATA_ERROR in CLI frontend sys.exit(17) # we check if there is a process # inspired by https://stackoverflow.com/a/568285 and https://stackoverflow.com/a/6940314 try: os.kill(pid, 0) except OSError as e: if e.errno == errno.ESRCH: running = False elif e.errno == errno.EPERM: print("Process {pid} is run by an other user") running = True else: running = True if running: print(f"{self.APP_NAME} is running (pid: {pid})") sys.exit(0) else: print( f"{self.NOT_RUNNING_MSG}, but a pid file is present (bad exit ?): {pid_file}" ) sys.exit(2) else: print(self.NOT_RUNNING_MSG) sys.exit(1) def parse_args(self) -> argparse.Namespace: """Parse command-line arguments and return the resulting namespace. Subclasses can override to add custom subparsers before calling ``parser.parse_args()``. @return: The configured argument parser. """ parser = argparse.ArgumentParser(description=f"Launch {self.APP_NAME} backend") parser.set_defaults(cmd=self.cmd_no_subparser) subparsers: _SubParsersAction = parser.add_subparsers() self.add_subparsers(subparsers) return parser.parse_args() def add_subparsers(self, subparsers: _SubParsersAction) -> None: """Add subparsers (bg, fg, debug, stop, status). @param subparsers: The subparsers object from the argument parser. """ extra_help = f"arguments to pass to {self.APP_NAME} service" bg_parser = subparsers.add_parser( "background", aliases=["bg"], help=f"run {self.APP_NAME} backend in background (as a daemon)", ) bg_parser.add_argument("extra_args", nargs=argparse.REMAINDER, help=extra_help) bg_parser.set_defaults(cmd=self.cmd_background) fg_parser = subparsers.add_parser( "foreground", aliases=["fg"], help=f"run {self.APP_NAME} backend in foreground", ) fg_parser.add_argument("extra_args", nargs=argparse.REMAINDER, help=extra_help) fg_parser.set_defaults(cmd=self.cmd_foreground) dbg_parser = subparsers.add_parser( "debug", aliases=["dbg"], help=f"run {self.APP_NAME} backend in debug mode" ) dbg_parser.add_argument("extra_args", nargs=argparse.REMAINDER, help=extra_help) dbg_parser.set_defaults(cmd=self.cmd_debug) stop_parser = subparsers.add_parser( "stop", help=f"stop running {self.APP_NAME} backend" ) stop_parser.set_defaults(cmd=self.cmd_stop) status_parser = subparsers.add_parser( "status", help=f"indicate if {self.APP_NAME} backend is running" ) status_parser.set_defaults(cmd=self.cmd_status) def get_pid_file(self) -> Path: return self.config.common.pid_dir / f"{self.APP_NAME_FILE}.pid" def wait_for_service( self, service_host: str, service_port: int, timeout: int, service_name: str ) -> None: """Waits for a network service to become available. @param service_host: The hostname or IP address of the service. @param service_port: The port number of the service. @param timeout: The maximum number of seconds to wait for the service. @param service_name: The name of the service. @raise TimeoutError: If the service is not available within the specified timeout. """ import socket import time start_time = time.time() wait_interval = 5 while True: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.settimeout(1) try: sock.connect((service_host, service_port)) return except socket.error: elapsed_time = time.time() - start_time if elapsed_time % wait_interval < 1: print(f"Waiting for {service_name}…") if elapsed_time > timeout: raise TimeoutError( f"{service_name} on {service_host}:{service_port} not " f"available after {timeout} seconds." ) time.sleep(1) def run_twistd(self, args, twistd_opts=None): """Run twistd settings options with args""" from twisted.python.runtime import platformType if platformType == "win32": from twisted.scripts._twistw import ( ServerOptions, WindowsApplicationRunner as app_runner, ) else: from twisted.scripts._twistd_unix import ( ServerOptions, UnixApplicationRunner as app_runner, ) app_runner.loggerFactory = LiberviaLogger server_options = ServerOptions() # wait for a service (e.g. XMPP server) wait_for_service_value = self.config.common.init_wait_for_service if wait_for_service_value is not None: try: # Syntax: [ipv6_address]:port[:timeout][:service_name] # or hostname:port[:timeout][:service_name] parts = wait_for_service_value.split(":") if parts[0] and parts[0][0] == "[" and parts[0][-1] == "]": # IPv6 address host = parts[0][1:-1] else: # Hostname or IPv4 host = parts[0] port = int(parts[1]) # Defaults timeout = 60 service_name = "service" if len(parts) > 2: timeout_part = parts[2] # Check if timeout is skipped (double colon for service_name) if timeout_part: timeout = int(timeout_part) if len(parts) > 3: service_name = parts[3] except (ValueError, IndexError): raise ValueError( f'Invalid "init_wait_for_service" value: {wait_for_service_value!r}' ) else: self.wait_for_service(host, port, timeout, service_name) pid_file = self.get_pid_file() log_dir = self.config.common.log_dir log_file = log_dir / f"{self.APP_NAME_FILE}.log" server_opts = [ "--no_save", "--pidfile", str(pid_file), "--logfile", str(log_file), ] if twistd_opts is not None: server_opts.extend(twistd_opts) server_opts.append(self.APP_NAME_FILE) if args.extra_args: try: args.extra_args.remove("--") except ValueError: pass server_opts.extend(args.extra_args) try: server_options.parseOptions(server_opts) except usage.error as ue: print(server_options) print("%s: %s" % (sys.argv[0], ue)) sys.exit(1) else: runner = app_runner(server_options) runner.run() if runner._exitSignal is not None: app._exitWithSignal(runner._exitSignal) # FIXME: The loop is not closing correctly as soon as # libervia.backend.tools.utils.aio decoractor is used. Asyncio loop doesn't # seem to have any task left, and reactor is stopped, but there is still # something preventing proper exit. `os._exit` is not great, but currently # the way to actually close the backend in this case. This needs further # investigation. try: os._exit(app._exit_code) except AttributeError: os._exit(C.EXIT_ERROR) @classmethod def run(cls): args = cls().parse_args() args.cmd(args) if __name__ == "__main__": Launcher.run()