Skip to content

Commit 89ca085

Browse files
mforcemforce
authored andcommitted
feat: add --run-as-service for background daemon mode
Adds a `service` module and wires `launcher.main()` to support running Thoth detached from the terminal so closing the shell does not shut the app down. New flags: --run-as-service POSIX double-fork; writes PID to $THOTH_DATA_DIR/service.pid and redirects stdout/stderr to $THOTH_DATA_DIR/service.log. Forces --server --no-tray --no-open --no-splash. --service-stop SIGTERM (then SIGKILL after 10s) the daemon. --service-status Print running/stopped + PID. --service-restart Stop existing daemon, then start a new one. --install-systemd-service Write ~/.config/systemd/user/thoth.service (Type=simple) for a cleaner long-term setup. --pid-file / --service-log Override default paths. Windows is not supported by --run-as-service; users get a message pointing them to Task Scheduler / NSSM. The existing bin/thoth wrapper already forwards extra args, so no installer change is needed.
1 parent 542bc10 commit 89ca085

4 files changed

Lines changed: 443 additions & 0 deletions

File tree

installer/thoth_setup.iss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ Source: "..\voice.py"; DestDir: "{app}\app"; Flags: ignoreversio
6969
Source: "..\tts.py"; DestDir: "{app}\app"; Flags: ignoreversion
7070
Source: "..\vision.py"; DestDir: "{app}\app"; Flags: ignoreversion
7171
Source: "..\launcher.py"; DestDir: "{app}\app"; Flags: ignoreversion
72+
Source: "..\service.py"; DestDir: "{app}\app"; Flags: ignoreversion
7273
Source: "..\notifications.py"; DestDir: "{app}\app"; Flags: ignoreversion
7374
Source: "..\prompts.py"; DestDir: "{app}\app"; Flags: ignoreversion
7475
Source: "..\requirements.txt"; DestDir: "{app}\app"; Flags: ignoreversion

launcher.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from pathlib import Path
2828
from typing import TYPE_CHECKING
2929

30+
import service
3031
from app_port import DEFAULT_APP_PORT, THOTH_HOST_ENV, THOTH_PORT_ENV, parse_app_port
3132

3233
if TYPE_CHECKING:
@@ -1504,6 +1505,27 @@ def _build_arg_parser() -> argparse.ArgumentParser:
15041505
parser.add_argument("--no-ollama", action="store_true", help="Do not try to auto-start Ollama")
15051506
parser.add_argument("--port", type=int, default=_PORT, help=f"Preferred app port (default: {_PORT})")
15061507
parser.add_argument("--host", default=None, help="Host/interface for the NiceGUI server")
1508+
1509+
svc = parser.add_argument_group("service mode (Linux/macOS)")
1510+
svc.add_argument(
1511+
"--run-as-service",
1512+
action="store_true",
1513+
help="Detach from the terminal and run Thoth in the background (writes PID and log files)",
1514+
)
1515+
svc.add_argument("--service-stop", action="store_true", help="Stop the running Thoth service")
1516+
svc.add_argument("--service-status", action="store_true", help="Print Thoth service status")
1517+
svc.add_argument(
1518+
"--service-restart",
1519+
action="store_true",
1520+
help="Stop the running Thoth service (if any) and start a new one as a daemon",
1521+
)
1522+
svc.add_argument(
1523+
"--install-systemd-service",
1524+
action="store_true",
1525+
help="Install a user-level systemd unit at ~/.config/systemd/user/thoth.service",
1526+
)
1527+
svc.add_argument("--pid-file", default=None, help="Override PID file path for service mode")
1528+
svc.add_argument("--service-log", default=None, help="Override log file path for service mode")
15071529
return parser
15081530

15091531

@@ -1521,9 +1543,62 @@ def quit_for_update() -> None:
15211543

15221544
# ── Main ─────────────────────────────────────────────────────────────────────
15231545

