Files
darling-lol/tests/test_runtime_cleanup_scope.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

158 lines
6.9 KiB
Python

"""Cleanup scope regressions: fake proc/prefix trees and mocked signals only."""
import contextlib
import importlib.util
import io
import os
from pathlib import Path
import tempfile
import unittest
from unittest.mock import MagicMock, Mock, patch
SCRIPT = Path(__file__).resolve().parents[1] / 'tools/ensure-darling-runtime.py'
class RuntimeCleanupScopeTests(unittest.TestCase):
def enterContext(self, manager):
"""unittest.TestCase.enterContext() is Python 3.11+; Ubuntu 22.04 ships 3.10."""
value = manager.__enter__()
self.addCleanup(manager.__exit__, None, None, None)
return value
def setUp(self):
temporary = tempfile.TemporaryDirectory(prefix='cleanup-scope-')
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
self.proc = self.root / 'proc'
self.proc.mkdir()
self.prefix = self.root / 'selected prefix'
self.other = self.root / 'unrelated prefix'
self.shm = self.root / 'shm'
for folder in (self.prefix / 'private/tmp', self.other / 'private/tmp', self.shm):
folder.mkdir(parents=True)
# Guard signals before import; no real process may ever be signalled.
self.kill = self.enterContext(patch('os.kill'))
spec = importlib.util.spec_from_file_location('cleanup_scope', SCRIPT)
assert spec is not None and spec.loader is not None
self.cleanup = importlib.util.module_from_spec(spec)
spec.loader.exec_module(self.cleanup)
self.stderr = self.enterContext(contextlib.redirect_stderr(io.StringIO()))
self.enterContext(contextlib.redirect_stdout(io.StringIO()))
self.enterContext(patch.dict(os.environ, {'DPREFIX': str(self.prefix)}))
self.enterContext(patch.object(self.cleanup, 'Path', side_effect=self.fake_path))
# Its default Path('/proc') is bound at definition time, before patching Path.
running = self.cleanup.any_server_running
self.enterContext(patch.object(self.cleanup, 'any_server_running',
side_effect=lambda proc_root=None: running(
self.proc if proc_root is None else proc_root)))
def fake_path(self, value):
# Never inspect host /proc or remove host shared-memory files.
return {'/proc': self.proc, '/dev/shm': self.shm}.get(str(value), Path(value))
def process(self, pid, *, comm='launchd', argv=('/sbin/launchd',), state='S'):
folder = self.proc / str(pid)
folder.mkdir()
(folder / 'comm').write_text(comm + '\n')
(folder / 'cmdline').write_bytes(b'\0'.join(arg.encode() for arg in argv) + b'\0')
(folder / 'status').write_text(f'State:\t{state} (fixture)\n')
return folder
def test_direct_cleanup_entry_points_preserve_ambiguous_processes(self):
self.process(101)
self.process(102, comm='chrome_crashpad', argv=(
'/Applications/Riot Client.app/Contents/Frameworks/chrome_crashpad_handler',))
self.cleanup.stop_orphaned_launchd(self.proc)
self.cleanup.stop_riot_crashpad(self.proc)
self.kill.assert_not_called()
self.assertIn('prefix ownership', self.stderr.getvalue())
self.assertIn('launchd candidate PIDs 101', self.stderr.getvalue())
self.assertIn('Riot crashpad candidate PIDs 102', self.stderr.getvalue())
def test_clean_start_is_silent(self):
self.cleanup.main()
self.assertEqual(self.stderr.getvalue(), '')
self.kill.assert_not_called()
def test_unrelated_processes_are_silent(self):
self.process(110, comm='chrome_crashpad', argv=(
'/opt/google/chrome/chrome_crashpad_handler', '--annotation=riot'))
self.process(111, comm='unrelated', argv=('/usr/bin/tool', '/sbin/launchd'))
self.process(112, comm='unrelated', argv=('/usr/bin/tool',
'/Applications/Riot Client.app/chrome_crashpad_handler'))
self.cleanup.main()
self.assertEqual(self.stderr.getvalue(), '')
self.kill.assert_not_called()
def test_foreign_owned_candidates_are_silent(self):
self.process(113)
self.process(114, comm='chrome_crashpad', argv=(
'/Applications/Riot Client.app/chrome_crashpad_handler',))
with patch.object(Path, 'stat', return_value=Mock(st_uid=os.getuid() + 1)):
self.cleanup.stop_orphaned_launchd(self.proc)
self.cleanup.stop_riot_crashpad(self.proc)
self.assertEqual(self.stderr.getvalue(), '')
self.kill.assert_not_called()
def test_main_preserves_processes_from_another_prefix_without_a_server(self):
self.process(101)
self.process(102, comm='chrome_crashpad', argv=(
str(self.other / 'Applications/Riot Client.app/crashpad_handler'),))
selected = self.prefix / 'private/tmp/.com.riotgames.selected'
selected.write_text('stale')
unrelated = self.other / 'private/tmp/.com.riotgames.other'
unrelated.write_text('preserve')
shared = self.shm / 'darling-buffer-fixture'
shared.write_text('stale')
self.cleanup.main()
self.kill.assert_not_called()
self.assertFalse(selected.exists())
self.assertFalse(shared.exists())
self.assertEqual(unrelated.read_text(), 'preserve')
def test_any_live_server_preserves_locks_and_shared_buffers(self):
self.process(103, comm='darlingserver', argv=('darlingserver',))
lock = self.prefix / 'private/tmp/.com.riotgames.lock'
lock.write_text('active')
shared = self.shm / 'darling-buffer-fixture'
shared.write_text('active')
self.cleanup.main()
self.kill.assert_not_called()
self.assertTrue(lock.exists())
self.assertTrue(shared.exists())
def test_unreadable_process_list_fails_closed(self):
hidden = MagicMock()
hidden.name = '104'
(hidden / 'comm').read_text.side_effect = PermissionError('hidden')
proc = Mock()
proc.iterdir.return_value = [hidden]
self.assertTrue(self.cleanup.any_server_running(proc))
self.kill.assert_not_called()
def test_dead_marker_is_recovered_only_in_the_selected_prefix(self):
self.process(105, state='Z')
marker = self.prefix / '.init.pid'
marker.write_text('105')
other = self.other / '.init.pid'
other.write_text('105')
self.cleanup.main()
self.assertFalse(marker.exists())
recovered = list(self.prefix.glob('.init.pid.stale-105-*'))
self.assertEqual(len(recovered), 1)
self.assertEqual(recovered[0].read_text(), '105')
self.assertEqual(other.read_text(), '105')
self.kill.assert_not_called()
def test_live_marker_is_not_replaced(self):
self.process(106)
marker = self.prefix / '.init.pid'
marker.write_text('106')
self.cleanup.main()
self.assertEqual(marker.read_text(), '106')
self.kill.assert_not_called()
if __name__ == '__main__':
unittest.main()