Background services without systemd (runit, OpenRC, ...)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
sarahandClaude Opus 5.5 committed 2026-10-13 17:51:30 +02:00
1 parent e7e233951f
commit f63e708572
8 files changed
+325 -110

No files matched your search

+86 -15
View File
@@ -1,33 +1,104 @@
"""Runs a SaraSuite program as a background service on Windows, the stand-in
for a systemd --user service. Started windowless with pythonw.exe, both at
login (by the program's shortcut in the Startup folder) and when SaraSuite
or the program's own GUI (e.g. SaraSort for SaraSortd) starts it:
"""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:
pythonw.exe ServiceRun.py <Name> <Script> (in the service's working folder)
python ServiceRun.py <Name> <Script> [<WorkingDir>]
It records its PID in %LOCALAPPDATA%\\SaraSuite\\Services\\<Name>.pid, so
anything can tell whether the service runs, and stop it (taskkill), without
a Windows service and without admin rights. Installed next to each program
that has a Windows service (Programs/<Name>/Manifest.toml, [[Services]]).
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])
LocalAppData = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local")
PidFile = LocalAppData / "SaraSuite" / "Services" / f"{Name}.pid"
PidFile.parent.mkdir(parents=True, exist_ok=True)
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:
sys.argv = [str(Script)]
sys.path.insert(0, str(Script.parent))
runpy.run_path(str(Script), run_name="__main__")
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()):