mirror of
https://github.com/lucannez64/darling-lol.git
synced 2026-10-26 19:47:42 +00:00
fix(nix): simplify compiler setup and complete rootless session lifecycle
This commit is contained in:
1 parent
3c0b9cdfd3
commit
573f290552
12 files changed
+824
-176
No files matched your search
+229
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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')
|
||||
|
||||
Reference in new issue
Block a user