1546+
def _resolve_service_paths(args: argparse.Namespace) -> tuple[Path, Path]:
1547+
pid_path = Path(args.pid_file) if args.pid_file else service.default_pid_path()
1548+
log_path = Path(args.service_log) if args.service_log else service.default_log_path()
1549+
return pid_path, log_path
1550+
1551+
1552+
def _handle_service_management(args: argparse.Namespace) -> bool:
1553+
"""Run a stop/status/install command if requested. Returns True if handled."""
1554+
pid_path, _log_path = _resolve_service_paths(args)
1555+
1556+
if args.service_status:
1557+
print(service.status_message(pid_path))
1558+
return True
1559+
if args.service_stop:
1560+
print(service.stop_service(pid_path))
1561+
return True
1562+
if args.install_systemd_service:
1563+
unit = service.install_systemd_unit()
1564+
print(f"Wrote {unit}")
1565+
print("Enable with:")
1566+
print(" systemctl --user daemon-reload")
1567+
print(" systemctl --user enable --now thoth.service")
1568+
print("View logs with: journalctl --user -u thoth.service -f")
1569+
return True
1570+
return False
1571+
1572+
1573+
def _force_service_flags(args: argparse.Namespace) -> None:
1574+
"""Apply the implicit options that ``--run-as-service`` requires."""
1575+
args.no_tray = True
1576+
args.no_open = True
1577+
args.no_splash = True
1578+
args.server = True
1579+
1580+
15241581
def main(argv: list[str] | None = None) -> None:
15251582
global _ACTIVE_TRAY
15261583
args = _build_arg_parser().parse_args(argv)
1584+
1585+
if _handle_service_management(args):
1586+
return
1587+
1588+
if args.service_restart:
1589+
pid_path, _ = _resolve_service_paths(args)
1590+
print(service.stop_service(pid_path))
1591+
args.run_as_service = True
1592+
1593+
if args.run_as_service:
1594+
pid_path, log_path = _resolve_service_paths(args)
1595+
print(f"Starting Thoth as a service (logs: {log_path}). Stop with: thoth --service-stop")
1596+
sys.stdout.flush()
1597+
service.daemonize(pid_path, log_path)
1598+
_force_service_flags(args)
1599+
service.install_signal_handlers(lambda: service.remove_pid(pid_path))
1600+
atexit.register(service.remove_pid, pid_path)
1601+
15271602
preferred_mode = "browser" if args.browser else "native" if args.native else None
15281603
linux_default_direct = sys.platform.startswith("linux") and not args.tray
15291604
direct = args.server or args.no_tray or linux_default_direct

service.py

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
"""Background-service helpers for Thoth.
2+
3+
Provides POSIX daemonization, PID-file management, status/stop helpers, and a
4+
generator for a user-level systemd unit. Used by ``launcher.py`` to implement
5+
``--run-as-service`` and the related management flags.
6+
7+
Windows is intentionally not supported here; on Windows ``--run-as-service``
8+
prints guidance to use Task Scheduler or NSSM instead of attempting a fragile
9+
detach.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import errno
15+
import logging
16+
import os
17+
import signal
18+
import sys
19+
import time
20+
from pathlib import Path
21+
22+
logger = logging.getLogger(__name__)
23+
24+
25+
def _data_dir() -> Path:
26+
return Path(os.environ.get("THOTH_DATA_DIR", Path.home() / ".thoth"))
27+
28+
29+
def default_pid_path() -> Path:
30+
return _data_dir() / "service.pid"
31+
32+
33+
def default_log_path() -> Path:
34+
return _data_dir() / "service.log"
35+
36+
37+
def read_pid(pid_path: Path) -> int | None:
38+
try:
39+
text = pid_path.read_text().strip()
40+
except FileNotFoundError:
41+
return None
42+
except OSError:
43+
return None
44+
try:
45+
return int(text)
46+
except ValueError:
47+
return None
48+
49+
50+
def is_alive(pid: int) -> bool:
51+
if pid <= 0:
52+
return False
53+
try:
54+
os.kill(pid, 0)
55+
except ProcessLookupError:
56+
return False
57+
except PermissionError:
58+
# Process exists but is owned by someone else — treat as alive.
59+
return True
60+
return True
61+
62+
63+
def write_pid(pid_path: Path, pid: int) -> None:
64+
pid_path.parent.mkdir(parents=True, exist_ok=True)
65+
pid_path.write_text(f"{pid}\n", encoding="utf-8")
66+
67+
68+
def remove_pid(pid_path: Path) -> None:
69+
try:
70+
pid_path.unlink()
71+
except FileNotFoundError:
72+
pass
73+
74+
75+
def daemonize(pid_path: Path, log_path: Path) -> None:
76+
"""Detach from the controlling terminal using the standard double-fork.
77+
78+
On return, the calling process is the daemon child: it has no controlling
79+
terminal, ``stdin`` is ``/dev/null``, and ``stdout``/``stderr`` are
80+
appended to ``log_path``. The PID of the daemon is written to ``pid_path``.
81+
82+
Refuses to start a second daemon if ``pid_path`` already references a live
83+
process; raises :class:`SystemExit` with a friendly message in that case.
84+
"""
85+
if os.name != "posix":
86+
raise SystemExit(
87+
"--run-as-service is only supported on Linux/macOS. "
88+
"On Windows, use Task Scheduler or NSSM to run thoth in the background."
89+
)
90+
91+
existing = read_pid(pid_path)
92+
if existing is not None and is_alive(existing):
93+
raise SystemExit(f"Thoth service is already running (PID {existing}).")
94+
95+
pid_path.parent.mkdir(parents=True, exist_ok=True)
96+
log_path.parent.mkdir(parents=True, exist_ok=True)
97+
98+
# First fork — let the parent return so the shell prompt comes back.
99+
if os.fork() > 0:
100+
os._exit(0)
101+
102+
# New session, no controlling TTY.
103+
os.setsid()
104+
105+
# Second fork — guarantees we cannot re-acquire a TTY.
106+
if os.fork() > 0:
107+
os._exit(0)
108+
109+
os.chdir("/")
110+
os.umask(0o022)
111+
112+
# Redirect standard fds.
113+
sys.stdout.flush()
114+
sys.stderr.flush()
115+
116+
with open(os.devnull, "rb") as devnull_in:
117+
os.dup2(devnull_in.fileno(), sys.stdin.fileno())
118+
119+
log_fd = os.open(
120+
str(log_path),
121+
os.O_WRONLY | os.O_CREAT | os.O_APPEND,
122+
0o644,
123+
)
124+
os.dup2(log_fd, sys.stdout.fileno())
125+
os.dup2(log_fd, sys.stderr.fileno())
126+
os.close(log_fd)
127+
128+
write_pid(pid_path, os.getpid())
129+
130+
131+
def install_signal_handlers(on_term) -> None:
132+
"""Install SIGTERM/SIGINT handlers that delegate to ``on_term``.
133+
134+
``on_term`` is invoked with no arguments and should perform a graceful
135+
shutdown. The handler raises :class:`KeyboardInterrupt` afterwards so that
136+
existing ``except KeyboardInterrupt`` blocks still trigger.
137+
"""
138+
def _handler(signum, _frame): # pragma: no cover - exercised via signals
139+
logger.info("Received signal %s; shutting down", signum)
140+
try:
141+
on_term()
142+
finally:
143+
raise KeyboardInterrupt
144+
145+
if hasattr(signal, "SIGTERM"):
146+
signal.signal(signal.SIGTERM, _handler)
147+
148+
149+
def stop_service(pid_path: Path, timeout: float = 10.0) -> str:
150+
pid = read_pid(pid_path)
151+
if pid is None:
152+
return "Thoth service is not running (no PID file)."
153+
if not is_alive(pid):
154+
remove_pid(pid_path)
155+
return f"Thoth service is not running (stale PID file removed; was PID {pid})."
156+
157+
try:
158+
os.kill(pid, signal.SIGTERM)
159+
except ProcessLookupError:
160+
remove_pid(pid_path)
161+
return f"Thoth service was not running (PID {pid})."
162+
except PermissionError as exc:
163+
return f"Cannot signal PID {pid}: {exc}."
164+
165+
deadline = time.monotonic() + timeout
166+
while time.monotonic() < deadline:
167+
if not is_alive(pid):
168+
remove_pid(pid_path)
169+
return f"Stopped Thoth service (PID {pid})."
170+
time.sleep(0.2)
171+
172+
try:
173+
os.kill(pid, signal.SIGKILL)
174+
except ProcessLookupError:
175+
pass
176+
except OSError as exc:
177+
if exc.errno != errno.ESRCH:
178+
return f"Failed to force-kill PID {pid}: {exc}."
179+
180+
remove_pid(pid_path)
181+
return f"Force-killed Thoth service (PID {pid}) after {timeout:.0f}s timeout."
182+
183+
184+
def status_message(pid_path: Path) -> str:
185+
pid = read_pid(pid_path)
186+
if pid is None:
187+
return "Thoth service: stopped."
188+
if is_alive(pid):
189+
return f"Thoth service: running (PID {pid}, pidfile {pid_path})."
190+
return f"Thoth service: stopped (stale PID file at {pid_path}, was PID {pid})."
191+
192+
193+
_SYSTEMD_UNIT_TEMPLATE = """[Unit]
194+
Description=Thoth — local-first AI assistant
195+
After=network-online.target
196+
Wants=network-online.target
197+
198+
[Service]
199+
Type=simple
200+
ExecStart={launch_cmd} --server --no-tray --no-open --no-splash
201+
Restart=on-failure
202+
RestartSec=5
203+
Environment=PYTHONUNBUFFERED=1
204+
205+
[Install]
206+
WantedBy=default.target
207+
"""
208+
209+
210+
def install_systemd_unit(
211+
launch_cmd: str | None = None,
212+
unit_path: Path | None = None,
213+
) -> Path:
214+
"""Write a user-level systemd unit for Thoth and return its path.
215+
216+
Caller-controlled ``launch_cmd`` is interpolated verbatim into ``ExecStart``;
217+
if omitted, this resolves the absolute path to the ``thoth`` launcher
218+
(``~/.local/bin/thoth`` if available, otherwise the value of ``sys.argv[0]``).
219+
"""
220+
if sys.platform.startswith("win"):
221+
raise SystemExit("Systemd is Linux-only. Use --run-as-service or Task Scheduler on other platforms.")
222+
223+
if launch_cmd is None:
224+
candidate = Path.home() / ".local" / "bin" / "thoth"
225+
launch_cmd = str(candidate) if candidate.exists() else os.path.abspath(sys.argv[0])
226+
227+
target = unit_path or (Path.home() / ".config" / "systemd" / "user" / "thoth.service")
228+
target.parent.mkdir(parents=True, exist_ok=True)
229+
target.write_text(_SYSTEMD_UNIT_TEMPLATE.format(launch_cmd=launch_cmd), encoding="utf-8")
230+
return target

0 commit comments

Comments
 (0)