diff --git a/hbd/server/cli.py b/hbd/server/cli.py index 9bb01f2..23b35b8 100644 --- a/hbd/server/cli.py +++ b/hbd/server/cli.py @@ -75,6 +75,20 @@ def build_parser(): help="Notification title (default: '[LEVEL] test')", ) + # --- stop --- + stop_p = subparsers.add_parser("stop", help="Stop the running hbd instance") + stop_p.add_argument("-c", "--config", dest="configfile", help="Config file path (YAML)") + + # --- reload --- + reload_p = subparsers.add_parser("reload", help="Reload configuration (SIGHUP)") + reload_p.add_argument("-c", "--config", dest="configfile", help="Config file path (YAML)") + + # --- restart --- + restart_p = subparsers.add_parser("restart", help="Restart the running hbd instance") + restart_p.add_argument("-c", "--config", dest="configfile", help="Config file path (YAML)") + restart_p.add_argument("-f", "--foreground", action="store_true", help="Run in foreground after restart") + restart_p.add_argument("-v", "--verbose", action="store_true", help="Verbose output after restart") + return parser @@ -154,6 +168,96 @@ def cmd_notify(args): sys.exit(1) +def _read_pid(configfile) -> int | None: + """Return the PID from the pidfile, or None if not found / not running.""" + import os + config = load_config(configfile) + pidfile = config.get("pidfile", "") + if not pidfile: + print("Error: no pidfile configured.", file=sys.stderr) + return None + try: + with open(pidfile) as f: + pid = int(f.read().strip()) + # Verify process is actually running + os.kill(pid, 0) + return pid + except FileNotFoundError: + print(f"PID file not found ({pidfile}). Is hbd running?", file=sys.stderr) + return None + except ProcessLookupError: + print(f"PID file exists but process {pid} is not running.", file=sys.stderr) + return None + except Exception as e: + print(f"Error reading pidfile: {e}", file=sys.stderr) + return None + + +def cmd_stop(args): + import os, signal as _signal, time + pid = _read_pid(args.configfile) + if pid is None: + sys.exit(1) + print(f"Stopping hbd (pid {pid})...") + os.kill(pid, _signal.SIGTERM) + # Wait up to 10 s for the process to exit + for _ in range(20): + time.sleep(0.5) + try: + os.kill(pid, 0) + except ProcessLookupError: + print("hbd stopped.") + return + print("Warning: hbd did not stop within 10 seconds.", file=sys.stderr) + sys.exit(1) + + +def cmd_reload(args): + import os, signal as _signal + pid = _read_pid(args.configfile) + if pid is None: + sys.exit(1) + print(f"Sending SIGHUP to hbd (pid {pid})...") + os.kill(pid, _signal.SIGHUP) + print("Reload signal sent.") + + +def cmd_restart(args): + import os, signal as _signal, time, subprocess + pid = _read_pid(args.configfile) + if pid is not None: + print(f"Stopping hbd (pid {pid})...") + os.kill(pid, _signal.SIGTERM) + for _ in range(20): + time.sleep(0.5) + try: + os.kill(pid, 0) + except ProcessLookupError: + print("hbd stopped.") + break + else: + print("Warning: hbd did not stop within 10 seconds.", file=sys.stderr) + sys.exit(1) + else: + print("hbd does not appear to be running — starting fresh.") + + # Re-launch hbd with the same config + cmd = [sys.executable, "-m", "hbd.server.cli", "serve"] + if args.configfile: + cmd += ["-c", args.configfile] + if getattr(args, "foreground", False): + cmd += ["-f"] + if getattr(args, "verbose", False): + cmd += ["-v"] + + if getattr(args, "foreground", False): + # Run in foreground — replace current process + os.execv(sys.executable, cmd) + else: + subprocess.Popen(cmd, start_new_session=True) + print("hbd restarted.") + + def main(argv=None): parser = build_parser() args = parser.parse_args(argv) @@ -166,6 +270,18 @@ def main(argv=None): cmd_notify(args) return + if args.command == "stop": + cmd_stop(args) + return + + if args.command == "reload": + cmd_reload(args) + return + + if args.command == "restart": + cmd_restart(args) + return + # Default: run the server (supports both `hbd serve ...` and `hbd ...`) config = load_config(args.configfile) diff --git a/hbd/server/config.py b/hbd/server/config.py index f1e445e..494ac81 100644 --- a/hbd/server/config.py +++ b/hbd/server/config.py @@ -17,6 +17,7 @@ SERVER_DEFAULTS = { # Persistence "pickfile": os.path.join(os.path.expanduser("~"), ".hb.pick"), # File to store host state between restarts + "pidfile": os.path.join(os.path.expanduser("~"), ".hb.pid"), # PID file for stop/restart/reload # Logging "logfile": os.path.join(os.path.expanduser("~"), ".hb.log"), diff --git a/hbd/server/main.py b/hbd/server/main.py index 85cb201..9488ac9 100644 --- a/hbd/server/main.py +++ b/hbd/server/main.py @@ -475,6 +475,16 @@ def run(config, config_path=None): notify_mod.initlog(logfile=config.get("logfile", "messages.log")) users_mod.load_users(config) + + # Write pidfile + pidfile = config.get("pidfile", "") + if pidfile: + try: + with open(pidfile, "w") as f: + f.write(str(os.getpid())) + except Exception as e: + logger.warning("Failed to write pidfile %s: %s", pidfile, e) + eventlog(None, "INFO", f"hbd version {__version__} starting up") if config_path: @@ -497,6 +507,12 @@ def run(config, config_path=None): logger.info("hbd shutdown complete") eventlog(None, "INFO", f"hbd version {__version__} shutdown") notify_mod.closelog() + # Remove pidfile + if pidfile: + try: + os.unlink(pidfile) + except Exception: + pass # Explicitly close the loop try: # Cancel all remaining tasks