Files
darling-lol/scripts/nix-idle.py
David GhegheaandClaude Opus 5.5 033ded4fd6 Stop Darling on Ctrl+C, terminal hangup and darling-lol --stop
After the clients quit, the session kept Darling running: Riot and League
crash handlers never exit under Darling, and the idle check counted them as
clients. Ctrl+C or closing the terminal killed bubblewrap, whose
--die-with-parent then killed nix-lifecycle.py before it could shut Darling
down, which left a live server marker behind.

- nix-idle.py ignores crash handlers when deciding whether a client still
  uses the prefix.
- The launcher starts bubblewrap with INT, QUIT, HUP and TERM ignored, and
  env restores them inside. The supervisor turns them into a stop request.
- nix-lifecycle.py polls for a signal or stop request and then shuts Darling
  down without waiting for clients. It stops retrying a failed shutdown once
  the server is gone, and waits for or kills its own command afterwards.
- darling-lol --stop makes the stop request and waits for the session. If
  the session does not exit within 30 seconds, or no session holds the lock,
  it kills the prefix's darlingserver and launchd like darling shutdown,
  after checking that the marker PID is this user's darlingserver for this
  prefix.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 17:14:26 +02:00

79 lines
3.3 KiB
Python

#!/usr/bin/env python3
"""Read-only, fail-closed rootless maintenance preflight. Never signal a process."""
import os
from pathlib import Path
# Crash handlers do not exit after their clients under Darling, so a mapping by
# one of them alone does not mean that a client still uses the prefix.
CRASH_HANDLERS = {'RiotClientCrashHandler', 'LeagueCrashHandler', 'chrome_crashpad_handler'}
def crash_handler(process):
executable = (process / 'cmdline').read_bytes().split(b'\0', 1)[0]
return os.path.basename(executable.decode(errors='replace')) in CRASH_HANDLERS
def server_running(prefix):
"""Whether the prefix marker names a live (not zombie) process."""
try:
pid = int((prefix / '.init.pid').read_text().strip())
except FileNotFoundError:
return False
if pid <= 0:
return False
try:
status = (Path('/proc') / str(pid) / 'status').read_text()
except (FileNotFoundError, ProcessLookupError):
return False
return not any(line.startswith('State:') and line.split()[1] == 'Z' for line in status.splitlines())
def client_active(prefix, *, processes=None):
"""Check exact-prefix mappings; only vanished processes may be skipped."""
paths = [str(prefix / name) + '/' for name in (
'Applications/Riot Client.app', 'Applications/League of Legends.app',
'Users/Shared/Riot Games')]
namespace_map = Path('/proc/self/uid_map').read_text()
for process in Path('/proc').iterdir() if processes is None else processes:
if not process.name.isdigit():
continue
try:
if process.stat().st_uid != os.getuid():
continue
# The outer live-marker gate excludes earlier Darling namespaces.
# Host processes are not ours to inspect; their maps are commonly
# unreadable from a child user namespace (including systemd --user).
if processes is None and (process / 'uid_map').read_text() != namespace_map:
continue
mappings = (process / 'maps').read_text()
if any(path in mappings for path in paths) and not crash_handler(process):
return True
except (FileNotFoundError, ProcessLookupError):
# Process exited between enumeration and inspection.
continue
except OSError as error:
raise RuntimeError(f'Cannot inspect process {process.name}; cannot establish that the prefix is idle: {error}') from error
return False
def main():
prefix = Path(os.environ.get('DPREFIX', str(Path.home() / '.darling'))).resolve()
try:
running = server_running(prefix)
except ValueError:
raise SystemExit('Cannot validate prefix server marker; inspect it before maintenance.')
except OSError as error:
raise SystemExit(f'Cannot validate prefix server marker: {error}') from error
if running:
raise SystemExit('Prefix has a live server marker; exit Riot/League and run darling-lol --stop before updating. No process was stopped.')
try:
active = client_active(prefix)
except (OSError, RuntimeError) as error:
raise SystemExit(str(error)) from error
if active:
raise SystemExit('A client maps this prefix; exit it before rootless maintenance. No process was stopped.')
if __name__ == '__main__':
main()