Files
SaraSuite/Programs/ServiceRun.py
T
2026-10-13 17:51:30 +02:00

112 lines
3.9 KiB
Python

"""Runs a SaraSuite program as a background service where there's no systemd
--user to do it: on Windows (started windowless with pythonw.exe), and on
Linux with another init system (runit on Void, OpenRC, s6, dinit, ...).
Started at login (Windows: a shortcut in the Startup folder; Linux: an XDG
autostart entry) and when SaraSuite or the program's own GUI (e.g. SaraSort
for SaraSortd) starts it:
python ServiceRun.py <Name> <Script> [<WorkingDir>]
Like the systemd unit it stands in for: one copy at a time; restarted 10
seconds after it fails (an exception or a non-zero exit), not after a clean
exit or a stop; its output goes to <Name>.log. Its PID is in <Name>.pid, so
anything can tell whether it runs, and stop it, without admin rights. Both
files are in SaraSuite's Services folder: %LOCALAPPDATA%\\SaraSuite\\Services
on Windows, ~/.local/share/SaraSuite/Services on Linux.
Installed next to each program that has a service (Programs/<Name>/Manifest.toml).
"""
import os
import runpy
import signal
import sys
import time
import traceback
from datetime import datetime
from pathlib import Path
IsWindows = sys.platform == "win32"
RestartSeconds = 10 # the unit's RestartSec
MaxLogBytes = 1_000_000 # then the log starts over, the old one kept as <Name>.log.1
Stopping = False
def ServicesDir() -> Path:
if IsWindows:
Base = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local")
else:
Base = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share")
return Base / "SaraSuite" / "Services"
def _AlreadyRunning(PidFile: Path) -> bool:
"""Linux: whether the PID file's process is another ServiceRun.py still
running (not a reused PID). Windows relies on whoever starts it to check."""
if IsWindows:
return False
try:
Pid = int(PidFile.read_text().strip())
if Pid == os.getpid():
return False
return b"ServiceRun.py" in Path(f"/proc/{Pid}/cmdline").read_bytes()
except (OSError, ValueError):
return False
def _Stop(Signal, Frame) -> None:
global Stopping
Stopping = True
raise SystemExit(0)
def Main() -> None:
Name, Script = sys.argv[1], Path(sys.argv[2])
if len(sys.argv) > 3:
Folder = Path(sys.argv[3])
Folder.mkdir(parents=True, exist_ok=True)
os.chdir(Folder)
Folder = ServicesDir()
Folder.mkdir(parents=True, exist_ok=True)
# Not an f-string: SaraSuite fills in braced names like that one when it places this file.
PidFile = Folder / (Name + ".pid")
if _AlreadyRunning(PidFile):
return
LogFile = Folder / (Name + ".log")
if LogFile.exists() and LogFile.stat().st_size > MaxLogBytes:
LogFile.replace(LogFile.with_suffix(".log.1"))
sys.stdout = sys.stderr = open(LogFile, "a", buffering=1, encoding="utf-8", errors="replace")
PidFile.write_text(str(os.getpid()))
if not IsWindows:
signal.signal(signal.SIGTERM, _Stop) # a stop: finish cleanly, don't restart
sys.path.insert(0, str(Script.parent))
try:
while True:
print(f"== {datetime.now():%Y-%m-%d %H:%M:%S} starting {Script.name}")
try:
sys.argv = [str(Script)]
runpy.run_path(str(Script), run_name="__main__")
break
except SystemExit as Exit:
if Stopping or Exit.code in (None, 0):
break
print(f"== {Script.name} exited with {Exit.code}")
except Exception:
traceback.print_exc()
print(f"== restarting in {RestartSeconds} seconds")
time.sleep(RestartSeconds)
finally:
print(f"== {datetime.now():%Y-%m-%d %H:%M:%S} stopped")
# Only remove it if it's still ours (a newer start may have replaced it).
try:
if PidFile.read_text().strip() == str(os.getpid()):
PidFile.unlink()
except OSError:
pass
if __name__ == "__main__":
Main()