fix(nix): simplify compiler setup and complete rootless session lifecycle

This commit is contained in:
Nicolai Bruns committed 2026-10-18 13:35:15 +02:00
1 parent f5e65e3cf1
commit 57223d4c0a
12 files changed
+824 -176

No files matched your search

+229
View File
@@ -0,0 +1,229 @@
# Nix setup
The experimental Nix package provides a rootless build and launch environment
for x86_64 Linux. It supplies the dependencies, not a prebuilt Darling runtime
or game files. Initial setup builds Darling in a writable per-user workspace.
## Prerequisites
Enable Nix flakes and configure subordinate UID/GID ranges for your user. On
NixOS, add this to your system configuration and rebuild:
```nix
users.users.<name>.autoSubUidGidRange = true;
```
On other Linux distributions, [install Nix using the official instructions](https://nixos.org/download/).
You do not need to install NixOS. If you already use `nix-shell`, Nix is installed.
Enable the modern CLI and flakes in `~/.config/nix/nix.conf`:
```ini
experimental-features = nix-command flakes
```
Alternatively, enable them for an individual command:
```sh
nix --extra-experimental-features 'nix-command flakes' run github:lucannez64/darling-lol
```
The rootless launcher requires unprivileged user namespaces, subordinate UID/GID
ranges, and working `newuidmap`/`newgidmap` helpers. If these are already configured
on your distribution, no additional host setup is needed.
Run setup in an interactive terminal and allow roughly 40–50 GB for the runtime
build.
## Install and launch
Run the flake directly from GitHub; no local checkout is needed:
```sh
nix run github:lucannez64/darling-lol
```
On first launch, setup prompts to build Darling and install the Riot client.
To explicitly open the installer, including for another region:
```sh
nix run github:lucannez64/darling-lol#installer
```
Setup prompts before building. Subsequent launches reuse a compatible runtime;
they do not automatically rebuild it. If the package is installed in your
profile or system configuration, the equivalent commands are
`darling-lol --installer` and `darling-lol`. The desktop entry opens a terminal
for prompts and session output.
Keep the terminal open while clients are running and until cleanup completes.
Default and installer sessions wait for clients to exit, then shut Darling down
in the same namespace. A failed shutdown is retried without discarding that
namespace.
## Install permanently
`nix run` does not add commands to your profile or install a desktop menu entry.
It does reuse downloaded store paths and the persistent Darling workspace.
For commands on your `PATH` and desktop-menu access, install the package using
one of the following methods.
### User profile (NixOS or other Linux distributions)
```sh
nix profile add github:lucannez64/darling-lol#default
```
Then launch from your application menu or terminal:
```sh
darling-lol
# Explicit setup or runtime shell:
darling-lol --installer
darling-lol-shell
```
If the desktop entry does not appear, ensure your desktop session includes
`~/.nix-profile/share` in `XDG_DATA_DIRS`, then log out and back in. Non-NixOS
systems may need this desktop-session integration after installing Nix.
To upgrade, find the package's name with `nix profile list`, then run
`nix profile upgrade <name>`. Close the clients first; after upgrading, run
`darling-lol --update` to rebuild the mutable runtime when required.
### NixOS system configuration
In your system flake, add the input and pass it to your NixOS modules:
```nix
inputs.darling-lol.url = "github:lucannez64/darling-lol";
outputs = inputs@{ nixpkgs, ... }: {
nixosConfigurations.<hostname> = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
specialArgs = { inherit inputs; };
modules = [ ./configuration.nix ];
};
};
```
Merge these entries into your existing flake rather than replacing its outputs.
In `configuration.nix`, add the package and subordinate-ID configuration:
```nix
{ inputs, pkgs, ... }:
{
environment.systemPackages = [
inputs.darling-lol.packages.${pkgs.stdenv.hostPlatform.system}.default
];
users.users.<name>.autoSubUidGidRange = true;
nix.settings.experimental-features = [ "nix-command" "flakes" ];
}
```
Apply with `sudo nixos-rebuild switch --flake .#<hostname>`. The launcher and
desktop entry are then installed system-wide; each user still has a separate
workspace and prefix. Start `darling-lol` as your normal user, not with sudo.
To upgrade, update the input with `nix flake update darling-lol`, rebuild your
system, and run `darling-lol --update` after closing the clients.
## Update
Close League and both Riot clients and wait for the launcher to finish cleanup.
Then run the updated flake and rebuild the runtime:
```sh
nix run --refresh github:lucannez64/darling-lol -- --update
# Installed package: darling-lol --update
```
This rebuilds incrementally, installs the base runtime into the user-owned
workspace, and refreshes the per-prefix overlays. No `sudo` is needed.
## Local checkout and scripts
You can also launch the flake directly from an already checked-out repository:
```sh
cd /path/to/darling-lol
nix run .
```
Or clone it first:
```sh
git clone https://github.com/lucannez64/darling-lol.git
cd darling-lol
nix run .
```
The same checkout also provides installer and update commands:
```sh
nix run .#installer
# After updating the checkout:
nix run . -- --update
```
These commands work on NixOS and other Linux distributions with Nix installed.
Use `nix run` for this flake rather than `nix-shell`: the launcher sets up the
rootless namespace in addition to supplying dependencies. `nix develop` is for
development tools only; use `nix run .#shell` for an actual runtime shell.
To run a repository script inside the rootless FHS environment, pass its command
after `--`. Commands run from the managed workspace, using the tools copied from
the selected flake:
```sh
nix run github:lucannez64/darling-lol -- python3 scripts/validate.py
# Equivalent from a local checkout:
nix run . -- python3 scripts/validate.py
```
Use the supported installer/update commands for maintenance. Arbitrary script
commands do not get the default launcher's automatic client-session cleanup; if
a script starts Darling, shut it down in the same namespace before returning.
For multiple commands, use an interactive runtime shell:
## Runtime shell
```sh
nix run github:lucannez64/darling-lol#shell
# Local checkout: nix run .#shell
# Installed package: darling-lol-shell
```
Use the launcher-provided shell rather than entering the raw FHS environment.
If you start Darling manually in this shell, run `darling shutdown` there
before exiting. A separately launched shell cannot reconnect to an existing
Darling namespace.
## Files and operational limits
- Workspace: `${XDG_DATA_HOME:-$HOME/.local/share}/darling-lol`.
- Default guest prefix: `$HOME/.darling`; set `DPREFIX` to use another prefix.
Explicit `DPREFIX` and `RIOT_GUEST_HOME` overrides take precedence over saved
configuration, so clear stale overrides before switching installations.
- Package upgrades refresh managed tools and remove previously tracked obsolete
files. Builds, the installed runtime, guest prefixes, and unmanaged files are
preserved. Edit the source checkout rather than copied workspace tools.
- One active session is supported per workspace. A workspace lock and live
prefix marker prevent concurrent launches; the launcher does not kill an
existing session or reconnect across namespaces. Inspect an unexpected live
marker rather than deleting it to force a launch.
- Readiness records in `build/nix-ready/` validate the selected prefix, guest,
package, base runtime, and required overlays. A new prefix can reuse a
compatible base runtime; failed setup invalidates readiness.
- Keep `build/nix-gcroots/` while using the workspace. Mutable compiled binaries
can reference Nix-store libraries, so these roots retain the package closures
they need. Remove them only when discarding the dependent build outputs.
The launcher uses subordinate-ID user namespaces before entering the FHS
environment. Mount privileges are scoped to that namespace, not host root;
workspace and prefix files retain the Linux user's ownership. The setup does
not install a host setuid binary or require a privileged runtime installation.
Darling compilation still happens outside Nix derivations and uses the host CPU;
the resulting runtime is not a portable, binary-cacheable Nix package. The
FFmpeg dependency is pinned to 8.x because the generated AV wrappers require
its library ABI.
+19 -59
View File
@@ -1,73 +1,33 @@
# Setup and portability
## NixOS package
<a id="nixos-package"></a>
Use `darling-lol`, `darling-lol --installer`, or `darling-lol-shell` rather than
invoking the raw FHS environment. The launcher uses `unshare --map-auto` before
bubblewrap, so the user needs subordinate UID/GID ranges (`/etc/subuid` and
`/etc/subgid`, or NixOS `users.users.<name>.autoSubUidGidRange = true`).
Namespace root maps to a subordinate host ID, not host root. The FHS entrypoint
restores the real Linux user's IDs while retaining namespace-only capabilities;
the Darling shim temporarily switches effective IDs for mounts. No `sudo`,
root-owned binary in a writable workspace, or host setuid installation is needed.
## Nix package
The launcher refreshes managed scripts with executable bits and owner-write access,
rejecting symlinks in that managed tree. It preserves `build/`, `usr-local/`, and
the guest prefix. Edit the source checkout, not copied workspace tools.
See [prerequisites](NIX.md#prerequisites) before starting, and the
[Nix setup guide](NIX.md) for local checkout usage, updates, scripts, and runtime
details.
First setup: run `darling-lol --installer` in an interactive terminal, choose the
build when prompted, then choose the Riot region. The desktop entry uses
`Terminal=true` and delegates terminal selection to the desktop environment.
A launch with EOF stdin fails with a command to run in a terminal; it does not
try a hard-coded list of terminal emulators or silently abandon a prompt.
Run the experimental rootless package on x86_64 Linux:
Update: after manually exiting League and both Riot clients, shut down Darling
in the ORIGINAL `darling-lol-shell` terminal with `darling shutdown`, then exit
that shell. Upgrade the Nix package and run `darling-lol --update` in a terminal.
This rebuilds incrementally, installs into the user-owned `/usr/local` bind,
and installs paired per-prefix overlays without sudo. For a first-time setup,
the same rootless update path performs the base build/install. Native/manual
(non-Nix) instructions below still use their normal privileged base install.
```sh
nix --extra-experimental-features 'nix-command flakes' run github:lucannez64/darling-lol
```
Readiness JSON under `build/nix-ready/` is keyed by canonical prefix and checks
the configured guest and resolved host guest home, UID/GID, package path,
maintained patch/tool identities, executable modes, base SDK/runtime metadata
and required-overlay inventory. Stamping reuses required-artifact validation.
Changing `RIOT_GUEST_HOME` invalidates readiness. Explicit `DPREFIX` and
`RIOT_GUEST_HOME` environment overrides take precedence over saved configuration;
clear stale values before selecting a different installation. A separate base
manifest avoids rebuilding when only a new prefix needs overlays.
Updates invalidate readiness before replacing the base; failed/interrupted setup
cannot leave the previous prefix stamp authoritative. Existing app bundles never
bypass these checks. Normal ready launches only check state, not rebuild.
To install permanently, adding the launcher to your user profile:
Operational limits: each workspace supports one foreground namespace session.
The launcher holds a lock while its child runs, even if bubblewrap closes file
descriptors. It refuses a live `.init.pid` rather than reconnecting across new
user namespaces or killing anything. A stale/reused live PID can therefore block
a launch conservatively; inspect it before taking manual action. Never remove a
marker just to force another launch. Manual stop/reconnect and game lifecycle
acceptance have not been exercised with this repaired package. Keep the original
shell open if you need to stop a session. Maintenance requires that prefix idle;
rootless CMake's shutdown hook checks only that prefix, never enumerates and
shuts down every user's launchd. The inherited scoped stop helper remains used
by overlay installers; it is fixture-tested, not live-game tested here.
```sh
nix profile add github:lucannez64/darling-lol#default
```
Mutable Linux ELF outputs can retain Nix-store interpreter/library references.
A native compiler probe executed successfully with its store-path interpreter;
there is no evidence that all such outputs require `patchelf`. The update path
creates indirect closure roots under `build/nix-gcroots/` before compiling and
retains old package roots for incremental outputs. Keep those roots while using
the workspace; remove them only when discarding/rebuilding all dependent mutable
outputs. This trades disk retention for GC safety, not a claim of full runtime
relocatability. A full Darling build/upgrade and GC acceptance test remains open.
Then run `darling-lol` or use the desktop application menu. See
[permanent installation](NIX.md#install-permanently) for details.
The FFmpeg dependency is explicitly 8.x: generated Darling AV wrappers reference
`libavformat.so.62`, `libavcodec.so.62`, and `libavutil.so.60`; FFmpeg 9 cannot
replace those ABI versions.
Run the package regression suite with `python3 -m unittest discover -s tests -v`.
First launch builds Darling and guides you through Riot client installation.
NixOS is not required; other Linux distributions with Nix installed are also
supported.
## Native setup notes
The patch manifest pins Darling to `60ba801decee7a00782f74f6be4c8ffb013f79ff` and records every changed submodule's revision. `scripts/prepare.py` checks each revision before applying patches and never resets an existing checkout to make a patch apply. Re-running it accepts an already-applied patch.
+17 -49
View File
@@ -10,6 +10,7 @@
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
llvmPackages = pkgs.llvmPackages_21;
# Python environment with dependencies required by tooling
pythonEnv = pkgs.python3.withPackages (
@@ -36,10 +37,9 @@
gnumake
ninja
pkg-config
clang
lld
llvm
llvm.dev
llvmPackages.clang
llvmPackages.lld
llvmPackages.llvm
llvmPackages.clang-unwrapped
bison
flex
@@ -54,16 +54,11 @@
# System & core libraries
glibc
glibc.dev
glibc.static
zlib
zlib.dev
libffi
libffi.dev
libbsd
libbsd.dev
libmd
libmd.dev
libcap
libelf
fuse
@@ -131,8 +126,7 @@
darlingLolFhs = pkgs.buildFHSEnv {
name = "darling-lol-fhs";
targetPkgs = pkgs: buildDeps;
multiPkgs = pkgs: [ pkgs.glibc ];
# multiArch is disabled; glibc is already in targetPkgs and the FHS base.
extraOutputsToInstall = [ "dev" "out" ];
extraBwrapArgs = [
@@ -143,44 +137,18 @@
];
extraBuildCommands = ''
mkdir -p bin usr/bin
rm -f bin/clang bin/clang++ usr/bin/clang usr/bin/clang++
ln -sfn /usr-local $out/usr/local
cat << 'EOF' > usr/bin/clang
#!/usr/bin/env bash
is_darwin=0
for a in "$@"; do
if [[ "$a" == *darwin* ]] || [[ "$a" == *apple* ]] || [[ "$a" == *macosx* ]]; then
is_darwin=1
break
fi
done
if [[ "$is_darwin" -eq 1 ]]; then
exec ${pkgs.llvmPackages.clang-unwrapped}/bin/clang "$@"
else
exec ${pkgs.clang}/bin/clang -idirafter /usr/include "$@"
fi
EOF
chmod +x usr/bin/clang
cat << 'EOF' > usr/bin/clang++
#!/usr/bin/env bash
is_darwin=0
for a in "$@"; do
if [[ "$a" == *darwin* ]] || [[ "$a" == *apple* ]] || [[ "$a" == *macosx* ]]; then
is_darwin=1
break
fi
done
if [[ "$is_darwin" -eq 1 ]]; then
exec ${pkgs.llvmPackages.clang-unwrapped}/bin/clang++ "$@"
else
exec ${pkgs.clang}/bin/clang++ -idirafter /usr/include "$@"
fi
EOF
chmod +x usr/bin/clang++
cp -f usr/bin/clang bin/clang
cp -f usr/bin/clang++ bin/clang++
mkdir -p "$out/usr/bin"
rm -f "$out/usr/bin/clang" "$out/usr/bin/clang++"
ln -sfn /usr-local "$out/usr/local"
{
printf '%s\n' '#!/bin/sh' \
'wrapped=${llvmPackages.clang}/bin' \
'unwrapped=${llvmPackages.clang-unwrapped}/bin'
cat ${./scripts/nix-compiler.sh}
} > "$out/usr/bin/clang"
chmod +x "$out/usr/bin/clang"
ln -s clang "$out/usr/bin/clang++"
# /bin is already a guest-absolute symlink to /usr/bin.
'';
profile = ''
+8
View File
@@ -0,0 +1,8 @@
# Invoked under clang or clang++: keep the driver's original name.
driver=${0##*/}
for arg in "$@"; do
case "$arg" in
*darwin*|*apple*|*macosx*) exec "$unwrapped/$driver" "$@" ;;
esac
done
exec "$wrapped/$driver" -idirafter /usr/include "$@"
+169 -35
View File
@@ -1,44 +1,178 @@
#!/usr/bin/env python3
"""Refresh managed package files without following mutable destination symlinks."""
"""Refresh tracked package files, never recursively deleting workspace data.
Legacy workspaces have no ownership inventory: adopt only files in the current
package, preserving unknown legacy files rather than guessing what is disposable.
The manifest is untrusted input, not authority to delete arbitrary workspace paths.
This guards pre-existing links; concurrent hostile workspace mutation is unsupported.
"""
import errno
import json
import os
from pathlib import Path
from pathlib import Path, PurePosixPath
import shutil
import stat
import sys
import tempfile
source, destination = map(Path, sys.argv[1:])
items = 'docs patches research scripts tests tools upstream LICENSE NOTICE.md README.md requirements.txt'.split()
# Preflight the entire managed tree before writing anything. Never chmod or copy
# through a link planted by a previous installation (including directory links).
for root in [destination, *destination.parents]:
if root.is_symlink():
raise SystemExit(f'Refusing symlink workspace ancestor: {root}')
for item in items:
for base in [source / item, destination / item]:
for path in [base, *base.rglob('*')] if base.is_dir() else [base]:
if path.is_symlink():
raise SystemExit(f'Refusing symlink in managed package tree: {path}')
if path.exists() and not (path.is_file() or path.is_dir()):
raise SystemExit(f'Refusing special file in managed package tree: {path}')
for item in items:
src = source / item
if not src.exists():
continue
for path in [src, *src.rglob('*')] if src.is_dir() else [src]:
target = destination / path.relative_to(source)
if path.is_dir():
target.mkdir(parents=True, exist_ok=True)
target.chmod(0o755)
ITEMS = 'docs patches research scripts tests tools upstream LICENSE NOTICE.md README.md requirements.txt'.split()
DIRECTORY_ITEMS = set(ITEMS[:7])
MANIFEST = '.nix-managed-files.json'
def refuse(message):
raise ValueError(message)
def check_ancestors(path):
for ancestor in [path, *path.parents]:
if ancestor.is_symlink():
refuse(f'Refusing symlink workspace ancestor: {ancestor}')
def inventory(root):
files, directories = set(), set()
def visit(path):
mode = path.lstat().st_mode
relative = path.relative_to(root).as_posix()
if stat.S_ISREG(mode):
files.add(relative)
elif stat.S_ISDIR(mode):
directories.add(relative)
for child in sorted(path.iterdir()):
visit(child)
else:
target.parent.mkdir(parents=True, exist_ok=True)
fd, temporary = tempfile.mkstemp(prefix='.package-', dir=target.parent)
refuse(f'Refusing symlink or special file in managed package tree: {path}')
for item in ITEMS:
path = root / item
if path.exists() or path.is_symlink():
if path.is_dir() != (item in DIRECTORY_ITEMS) and not path.is_symlink():
refuse(f'Invalid managed root type: {path}')
visit(path)
return files, directories
def load_manifest(path):
if path.is_symlink():
refuse(f'Refusing symlink manifest: {path}')
if not path.exists():
return set(), set()
if not path.is_file():
refuse(f'Refusing special manifest: {path}')
data = json.loads(path.read_text())
if (not isinstance(data, dict) or set(data) != {'version', 'files', 'directories'}
or type(data['version']) is not int or data['version'] != 1):
refuse('Invalid managed-file manifest schema')
result = []
for field in ('files', 'directories'):
entries = data[field]
if not isinstance(entries, list):
refuse(f'Invalid manifest {field}')
validated = set()
for name in entries:
if not isinstance(name, str):
refuse('Non-string manifest path')
parts = PurePosixPath(name).parts
if not parts:
refuse('Empty manifest path')
continue
if (name != '/'.join(parts) or '\\' in name or '\x00' in name
or any(part in ('.', '..', '/') for part in parts)
or parts[0] not in ITEMS
or (len(parts) > 1 and parts[0] not in DIRECTORY_ITEMS)
or (field == 'directories' and parts[0] not in DIRECTORY_ITEMS)):
refuse(f'Unsafe manifest path: {name!r}')
if name in validated:
refuse(f'Duplicate manifest path: {name}')
validated.add(name)
result.append(validated)
files, directories = result
if files & directories:
refuse('Conflicting manifest file/directory paths')
for name in files | directories:
if any(parent.as_posix() in files for parent in PurePosixPath(name).parents):
refuse(f'Manifest file used as directory: {name}')
return files, directories
def atomic_write(target, writer, mode):
fd, temporary = tempfile.mkstemp(prefix='.package-', dir=target.parent)
try:
with os.fdopen(fd, 'wb') as output:
writer(output)
os.chmod(temporary, mode)
os.replace(temporary, target)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
def refresh(source, destination):
for root in (source, destination):
if '..' in root.parts:
refuse(f'Refusing path traversal: {root}')
check_ancestors(root)
source, destination = source.absolute(), destination.absolute()
if not source.is_dir():
refuse(f'Missing package source: {source}')
if source == destination or source in destination.parents or destination in source.parents:
refuse('Package source and workspace must not overlap')
old_files, old_directories = load_manifest(destination / MANIFEST)
files, directories = inventory(source)
present_files, present_directories = inventory(destination)
# Validate ownership/type conflicts before copying or pruning anything.
if old_files & present_directories or old_directories & present_files:
refuse('Tracked path changed type in workspace')
stale_files = old_files - files
stale_directories = old_directories - directories
if directories & (present_files - stale_files):
refuse('Unmanaged file blocks package directory')
replaced_directories = files & present_directories
for name in replaced_directories:
if name not in stale_directories:
refuse(f'Unmanaged directory blocks package file: {name}')
prefix = name + '/'
if (any(p.startswith(prefix) and p not in stale_files for p in present_files)
or any(p.startswith(prefix) and p not in stale_directories for p in present_directories)):
refuse(f'Unmanaged data blocks directory replacement: {name}')
for name in sorted(stale_files):
if name in present_files:
(destination / name).unlink()
# rmdir only: a user-added child always keeps its containing tree alive.
for name in sorted(stale_directories, key=lambda p: (-p.count('/'), p)):
if name in present_directories:
try:
with os.fdopen(fd, 'wb') as output, path.open('rb') as input_file:
shutil.copyfileobj(input_file, output)
# Keep executable bits, remove special bits, and keep owner writable.
os.chmod(temporary, stat.S_IMODE(path.stat().st_mode) & 0o777 | 0o600)
os.replace(temporary, target)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
(destination / name).rmdir()
except OSError as error:
if error.errno not in (errno.ENOTEMPTY, errno.EEXIST):
raise
destination.mkdir(parents=True, exist_ok=True)
for name in sorted(directories, key=lambda p: (p.count('/'), p)):
target = destination / name
target.mkdir(parents=True, exist_ok=True)
target.chmod(0o755)
for name in sorted(files):
path, target = source / name, destination / name
target.parent.mkdir(parents=True, exist_ok=True)
def copy(output):
with path.open('rb') as input_file:
shutil.copyfileobj(input_file, output)
atomic_write(target, copy, (stat.S_IMODE(path.stat().st_mode) & 0o777) | 0o600)
state = json.dumps({'version': 1, 'files': sorted(files),
'directories': sorted(directories)}, indent=2) + '\n'
atomic_write(destination / MANIFEST, lambda output: output.write(state.encode()), 0o600)
if __name__ == '__main__':
try:
if len(sys.argv) != 3:
refuse('Usage: nix-copy.py SOURCE DESTINATION')
refresh(*map(Path, sys.argv[1:]))
except (ValueError, OSError) as error:
raise SystemExit(f'Package refresh refused: {error}')
+55 -30
View File
@@ -3,34 +3,59 @@
import os
from pathlib import Path
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):
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:
pass
# Only inspect same-user mappings under this exact prefix. An unrelated game's
# command name is not grounds to stop or modify it. Unreadable same-user maps
# fail closed because we cannot rule out a client still using these files.
paths = [str(prefix / name) + '/' for name in (
'Applications/Riot Client.app', 'Applications/League of Legends.app',
'Users/Shared/Riot Games')]
for process in Path('/proc').iterdir():
if not process.name.isdigit():
continue
try:
if process.stat().st_uid != os.getuid():
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
mappings = (process / 'maps').read_text()
if any(path in mappings for path in paths):
raise SystemExit('A client maps this prefix; exit it before rootless maintenance. No process was stopped.')
except (FileNotFoundError, ProcessLookupError, PermissionError):
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):
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:
pid = int((prefix / '.init.pid').read_text().strip())
except FileNotFoundError:
pid = 0
except (OSError, 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
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()
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Keep namespace-local shutdown authority until the foreground and clients exit.
Never kill by name or use a marker PID as authority to signal a host process.
Uninspectable mappings retain this namespace rather than guessing it is idle.
"""
import importlib.util
import os
from pathlib import Path
import signal
import subprocess
import sys
import time
def main():
scripts = Path(__file__).resolve().parent
spec = importlib.util.spec_from_file_location('nix_idle', scripts / 'nix-idle.py')
assert spec is not None and spec.loader is not None
idle = importlib.util.module_from_spec(spec)
spec.loader.exec_module(idle)
prefix = Path(os.environ['DPREFIX']).resolve()
child = None
interrupted = 0
def interrupted_by(signum, _frame):
nonlocal interrupted
interrupted = 128 + signum
# Only our own foreground child, never a marker PID or host-wide search.
if child is not None and child.poll() is None:
try:
child.send_signal(signum)
except ProcessLookupError:
pass
for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
signal.signal(signum, interrupted_by)
result = 1
try:
child = subprocess.Popen(sys.argv[1:])
result = child.wait()
if result < 0:
result = 128 - result
except OSError as error:
print(f'Cannot start client: {error}', file=sys.stderr)
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.
last_error = None
while True:
try:
if not idle.client_active(prefix):
break
last_error = None
except (OSError, RuntimeError) as error:
if str(error) != last_error:
print(f'{error}; retaining original namespace and retrying. '
'Exit clients to allow safe shutdown.', file=sys.stderr)
last_error = str(error)
time.sleep(1)
while True:
try:
shutdown = subprocess.run(['bash', str(scripts / 'darling-rootless.sh'), 'shutdown']).returncode
except OSError as error:
print(f'Cannot run namespace-local shutdown: {error}', file=sys.stderr)
shutdown = 1
if not shutdown:
break
print(f'Darling namespace-local shutdown failed (status {shutdown}); '
'retaining the original namespace and retrying in 5 seconds. '
'Keep this terminal open.', file=sys.stderr)
time.sleep(5)
result = interrupted or result
return result
if __name__ == '__main__':
raise SystemExit(main())
+2 -2
View File
@@ -27,6 +27,6 @@ if [ "${1:-}" = --installer ] ||
[ ! -d "$DPREFIX/Users/Shared/Riot Games/Riot Client.app" ]; }; then
interactive
read -r -p 'Region [euw]: ' region || exit 1
exec ./scripts/installer.sh "${region:-euw}"
exec python3 scripts/nix-lifecycle.py ./scripts/installer.sh "${region:-euw}"
fi
exec ./scripts/run.sh
exec python3 scripts/nix-lifecycle.py ./scripts/run.sh
+52
View File
@@ -0,0 +1,52 @@
"""Direct regression for FHS compiler wrapper installation and dispatch."""
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
class CompilerTests(unittest.TestCase):
def test_build_hook_and_compiler_dispatch(self):
with tempfile.TemporaryDirectory(prefix="nix-compilers-") as tmp:
root = Path(tmp)
out = root / "out"
cwd = root / "build"
llvm = root / "llvm"
for directory in (out / "usr/bin", out / "usr/lib", cwd, llvm / "bin"):
directory.mkdir(parents=True)
for name in ("clang", "clang++"):
target = llvm / "bin" / name
target.write_text("#!/bin/sh\nprintf '%s\\n' \"$0\" \"$@\"\n")
target.chmod(0o755)
(out / "usr/bin" / name).symlink_to(target)
flake = (ROOT / "flake.nix").read_text()
hook = flake.split("extraBuildCommands = ''", 1)[1].split("\n '';", 1)[0]
for variable in ("llvmPackages.clang", "llvmPackages.clang-unwrapped"):
hook = hook.replace("${" + variable + "}", str(llvm))
hook = hook.replace("${./scripts/nix-compiler.sh}", str(ROOT / "scripts/nix-compiler.sh"))
hook = hook.replace("''${", "${")
result = subprocess.run(["bash", "-eu", "-c", hook], cwd=cwd,
env={**os.environ, "out": str(out)},
text=True, capture_output=True)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(list(cwd.iterdir()), [], "hook must operate on $out, not cwd")
for name, args, expected in (
("clang", ["-c", "source with spaces.c"],
["-idirafter", "/usr/include", "-c", "source with spaces.c"]),
("clang++", ["-target", "x86_64-apple-darwin", "source.cc"],
["-target", "x86_64-apple-darwin", "source.cc"]),
):
wrapper = out / "usr/bin" / name
self.assertFalse(wrapper.is_symlink() and wrapper.resolve().is_relative_to(llvm))
script = wrapper.read_text().replace("/usr/bin/", str(out / "usr/bin") + "/")
wrapper.write_text(script)
result = subprocess.run([str(wrapper), *args], text=True, capture_output=True)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.splitlines(), [str(llvm / "bin" / name), *expected])
if __name__ == "__main__":
unittest.main()
+101
View File
@@ -0,0 +1,101 @@
"""Managed refresh regressions; all installations are disposable fixtures."""
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
COPY = ROOT / 'scripts/nix-copy.py'
MANIFEST = '.nix-managed-files.json'
class ManagedCopyTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(dir=os.environ.get('TMPDIR'))
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
self.source = self.root / 'source'
self.destination = self.root / 'workspace'
self.source.mkdir()
self.destination.mkdir()
self.put(self.source, 'scripts/current.sh', 'current')
def put(self, root, name, content='user data'):
path = root / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
return path
def refresh(self, success=True, source=None, destination=None):
result = subprocess.run([sys.executable, str(COPY), str(source or self.source),
str(destination or self.destination)],
capture_output=True, text=True)
if success:
self.assertEqual(result.returncode, 0, result.stderr)
else:
self.assertNotEqual(result.returncode, 0)
return result
def manifest(self, files=(), directories=()):
path = self.destination / MANIFEST
path.write_text(json.dumps({'version': 1, 'files': list(files),
'directories': list(directories)}))
return path
def test_refresh_prunes_removed_files_and_empty_directories(self):
stale = self.put(self.source, 'scripts/obsolete/nested/old.sh')
self.put(self.source, 'NOTICE.md')
self.refresh()
stale.unlink()
stale.parent.rmdir()
stale.parent.parent.rmdir()
(self.source / 'NOTICE.md').unlink()
self.refresh()
self.assertFalse((self.destination / 'scripts/obsolete').exists())
self.assertFalse((self.destination / 'NOTICE.md').exists())
self.assertEqual((self.destination / 'scripts/current.sh').read_text(), 'current')
state = json.loads((self.destination / MANIFEST).read_text())
self.assertEqual(state['files'], ['scripts/current.sh'])
self.assertEqual(state['directories'], ['scripts'])
def test_removed_tree_retains_unmanaged_children_and_mutable_roots(self):
self.put(self.source, 'docs/old.txt')
self.refresh()
(self.source / 'docs/old.txt').unlink()
(self.source / 'docs').rmdir()
preserved = ['docs/personal.txt', 'scripts/local.sh', 'build/cache',
'usr-local/bin/tool', 'prefix/game/save', 'other/data']
for name in preserved:
self.put(self.destination, name)
self.refresh()
self.assertFalse((self.destination / 'docs/old.txt').exists())
for name in preserved:
self.assertEqual((self.destination / name).read_text(), 'user data')
def test_migration_preserves_unknown_legacy_files_then_tracks_new_files(self):
self.put(self.destination, 'scripts/legacy.sh')
self.refresh()
self.assertTrue((self.destination / MANIFEST).is_file())
(self.source / 'scripts/current.sh').unlink()
self.refresh()
self.assertTrue((self.destination / 'scripts/legacy.sh').is_file())
self.assertFalse((self.destination / 'scripts/current.sh').exists())
if __name__ == '__main__':
unittest.main()
+90
View File
@@ -0,0 +1,90 @@
"""Inert fixtures: no real Darling execution or live prefix modification."""
import importlib.util
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
from unittest.mock import MagicMock
ROOT = Path(__file__).resolve().parents[1]
class IdleTests(unittest.TestCase):
def test_unreadable_maps_fail_closed_but_disappearing_process_is_skipped(self):
spec = importlib.util.spec_from_file_location('nix_idle_fixture', ROOT / 'scripts/nix-idle.py')
assert spec is not None and spec.loader is not None
idle = importlib.util.module_from_spec(spec)
# Older versions run on import: point even those at an inert prefix.
with tempfile.TemporaryDirectory() as folder:
from unittest.mock import patch
with patch.dict(os.environ, DPREFIX=folder):
spec.loader.exec_module(idle)
process = MagicMock()
process.name = '123'
process.stat.return_value.st_uid = os.getuid()
(process / 'maps').read_text.side_effect = PermissionError('denied')
with self.assertRaisesRegex(RuntimeError, 'Cannot inspect'):
idle.client_active(Path('/fixture/prefix'), processes=[process])
(process / 'maps').read_text.side_effect = FileNotFoundError()
self.assertFalse(idle.client_active(Path('/fixture/prefix'), processes=[process]))
class LifecycleTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
scripts = self.root / 'scripts'
scripts.mkdir()
for name in ('nix-session.sh', 'nix-lifecycle.py'):
if (ROOT / 'scripts' / name).exists():
shutil.copy(ROOT / 'scripts' / name, scripts / name)
self.log = self.root / 'log'
self.env = dict(os.environ, DARLING_WORKSPACE=str(self.root),
DPREFIX=str(self.root / 'prefix'), TEST_LOG=str(self.log))
(self.root / 'prefix/Applications/Riot Client.app').mkdir(parents=True)
(scripts / 'nix-state.py').write_text('raise SystemExit(0)\n')
(scripts / 'nix-idle.py').write_text('''import os
calls = 0
def client_active(prefix, **kwargs):
global calls
calls += 1
with open(os.environ['TEST_LOG'], 'a') as f:
f.write('scan\\n')
return calls < 2
''')
(scripts / 'darling-rootless.sh').write_text('''#!/bin/bash
echo "shutdown:$*" >> "$TEST_LOG"
if [ "${SHUTDOWN_RC:-0}" != 0 ] && [ ! -e "$TEST_LOG.retried" ]; then
touch "$TEST_LOG.retried"
exit "$SHUTDOWN_RC"
fi
exit 0
''')
client = scripts / 'run.sh'
client.write_text('''#!/usr/bin/env bash
echo client-finished >> "$TEST_LOG"
exit "${CLIENT_RC:-0}"
'''.replace('#!/usr/bin/env bash', '#!' + str(shutil.which('bash'))))
client.chmod(0o755)
def run_session(self, **environment):
return subprocess.run(['bash', str(self.root / 'scripts/nix-session.sh')],
env=dict(self.env, **environment), text=True,
capture_output=True, timeout=10)
def test_exit_waits_for_command_and_clients_before_shutdown(self):
result = self.run_session()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(self.log.read_text().splitlines(),
['client-finished', 'scan', 'scan', 'shutdown:shutdown'])
def test_command_failure_preserved_and_shutdown_failure_reported(self):
result = self.run_session(CLIENT_RC='17', SHUTDOWN_RC='23')
self.assertEqual(result.returncode, 17, result.stderr)
self.assertIn('shutdown failed', result.stderr)
self.assertIn('23', result.stderr)
self.assertEqual(self.log.read_text().splitlines().count('shutdown:shutdown'), 2)
self.assertIn('shutdown:shutdown', self.log.read_text())
+4 -1
View File
@@ -397,8 +397,11 @@ class SessionTests(unittest.TestCase):
with tempfile.TemporaryDirectory(prefix='nix-dispatch-test-') as directory:
root = Path(directory)
(root / 'scripts').mkdir()
for name in ['nix-state.py', 'nix-session.sh']:
for name in ['nix-state.py', 'nix-session.sh', 'nix-lifecycle.py']:
shutil.copy(ROOT / 'scripts' / name, root / 'scripts' / name)
# Keep lifecycle dispatch inert: no real /proc scan or Darling call.
(root / 'scripts/nix-idle.py').write_text('def client_active(prefix): return False\n')
(root / 'scripts/darling-rootless.sh').write_text('exit 0\n')
(root / 'usr-local/bin').mkdir(parents=True)
(root / 'usr-local/bin/darling').write_text('never executed')
(root / 'usr-local/bin/darlingserver').write_text('never executed')