Files
darling-lol/tests/test_stop_darling_runtime.py
T
MintMachineHQ fa18b2d04f feat(setup): check host versions before the build and the runtime install
setup failures reported in #137, #138 and #143 happened only after the long
fetch/build or at Riot startup. scripts/preflight.py now runs from
scripts/build.sh (before configure.py, so an old interpreter gets this message
and not a traceback) and from scripts/install-runtime.sh, and checks the host
once:

- python 3.10+, cmake 3.20+ (the bundled indium CMakeLists requires it),
  git-lfs 2.13+, kernel 5.14+ and LLVM 16+ exit nonzero with one actionable
  line each. RCLOL_SKIP_PREFLIGHT=1 bypasses the gate.
- llvm-objdump and llvm-nm are required, and unresolved names are explained.
- Vulkan 1.3 and timeline semaphores are required when vulkaninfo is
  installed; they only warn when it is not, because a missing tool does not
  mean a missing driver.
- the X display is probed with xdpyinfo in the environment Darling inherits.
  It only warns: a build does not need a display.
- a prepared build/darling-src is scanned for small .dylib/.a/.so/.o files
  that still contain Git LFS pointer text, which links but crashes the guest.
- --json prints the same report for the diagnostic tools.

The installers no longer call hashlib.file_digest (Python 3.11+), so Ubuntu
22.04's Python 3.10 works instead of failing after the overlays are built
(#137). A streaming tools/file_digest.py replaces it.

LLVM command line tools are resolved by tools/llvm_tools.py: an unversioned
name on PATH first, then the highest llvm-<tool>-<N>, then llvm-config
--bindir. Debian/Ubuntu install llvm-objdump and llvm-nm only under
/usr/lib/llvm-<N>/bin, which broke the runtime validation (#138). Six tools
now use it. The LLVM 16 floor also stops the LLVM 15 runtime abort from #139
before anything is built.

Two test helpers used APIs added after Python 3.10; they are shimmed so the
same suite runs on 3.10, 3.11 and 3.14.

Adds tests/test_preflight.py (24 tests, fake host tools only).
2026-10-25 13:58:25 -06:00

192 lines
6.6 KiB
Python

"""Exercise the shutdown script without stopping the host's Darling session."""
import errno
import os
from pathlib import Path
import runpy
import signal
import subprocess
import sys
import tempfile
import types
import unittest
from unittest.mock import patch
SCRIPT = Path(__file__).resolve().parents[1] / 'tools/stop-darling-runtime.py'
class StopRuntimeTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
self.proc = self.root / 'proc'
self.proc.mkdir()
self.prefix = self.root / 'prefix'
self.prefix.mkdir()
self.calls = []
self.signals = []
self.clock = 0
self.status_error = None
self.shutdown = lambda: None
self.on_signal = lambda pid, sig: self.status(pid, 'Z')
def process(self, pid, *, state='S', parent=1, riot=False, children=()):
folder = self.proc / str(pid)
folder.mkdir()
self.status(pid, state)
(folder / 'stat').write_text(f'{pid} (test process) ' + ' '.join(
[state, str(parent)] + ['0'] * 17 + ['12345']))
(folder / 'comm').write_text('darlingserver' if pid == 100 else 'helper')
(folder / 'maps').write_text(
str(self.prefix / 'Applications/Riot Client.app/helper') if riot else '')
task = folder / 'task' / str(pid)
task.mkdir(parents=True)
(task / 'children').write_text(' '.join(map(str, children)))
def status(self, pid, state):
(self.proc / str(pid) / 'status').write_text(f'State:\t{state} (test)\n')
def server(self, **kwargs):
(self.prefix / '.init.pid').write_text('100')
self.process(100, **kwargs)
def run_script(self):
proc = self.proc
original_read = Path.read_text
owner = self
class RuntimePath(type(Path())):
def __new__(cls, *parts, **kwargs):
if parts and str(parts[0]) == '/proc':
parts = (proc, *parts[1:])
return super().__new__(cls, *parts, **kwargs)
def __init__(self, *parts, **kwargs):
if parts and str(parts[0]) == '/proc':
parts = (proc, *parts[1:])
try:
super().__init__(*parts, **kwargs)
except TypeError:
# Python 3.10's PurePath.__init__ takes no arguments; 3.12 stores them.
super().__init__()
def read_text(self, *args, **kwargs):
if self.name == 'status' and owner.status_error is not None:
raise owner.status_error
return original_read(self, *args, **kwargs)
def run(command, **kwargs):
self.calls.append((command, kwargs))
if command == ['darling', 'shutdown']:
self.shutdown()
return subprocess.CompletedProcess(command, 0)
def kill(pid, sig):
self.signals.append((pid, sig))
self.on_signal(pid, sig)
def monotonic():
self.clock += 1
return self.clock
with patch.dict(sys.modules, {'pathlib': types.SimpleNamespace(Path=RuntimePath)}), \
patch.dict(os.environ, {'DPREFIX': str(self.prefix)}), \
patch('subprocess.run', side_effect=run), \
patch('os.kill', side_effect=kill), \
patch('time.monotonic', side_effect=monotonic), \
patch('time.sleep'):
runpy.run_path(str(SCRIPT), run_name='__main__')
def assert_finished(self):
self.assertEqual(self.calls[0][0], ['darling', 'shutdown'])
self.assertEqual(self.calls[-1], (
[sys.executable, str(SCRIPT.with_name('ensure-darling-runtime.py'))],
{'check': True}))
def test_process_disappears_during_status_read(self):
self.server()
self.status_error = ProcessLookupError(errno.ESRCH, 'No such process')
self.run_script()
self.assert_finished()
def test_missing_process(self):
(self.prefix / '.init.pid').write_text('100')
self.run_script()
self.assert_finished()
def test_no_marker(self):
self.run_script()
self.assert_finished()
def test_invalid_marker(self):
(self.prefix / '.init.pid').write_text('invalid')
self.run_script()
self.assert_finished()
def test_zombie_server(self):
self.server(state='Z')
self.run_script()
self.assert_finished()
def test_server_exits_after_shutdown(self):
self.server()
self.shutdown = lambda: self.status(100, 'Z')
self.run_script()
self.assert_finished()
def test_live_server_blocks_runtime_changes(self):
self.server()
with self.assertRaisesRegex(SystemExit, 'runtime remains untouched'):
self.run_script()
self.assertEqual(len(self.calls), 1)
self.assertEqual(self.signals, [])
def test_permission_error_is_not_treated_as_exit(self):
self.server()
self.status_error = PermissionError(errno.EACCES, 'Permission denied')
with self.assertRaises(PermissionError):
self.run_script()
self.assertEqual(len(self.calls), 1)
def test_descendant_gets_sigterm(self):
self.server(children=(101,))
self.process(101, parent=100, riot=True)
self.shutdown = lambda: self.status(100, 'Z')
self.run_script()
self.assertEqual(self.signals, [(101, signal.SIGTERM)])
self.assert_finished()
def test_stubborn_orphan_gets_sigkill(self):
self.process(101, riot=True)
self.on_signal = lambda pid, sig: (
self.status(pid, 'Z') if sig == signal.SIGKILL else None)
self.run_script()
self.assertEqual(self.signals, [(101, signal.SIGTERM), (101, signal.SIGKILL)])
self.assert_finished()
def test_surviving_helper_blocks_runtime_changes(self):
self.process(101, riot=True)
self.on_signal = lambda pid, sig: None
with self.assertRaisesRegex(SystemExit, 'Old Riot helpers remain alive'):
self.run_script()
self.assertEqual(len(self.calls), 1)
def test_reused_pid_is_not_signalled(self):
self.server(children=(101,))
self.process(101, parent=100, riot=True)
def shutdown():
self.status(100, 'Z')
stat = self.proc / '101/stat'
stat.write_text(stat.read_text().replace('12345', '54321'))
self.shutdown = shutdown
self.run_script()
self.assertEqual(self.signals, [])
self.assert_finished()
if __name__ == '__main__':
unittest.main()