SaraSortd's service 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 826216cd03
commit b5d2fe0f1c
5 files changed
+137 -64

No files matched your search

+116 -54
View File
@@ -1,22 +1,29 @@
"""SaraSortd as a background service: status, start/stop/restart, autostart,
and its journal. SaraSortd's own log files are read from its LogDir.
and its logs. SaraSortd's own log files are read from its LogDir.
Linux: its systemd user unit (systemctl / journalctl).
Windows: SaraSuite's stand-in for a systemd service, used the same way here:
- it runs windowless as `pythonw.exe ServiceRun.py SaraSortd SaraSortd.py`
from SaraSortd's config folder, and ServiceRun.py records its PID in
%LOCALAPPDATA%\\SaraSuite\\Services\\SaraSortd.pid (that's how "running" is told);
- autostart is a shortcut to that command in the Startup folder.
Unit is the systemd unit on Linux and the service name ("SaraSortd") on Windows.
Linux with systemd: its systemd user unit (systemctl / journalctl).
Windows, and Linux without systemd (runit, OpenRC, s6, ...): SaraSuite runs
it itself (its Services.py), and this does the same:
- it runs as `python ServiceRun.py SaraSortd SaraSortd.py <ConfigDir>`
(pythonw.exe on Windows); ServiceRun.py restarts it if it fails, keeps its
output in SaraSuite's Services folder as SaraSortd.log (the "Service log"
here) and records its PID in SaraSortd.pid (that's how "running" is told);
- autostart is a shortcut to that command in the Startup folder (Windows)
or an XDG autostart entry, ~/.config/autostart/SaraSortd.desktop (Linux).
Unit is the systemd unit name ("SaraSortd.service"); run by SaraSuite, the
service is called the same without ".service".
"""
import os
import re
import signal
import subprocess
import time
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from Core import ChildEnvironment, ConfigHome, DataHome, IsWindows, LoadConfig
from Core import AutostartDir, ChildEnvironment, ConfigHome, DataHome, IsWindows, LoadConfig, RunBySaraSuite
@dataclass
@@ -41,90 +48,142 @@ def _Run(Arguments: list[str], Timeout: int = 15) -> subprocess.CompletedProcess
creationflags=_NoWindow)
# --- Windows ------------------------------------------------------------------------------
# --- Run by SaraSuite (Windows; Linux without systemd) ------------------------------------------
def _PidFile(Name: str) -> Path:
return DataHome / "SaraSuite" / "Services" / f"{Name}.pid"
def _Name(Unit: str) -> str:
return Unit.removesuffix(".service")
def _StartupLink(Name: str) -> Path:
return ConfigHome / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup" / f"{Name}.lnk"
def _ServicesFile(Name: str, Suffix: str) -> Path:
return DataHome / "SaraSuite" / "Services" / f"{Name}{Suffix}"
def _WindowsCommand() -> tuple[Path, list[str], Path]:
"""(pythonw.exe, its arguments, working folder), from SaraSuite's install layout."""
def _AutostartFile(Name: str) -> Path:
if IsWindows:
return ConfigHome / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup" / f"{Name}.lnk"
return AutostartDir / f"{Name}.desktop"
def _Command(Name: str) -> tuple[Path, list[str], Path]:
"""(python, its arguments, working folder), from SaraSuite's install layout."""
Config = LoadConfig()
AppDir = Path(Config.SortdAppDir).expanduser()
ProgramDir = AppDir.parent
PythonW = ProgramDir / ".venv" / "Scripts" / "pythonw.exe"
Arguments = ["-X", "utf8", str(ProgramDir / "ServiceRun.py"), Config.ServiceName, str(Config.SortdScriptPath)]
return PythonW, Arguments, Path(Config.SortdConfigDir).expanduser()
ProgramDir = Path(Config.SortdAppDir).expanduser().parent
Folder = Path(Config.SortdConfigDir).expanduser()
if IsWindows:
Python = ProgramDir / ".venv" / "Scripts" / "pythonw.exe"
Arguments = ["-X", "utf8"]
else:
Python = ProgramDir / ".venv" / "bin" / "python"
Arguments = []
Arguments += [str(ProgramDir / "ServiceRun.py"), Name, str(Config.SortdScriptPath), str(Folder)]
return Python, Arguments, Folder
def _WindowsPid(Name: str) -> str:
def _Pid(Name: str) -> str:
try:
Pid = int(_ServicesFile(Name, ".pid").read_text().strip())
except (OSError, ValueError):
return ""
if not IsWindows:
try:
return str(Pid) if b"ServiceRun.py" in Path(f"/proc/{Pid}/cmdline").read_bytes() else ""
except OSError:
return ""
try:
Pid = int(_PidFile(Name).read_text().strip())
Output = _Run(["tasklist", "/FI", f"PID eq {Pid}", "/NH", "/FO", "CSV"]).stdout
except (OSError, ValueError, subprocess.SubprocessError):
except (OSError, subprocess.SubprocessError):
return ""
return str(Pid) if re.search(rf'"{Pid}"', Output) else ""
def _WindowsStatus(Name: str) -> ServiceStatus:
PythonW, Arguments, _Folder = _WindowsCommand()
Pid = _WindowsPid(Name)
def _OwnStatus(Name: str) -> ServiceStatus:
Python, Arguments, _Folder = _Command(Name)
Pid = _Pid(Name)
Since = ""
if Pid:
Since = datetime.fromtimestamp(_PidFile(Name).stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")
Since = datetime.fromtimestamp(_ServicesFile(Name, ".pid").stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")
Runner = Path(Arguments[-4])
return ServiceStatus(
Active="active" if Pid else "inactive",
Sub="running" if Pid else "dead",
Enabled="enabled" if _StartupLink(Name).exists() else "disabled",
Enabled="enabled" if _AutostartFile(Name).exists() else "disabled",
Pid=Pid,
Since=Since,
Found=PythonW.is_file() and Path(Arguments[2]).is_file(),
Found=Python.is_file() and Runner.is_file(),
)
def _WindowsControl(Name: str, Action: str) -> str:
PythonW, Arguments, Folder = _WindowsCommand()
def _AutostartEntry(Python: Path, Arguments: list[str], Name: str) -> str:
"""The same entry SaraSuite writes (its Services.AutostartEntry)."""
def Quote(Text: str) -> str:
Text = Text.replace("%", "%%")
return Text if re.fullmatch(r"[\w@%+=:,./-]+", Text) else '"' + re.sub(r'(["`$\\])', r"\\\1", Text) + '"'
return (
"# Written by SaraSuite: starts this background service at login on systems\n"
"# without systemd (runit, OpenRC, s6, ...). Removed with the program.\n"
"[Desktop Entry]\n"
"Type=Application\n"
f"Name={Name}\n"
f"Comment={Name} (background service, run by SaraSuite)\n"
f"Exec={' '.join(Quote(Part) for Part in [str(Python), *Arguments])}\n"
"Terminal=false\n"
"NoDisplay=true\n"
"X-GNOME-Autostart-enabled=true\n"
)
def _OwnControl(Name: str, Action: str) -> str:
Python, Arguments, Folder = _Command(Name)
try:
if Action in ("stop", "restart"):
Pid = _WindowsPid(Name)
if Pid:
Pid = _Pid(Name)
if Pid and IsWindows:
_Run(["taskkill", "/PID", Pid, "/T", "/F"])
_PidFile(Name).unlink(missing_ok=True)
elif Pid:
os.kill(int(Pid), signal.SIGTERM) # ServiceRun.py finishes cleanly
for _ in range(50):
if not _Pid(Name):
break
time.sleep(0.2)
else:
os.kill(int(Pid), signal.SIGKILL)
_ServicesFile(Name, ".pid").unlink(missing_ok=True)
if Action in ("start", "restart"):
if _WindowsPid(Name):
if _Pid(Name):
return ""
Folder.mkdir(parents=True, exist_ok=True)
Extra = ({"creationflags": getattr(subprocess, "DETACHED_PROCESS", 0) | _NoWindow} if IsWindows
else {"start_new_session": True})
subprocess.Popen(
[str(PythonW), *Arguments], cwd=str(Folder), env=ChildEnvironment(), close_fds=True,
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
creationflags=getattr(subprocess, "DETACHED_PROCESS", 0) | _NoWindow,
[str(Python), *Arguments], cwd=str(Folder), env=ChildEnvironment(), close_fds=True,
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **Extra,
)
elif Action == "enable":
import pylnk3
Entry = _AutostartFile(Name)
Entry.parent.mkdir(parents=True, exist_ok=True)
if IsWindows:
import pylnk3
Link = _StartupLink(Name)
Link.parent.mkdir(parents=True, exist_ok=True)
Quoted = " ".join(f'"{Item}"' if " " in Item or "\\" in Item else Item for Item in Arguments)
pylnk3.for_file(str(PythonW), str(Link), arguments=Quoted, work_dir=str(Folder),
description=f"{Name} (background service)")
Quoted = " ".join(f'"{Item}"' if " " in Item or "\\" in Item else Item for Item in Arguments)
pylnk3.for_file(str(Python), str(Entry), arguments=Quoted, work_dir=str(Folder),
description=f"{Name} (background service)")
else:
Entry.write_text(_AutostartEntry(Python, Arguments, Name))
elif Action == "disable":
_StartupLink(Name).unlink(missing_ok=True)
_AutostartFile(Name).unlink(missing_ok=True)
except (OSError, subprocess.SubprocessError) as Error:
return str(Error)
return ""
# --- Both -----------------------------------------------------------------------------------
# --- Both ------------------------------------------------------------------------------------
def Status(Unit: str) -> ServiceStatus:
if IsWindows:
return _WindowsStatus(Unit)
if RunBySaraSuite:
return _OwnStatus(_Name(Unit))
try:
Result = _Run(["systemctl", "--user", "show", Unit,
"-p", "LoadState,ActiveState,SubState,UnitFileState,MainPID,ActiveEnterTimestamp"])
@@ -143,8 +202,8 @@ def Status(Unit: str) -> ServiceStatus:
def Control(Unit: str, Action: str) -> str:
"""Action: start, stop, restart, enable, disable. Returns "" or an error."""
if IsWindows:
return _WindowsControl(Unit, Action)
if RunBySaraSuite:
return _OwnControl(_Name(Unit), Action)
try:
Result = _Run(["systemctl", "--user", Action, Unit])
except (OSError, subprocess.SubprocessError) as Error:
@@ -160,9 +219,12 @@ def RestartIfRunning(Unit: str) -> str:
def Journal(Unit: str, Lines: int) -> list[str]:
if IsWindows:
return ["Windows has no systemd journal. SaraSortd's own log files (the other log source) "
"have everything it does."]
"""Its systemd journal, or (run by SaraSuite) ServiceRun.py's log of its output."""
if RunBySaraSuite:
LogPath = _ServicesFile(_Name(Unit), ".log")
if not LogPath.is_file():
return [f"No service log yet ({LogPath}): it's written once the service has run."]
return ReadTail(LogPath, Lines)
try:
Result = _Run(["journalctl", "--user", "-u", Unit, "-n", str(Lines), "--no-pager", "-o", "short-iso"])
except (OSError, subprocess.SubprocessError) as Error: