fix(process_registry): stop systemd-run expanding scoped commands (#132385)
Since systemd 254, `systemd-run --scope` expands $VAR / ${VAR} / $$ in the
command line itself (--expand-environment defaults to yes), so a background
command under a supervised gateway ran rewritten: `$$` became a literal `$`,
`${arr[@]}` became '' with a warning on stderr.
_systemd_scope_argv is the single argv choke point shared by the availability
probe, the pipe/PTY background spawns and restart-safe cron/kanban children,
so pass `--expand-environment=no` there. systemd-run < 254 rejects the
option (and never expanded in scope mode), so the existing cached probe drops
it and re-probes once when systemd-run names it in the rejection — no extra
subprocess on modern hosts and no lost isolation on old ones.
Fixes #132385.
This commit is contained in:
@@ -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."""
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user