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]>
This commit is contained in:
David GhegheaandClaude Opus 5.5 committed 2026-10-18 17:14:26 +02:00
1 parent 603b5a168e
commit 0a9ec5f67e
7 files changed
+318 -26

No files matched your search

+31 -14
View File
@@ -3,6 +3,30 @@
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."""
@@ -22,7 +46,7 @@ def client_active(prefix, *, processes=None):
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):
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.
@@ -35,20 +59,13 @@ def client_active(prefix, *, processes=None):
def main():
prefix = Path(os.environ.get('DPREFIX', str(Path.home() / '.darling'))).resolve()
try:
pid = int((prefix / '.init.pid').read_text().strip())
except FileNotFoundError:
pid = 0
except (OSError, ValueError):
running = server_running(prefix)
except ValueError:
raise SystemExit('Cannot validate prefix server marker; inspect it before maintenance.')
if pid > 0:
try:
status = (Path('/proc') / str(pid) / 'status').read_text()
if not any(line.startswith('State:') and line.split()[1] == 'Z' for line in status.splitlines()):
raise SystemExit('Prefix has a live server marker; exit Riot/League and run darling shutdown in the ORIGINAL namespace before updating. No process was stopped.')
except (FileNotFoundError, ProcessLookupError):
pass
except OSError as error:
raise SystemExit(f'Cannot validate prefix server marker: {error}') from error
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:
+29 -5
View File
@@ -28,9 +28,14 @@ def main():
idle = importlib.util.module_from_spec(spec)
spec.loader.exec_module(idle)
prefix = Path(os.environ['DPREFIX']).resolve()
# darling-lol --stop creates this file; the launcher removes it.
stop_request = Path(os.environ['DARLING_WORKSPACE']) / '.stop-request'
child = None
interrupted = 0
def stopping():
return interrupted or stop_request.exists()
def interrupted_by(signum, _frame):
nonlocal interrupted
interrupted = 128 + signum
@@ -46,16 +51,18 @@ def main():
result = 1
try:
child = subprocess.Popen(sys.argv[1:])
result = child.wait()
if result < 0:
result = 128 - result
# Poll rather than wait: guest processes can outlive the command, and a
# signal or stop request must still reach the shutdown below.
while child.poll() is None and not stopping():
time.sleep(0.5)
except OSError as error:
warn(f'Cannot start client: {error}')
finally:
# A launcher can return while guest clients remain. Do not tear down
# their server, or discard the only namespace capable of shutting it down.
# their server, or discard the only namespace capable of shutting it down,
# unless the user asked to stop.
last_error = None
while True:
while not stopping():
try:
if not idle.client_active(prefix):
break
@@ -66,6 +73,8 @@ def main():
'Exit clients to allow safe shutdown.')
last_error = str(error)
time.sleep(1)
if stopping():
warn('Stopping Darling.')
while True:
try:
shutdown = subprocess.run(['bash', str(scripts / 'darling-rootless.sh'), 'shutdown']).returncode
@@ -74,10 +83,25 @@ def main():
shutdown = 1
if not shutdown:
break
try:
# Nothing is left to shut down, for example after the server crashed.
if not idle.server_running(prefix):
break
except (OSError, ValueError):
pass
warn(f'Darling namespace-local shutdown failed (status {shutdown}); '
'retaining the original namespace and retrying in 5 seconds. '
'Keep this terminal open.')
time.sleep(5)
if child is not None:
# Without its server the command exits; it is our own child to kill.
try:
result = child.wait(timeout=10)
except subprocess.TimeoutExpired:
child.kill()
result = child.wait()
if result < 0:
result = 128 - result
result = interrupted or result
return result
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""darling-lol --stop: end the workspace's session and its Darling server.
The session shuts Darling down in its own namespace when it finds the stop
request. Only if it does not finish in time, or if no session holds the lock
but the prefix server is still alive, is that server killed from here. The
marker PID alone is not trusted: the process must be a darlingserver of this
user for this prefix.
"""
import fcntl
import os
from pathlib import Path
import signal
import sys
import time
WAIT_SECONDS = 30
def session_running(lock):
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
return True
fcntl.flock(lock, fcntl.LOCK_UN)
return False
def wait_for_session(lock):
deadline = time.monotonic() + WAIT_SECONDS
while session_running(lock):
if time.monotonic() > deadline:
return False
time.sleep(0.5)
return True
def prefix_server(prefix):
"""PID of this prefix's darlingserver, or 0 if none is running."""
try:
pid = int((prefix / '.init.pid').read_text().strip())
except (FileNotFoundError, ValueError):
return 0
process = Path('/proc') / str(pid)
try:
argv = (process / 'cmdline').read_bytes().split(b'\0')
owner = process.stat().st_uid
except (FileNotFoundError, ProcessLookupError):
return 0
if (pid <= 0 or owner != os.getuid() or len(argv) < 2
or os.path.basename(argv[0]) != b'darlingserver'
or Path(os.fsdecode(argv[1])).resolve() != prefix):
return 0
return pid
def kill_server(pid):
"""Same as darling shutdown: kill launchd, which ends its PID namespace, then the server."""
children = []
for task in (Path('/proc') / str(pid) / 'task').glob('*/children'):
try:
children += [int(child) for child in task.read_text().split()]
except (FileNotFoundError, ProcessLookupError):
pass
for target in [*children, pid]:
try:
os.kill(target, signal.SIGKILL)
except ProcessLookupError:
pass
def main():
workspace, prefix = Path(sys.argv[1]), Path(sys.argv[2]).resolve()
request = workspace / '.stop-request'
workspace.mkdir(parents=True, exist_ok=True)
with open(workspace / '.launcher.lock', 'a+') as lock:
if session_running(lock):
print('Asking the darling-lol session to shut Darling down...', flush=True)
request.touch()
if wait_for_session(lock):
request.unlink(missing_ok=True)
print('Stopped.')
return 0
print(f'The session did not stop within {WAIT_SECONDS} seconds.', flush=True)
server = prefix_server(prefix)
if server:
print(f'Killing the Darling server of {prefix} (PID {server}).', flush=True)
kill_server(server)
elif not session_running(lock):
print('No darling-lol session is running.')
return 0
if wait_for_session(lock):
request.unlink(missing_ok=True)
print('Stopped.')
return 0
# Do not leave a request that would stop the next session at once.
request.unlink(missing_ok=True)
print('Darling is stopped, but the session that started it is still running. '
'Close its terminal.', file=sys.stderr)
return 1
if __name__ == '__main__':
raise SystemExit(main())