diff --git a/tests/tools/test_process_registry.py b/tests/tools/test_process_registry.py index 8faa5cf9a3..3c4ff4c69c 100644 --- a/tests/tools/test_process_registry.py +++ b/tests/tools/test_process_registry.py @@ -2635,6 +2635,34 @@ class TestSystemdCgroupIsolation: value.startswith("OOMPolicy=") for value in probe_argv if isinstance(value, str) ), probe_argv + @pytest.mark.platforms("linux") + @pytest.mark.parametrize("rejects_no_expand", [False, True], ids=["systemd>=254", "systemd<254"]) + def test_scoped_command_is_not_expanded_by_systemd_run(self, monkeypatch, rejects_no_expand): + """systemd >= 254 rewrites ``$$``/``${X}`` in a --scope command unless given + ``--expand-environment=no``; older systemd-run rejects that option, and losing + it there must not cost the scope (#132385).""" + import tools.process_registry as pr + + monkeypatch.setattr(pr, "_SYSTEMD_SCOPE_AVAILABLE", None) + monkeypatch.setattr(pr, "_SYSTEMD_RUN_NO_EXPAND", True) + probes = [] + + def fake_run(argv, **kwargs): + probes.append(argv) + if rejects_no_expand and "--expand-environment=no" in argv: + return subprocess.CompletedProcess( + argv, 1, stderr=b"systemd-run: unrecognized option '--expand-environment=no'") + return subprocess.CompletedProcess(argv, 0, stderr=b"") + + monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/systemd-run") + monkeypatch.setattr("subprocess.run", fake_run) + + assert pr._systemd_run_user_scope_available() is True + spawn = pr._build_systemd_scope_argv(["bash", "-c", "echo $$"], unit_suffix="t") + assert ("--expand-environment=no" in spawn) is not rejects_no_expand + assert spawn[spawn.index("--") + 1:] == ["bash", "-c", "echo $$"] + assert len(probes) == (2 if rejects_no_expand else 1) + @pytest.mark.platforms("linux") def test_successful_systemd_probe_revalidates_after_cache_ttl(self, monkeypatch): """A vanished user bus invalidates a formerly successful scope verdict.""" diff --git a/tools/process_registry.py b/tools/process_registry.py index 80b5173da2..d5bdd5e659 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -105,6 +105,9 @@ _SYSTEMD_SCOPE_PROBED_AT = 0.0 # Both verdicts expire: the user bus can vanish after a True (session logout without linger, # #110803) and reappear after a False (linger enabled later, #104893). _SYSTEMD_SCOPE_PROBE_TTL_SECONDS = 60.0 +# systemd >= 254 expands ``$$``/``${X}`` in a ``--scope`` command line itself unless told not to; +# older systemd-run rejects the option (and never expanded there), so the probe drops it on rejection. +_SYSTEMD_RUN_NO_EXPAND = True _MIN_WORKER_MEMORY_MAX_BYTES = 64 * 1024 * 1024 _DEFAULT_WORKER_MEMORY_MAX_BYTES = 1024 * 1024 * 1024 _WORKER_MEMORY_MAX_CAP_BYTES = 4 * 1024 * 1024 * 1024 @@ -167,9 +170,11 @@ def _worker_memory_max_bytes() -> int: def _systemd_scope_argv(binary: str, unit_name: str, *argv: str) -> List[str]: """``systemd-run --user --scope`` argv shared by the probe and real spawns. ``--collect`` self-cleans the scope after exit; ``--unit`` names it for systemctl. - No ``OOMPolicy=``: transient scopes reject it on systemd <253 (#102486).""" + No ``OOMPolicy=``: transient scopes reject it on systemd <253 (#102486). + ``--expand-environment=no`` keeps the command byte-identical (#132385).""" + no_expand = ["--expand-environment=no"] if _SYSTEMD_RUN_NO_EXPAND else [] return [ - binary, "--user", "--scope", "--quiet", "--unit", unit_name, "--collect", + binary, "--user", "--scope", "--quiet", *no_expand, "--unit", unit_name, "--collect", "--property", "MemoryAccounting=yes", "--property", f"MemoryMax={_worker_memory_max_bytes()}", "--", *argv, @@ -248,7 +253,7 @@ def _systemd_run_user_scope_available() -> bool: Use ``/bin/sh -c 'exit 0'``: NixOS provides ``/bin/sh`` but not ``/bin/true`` (#105365), regardless of the gateway service's PATH.""" - global _SYSTEMD_SCOPE_AVAILABLE, _SYSTEMD_SCOPE_PROBED_AT + global _SYSTEMD_SCOPE_AVAILABLE, _SYSTEMD_SCOPE_PROBED_AT, _SYSTEMD_RUN_NO_EXPAND verdict = _systemd_scope_cached() if verdict is not None: return verdict @@ -267,12 +272,18 @@ def _systemd_run_user_scope_available() -> bool: if binary: # Unique unit avoids collisions; the timeout bounds D-Bus. probe_unit = f"hermes-probe-scope-{os.getpid()}-{uuid.uuid4().hex[:8]}" - result = subprocess.run( - _systemd_scope_argv(binary, probe_unit, "/bin/sh", "-c", "exit 0"), - capture_output=True, - timeout=3, - env=systemd_user_bus_env(), - ) + for _attempt in range(2): + result = subprocess.run( + _systemd_scope_argv(binary, probe_unit, "/bin/sh", "-c", "exit 0"), + capture_output=True, + timeout=3, + env=systemd_user_bus_env(), + ) + if not (result.returncode and _SYSTEMD_RUN_NO_EXPAND + and b"expand-environment" in (result.stderr or b"")): + break + # systemd < 254 rejects the option: drop it and probe again. + _SYSTEMD_RUN_NO_EXPAND = False available = result.returncode == 0 if not available: logger.debug(