Close inherited host file descriptors before starting the guest

A terminal, IDE or service manager can leave descriptors open in the
launcher, and the guest inherited all of them. tools/exec-guest.py closes
every descriptor above stderr and then execs the guest command.

A shell loop over /proc/self/fd cannot do this portably: dash only parses
single-digit descriptors in redirections, so "exec 10>&-" runs a command
named 10 and ends the launcher on Debian and Ubuntu.

The launcher offline check now leaks descriptors 3, 4, 12 and 200 into the
launcher and asserts the guest sees only 0, 1 and 2. It runs in CI.

Split from #61.

Co-authored-by: DevWebeloper <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
committed 2026-10-18 17:11:36 +02:00
1 parent c943a19606
commit 6d3e870346
5 files changed
+59 -5

No files matched your search

+1
View File
@@ -12,6 +12,7 @@ jobs:
python-version: '3.12' python-version: '3.12'
- run: python scripts/validate.py - run: python scripts/validate.py
- run: python tools/riot-input-offline-check.py - run: python tools/riot-input-offline-check.py
- run: python tools/riot-launcher-offline-check.py
- run: python tools/riot-runtime-cleanup-offline-check.py - run: python tools/riot-runtime-cleanup-offline-check.py
- run: python tools/riot-cdn-dns-offline-check.py - run: python tools/riot-cdn-dns-offline-check.py
- run: python -m unittest discover -s tests -p 'test_hidden_riot_recovery.py' - run: python -m unittest discover -s tests -p 'test_hidden_riot_recovery.py'
+2 -2
View File
@@ -27,7 +27,7 @@ Expected: `METAL_DEVICE_INFO failures=0 pass=1`. It covers how `MTLDevice.name`
The LLVM version check compiles Iridium's actual dynamic loader against small stub libraries. It verifies that a matching major initializes, older/newer majors produce a version mismatch, and a missing library fails initialization. It does not start Darling. The native AIR translator (`python3 tools/build-riot-air-check.py --native`) selects both headers and the shared library from the same `llvm-config`; its build prints those paths so shader results can be attributed to the tested LLVM installation. The LLVM version check compiles Iridium's actual dynamic loader against small stub libraries. It verifies that a matching major initializes, older/newer majors produce a version mismatch, and a missing library fails initialization. It does not start Darling. The native AIR translator (`python3 tools/build-riot-air-check.py --native`) selects both headers and the shared library from the same `llvm-config`; its build prints those paths so shader results can be attributed to the tested LLVM installation.
The patch check uses temporary base indexes and does not reset or modify the source checkout. The launcher check uses stub commands and never starts or contacts Riot or Darling. It checks installed-client and first-time-installer launches, strips inherited `DYLD_INSERT_LIBRARIES` even with the old VPX opt-in set, and preserves Darling runtime library/framework paths. The memory-installer check imports the installer and exercises its policy (default source is the prepared artifact; the artifact is never a destination of a different library) in temporary directories. The Foundation-installer check does the same for the overlay installer: it seeds a temporary framework from a stub SDK, replaces `Versions/C/Foundation`, keeps the standard symlinks, stages a backup, treats a re-run as current and refuses a non-Mach-O source. The installer handoff mock covers 28 scenarios, including HTTP 409, interrupted verification and preserving active downloads. The patch check uses temporary base indexes and does not reset or modify the source checkout. The launcher check uses stub commands and never starts or contacts Riot or Darling. It checks installed-client and first-time-installer launches, strips inherited `DYLD_INSERT_LIBRARIES` even with the old VPX opt-in set, preserves Darling runtime library/framework paths, and checks that host file descriptors the launcher inherited (including multi-digit ones) are closed before the guest starts. The memory-installer check imports the installer and exercises its policy (default source is the prepared artifact; the artifact is never a destination of a different library) in temporary directories. The Foundation-installer check does the same for the overlay installer: it seeds a temporary framework from a stub SDK, replaces `Versions/C/Foundation`, keeps the standard symlinks, stages a backup, treats a re-run as current and refuses a non-Mach-O source. The installer handoff mock covers 28 scenarios, including HTTP 409, interrupted verification and preserving active downloads.
## NSURLSession lifecycle regression ## NSURLSession lifecycle regression
@@ -181,7 +181,7 @@ build/riot-host-receive-check
python3 tools/riot-launcher-offline-check.py python3 tools/riot-launcher-offline-check.py
``` ```
Expected: `warm_empty_allocations=0 single_packet_allocations=2`, correct credentials, descriptor ownership/CLOEXEC, sender addresses, multiple batches, truncation flags, callback reentrancy and exception cleanup. The old receive implementation makes 32 allocations for a warmed empty receive and 64 for a single-packet receive in the same fixture. These are allocation counts, not an FPS benchmark. The launcher check uses stub commands and never starts or contacts Riot or Darling. It checks installed-client and first-time-installer launches, strips inherited `DYLD_INSERT_LIBRARIES` even with the old VPX opt-in set, and preserves Darling runtime library/framework paths. Expected: `warm_empty_allocations=0 single_packet_allocations=2`, correct credentials, descriptor ownership/CLOEXEC, sender addresses, multiple batches, truncation flags, callback reentrancy and exception cleanup. The old receive implementation makes 32 allocations for a warmed empty receive and 64 for a single-packet receive in the same fixture. These are allocation counts, not an FPS benchmark. The launcher check uses stub commands and never starts or contacts Riot or Darling. It checks installed-client and first-time-installer launches, strips inherited `DYLD_INSERT_LIBRARIES` even with the old VPX opt-in set, preserves Darling runtime library/framework paths, and checks that host file descriptors the launcher inherited (including multi-digit ones) are closed before the guest starts.
The same source passed AddressSanitizer/UndefinedBehaviorSanitizer and leak detection. Allocation counts depend on this fixture/compiler/library; preserve the no-wasted-slot property rather than interpreting them as game performance. See [setup](SETUP.md#updating-an-existing-runtime) for server build/install/activation. The same source passed AddressSanitizer/UndefinedBehaviorSanitizer and leak detection. Allocation counts depend on this fixture/compiler/library; preserve the no-wasted-slot property rather than interpreting them as game performance. See [setup](SETUP.md#updating-an-existing-runtime) for server build/install/activation.
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env python3
"""Run a command without the host file descriptors this launcher inherited (anything above stderr)."""
import os
import sys
def close_inherited_fds():
"""A terminal, IDE or service manager can leak descriptors; the guest must not inherit them."""
try:
names = os.listdir('/proc/self/fd')
except OSError:
os.closerange(3, 65536)
return
for name in names:
if name.isdigit() and int(name) > 2:
try:
os.close(int(name))
except OSError:
pass # the descriptor listdir used is already closed
def main(argv):
if len(argv) < 2:
sys.exit('usage: exec-guest.py COMMAND [ARGUMENT]...')
close_inherited_fds()
os.execvp(argv[1], argv[1:])
if __name__ == '__main__':
main(sys.argv)
+23 -2
View File
@@ -16,13 +16,25 @@ def check():
folder = Path(directory) folder = Path(directory)
launcher = folder / "launch.sh" launcher = folder / "launch.sh"
launcher.write_text((ROOT / "tools/run-riot-client.sh").read_text().replace("@GUEST_HOME@", "/Users/offline-check")) launcher.write_text((ROOT / "tools/run-riot-client.sh").read_text().replace("@GUEST_HOME@", "/Users/offline-check"))
(folder / "exec-guest.py").write_text((ROOT / "tools/exec-guest.py").read_text())
binary_folder = folder / "bin" binary_folder = folder / "bin"
binary_folder.mkdir() binary_folder.mkdir()
log = folder / "calls.jsonl" log = folder / "calls.jsonl"
stub = f'''#!{sys.executable} stub = f'''#!{sys.executable}
import json, os, sys import json, os, sys
record = {{"command": os.path.basename(sys.argv[0]), "args": sys.argv[1:]}} record = {{"command": os.path.basename(sys.argv[0]), "args": sys.argv[1:]}}
# The launcher starts the guest through exec-guest.py; run it for real.
if record["command"] == "python3" and sys.argv[1:] and os.path.basename(sys.argv[1]) == "exec-guest.py":
os.execv(sys.executable, [sys.executable] + sys.argv[1:])
if record["command"] == "darling": if record["command"] == "darling":
# Descriptors open now were inherited; the one listdir used is closed again before this check.
def is_open(fd):
try:
os.fstat(fd)
return True
except OSError:
return False
record["fds"] = sorted(fd for fd in map(int, os.listdir("/proc/self/fd")) if is_open(fd))
record["flags"] = {{key: os.environ.get(key) for key in record["flags"] = {{key: os.environ.get(key) for key in
("RCLOL_APP_ARG3", "RCLOL_APP_ARG3_SLOT", "RCLOL_UX_ARG2", "RCLOL_UX_ARG2_SLOT", "RCLOL_UX_ARG1", "RCLOL_APP_ARGS", "RCLOL_APP_ARG2", "DYLD_INSERT_LIBRARIES", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH", "DARLING_XSHM_LIBRARY")}} ("RCLOL_APP_ARG3", "RCLOL_APP_ARG3_SLOT", "RCLOL_UX_ARG2", "RCLOL_UX_ARG2_SLOT", "RCLOL_UX_ARG1", "RCLOL_APP_ARGS", "RCLOL_APP_ARG2", "DYLD_INSERT_LIBRARIES", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH", "DARLING_XSHM_LIBRARY")}}
with open({str(log)!r}, "a") as output: with open({str(log)!r}, "a") as output:
@@ -60,7 +72,15 @@ with open({str(log)!r}, "a") as output:
"RCLOL_OPEN_URL_BRIDGE": "1", "RCLOL_OPEN_URL_BRIDGE": "1",
"RCLOL_DISABLE_GPU_VSYNC": "1", "RCLOL_DISABLE_GPU_VSYNC": "1",
}) })
subprocess.run(["sh", str(launcher)], env=env, check=True, capture_output=True, text=True) # Leaked host descriptors, including multi-digit ones, must not reach the guest.
leaked = list(os.pipe())
for target in (12, 200):
leaked.append(os.dup2(leaked[0], target, inheritable=False))
try:
subprocess.run(["sh", str(launcher)], env=env, check=True, capture_output=True, text=True, pass_fds=leaked)
finally:
for fd in leaked:
os.close(fd)
records = [json.loads(line) for line in log.read_text().splitlines()] records = [json.loads(line) for line in log.read_text().splitlines()]
maintenance = [r for r in records if r["command"] == "python3"] maintenance = [r for r in records if r["command"] == "python3"]
assert [Path(r["args"][0]).name for r in maintenance] == [ assert [Path(r["args"][0]).name for r in maintenance] == [
@@ -70,6 +90,7 @@ with open({str(log)!r}, "a") as output:
launches = [r for r in records if r["command"] == "darling"] launches = [r for r in records if r["command"] == "darling"]
assert len(launches) == 1, "unexpected launch count" assert len(launches) == 1, "unexpected launch count"
launch = launches[0] launch = launches[0]
assert launch["fds"] == [0, 1, 2], f"inherited descriptors reached the guest: {launch['fds']}"
assert all(launch["flags"][key] is None for key in ( assert all(launch["flags"][key] is None for key in (
"RCLOL_APP_ARG3", "RCLOL_APP_ARG3_SLOT", "RCLOL_UX_ARG2", "RCLOL_UX_ARG2_SLOT" "RCLOL_APP_ARG3", "RCLOL_APP_ARG3_SLOT", "RCLOL_UX_ARG2", "RCLOL_UX_ARG2_SLOT"
)), "inherited inspector/unlimited-frame argument survived" )), "inherited inspector/unlimited-frame argument survived"
@@ -95,7 +116,7 @@ with open({str(log)!r}, "a") as output:
subprocess.run(["sh", str(launcher)], env=env, check=True, capture_output=True, text=True) subprocess.run(["sh", str(launcher)], env=env, check=True, capture_output=True, text=True)
clean = json.loads(log.read_text().splitlines()[-1]) clean = json.loads(log.read_text().splitlines()[-1])
assert clean["flags"]["DYLD_INSERT_LIBRARIES"] is None assert clean["flags"]["DYLD_INSERT_LIBRARIES"] is None
print("LAUNCHER_OFFLINE pass=1 installed=PASS installer=PASS inherited_injection=absent runtime=preserved") print("LAUNCHER_OFFLINE pass=1 installed=PASS installer=PASS inherited_injection=absent runtime=preserved inherited_fds=closed")
if __name__ == "__main__": if __name__ == "__main__":
+3 -1
View File
@@ -104,7 +104,9 @@ fi
# On affected setups Chromium's default temp directory is unusable. Electron # On affected setups Chromium's default temp directory is unusable. Electron
# can copy app.asar entries to temporary files for async reads and report a # can copy app.asar entries to temporary files for async reads and report a
# failed copy as "not found". Use a writable guest directory by default. # failed copy as "not found". Use a writable guest directory by default.
exec env -u ELECTRON_RUN_AS_NODE -u DYLD_INSERT_LIBRARIES EGL_PLATFORM=x11 \ # exec-guest.py closes host file descriptors above stderr before starting the guest. A shell
# loop cannot: dash only accepts single-digit descriptors in redirections.
exec python3 "$runner_dir/exec-guest.py" env -u ELECTRON_RUN_AS_NODE -u DYLD_INSERT_LIBRARIES EGL_PLATFORM=x11 \
MAC_CHROMIUM_TMPDIR="${MAC_CHROMIUM_TMPDIR:-/private/tmp}" \ MAC_CHROMIUM_TMPDIR="${MAC_CHROMIUM_TMPDIR:-/private/tmp}" \
LANG="${RCLOL_GUEST_LOCALE:-en_US.UTF-8}" LC_ALL="${RCLOL_GUEST_LOCALE:-en_US.UTF-8}" \ LANG="${RCLOL_GUEST_LOCALE:-en_US.UTF-8}" LC_ALL="${RCLOL_GUEST_LOCALE:-en_US.UTF-8}" \
mesa_glthread="${RCLOL_MESA_GLTHREAD-${mesa_glthread-false}}" \ mesa_glthread="${RCLOL_MESA_GLTHREAD-${mesa_glthread-false}}" \