Nothing open on GitHub. Fix merged in #46266. Check it covers your case.
The short answer
- Known?
- Yes. 5 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #46266. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #46266. That is separate from the fix event. - Still open
- Nothing open on GitHub.
A bug causes gateway services to auto-start when creating new profiles in s6 containers, leading to token-conflict zombie processes. These members address registering profile gateways without triggering automatic startup.
Open issues0
Open PRs0
Fixes on record
-
#46266 fix(s6): register profile gateways without auto-starting
files:
hermes_cli/profiles.py,hermes_cli/service_manager.py,tests/docker/test_container_restart.py,tests/hermes_cli/test_profiles_s6_hooks.py,tests/hermes_cli/test_service_manager.pyShow the diff
-
hermes_cli/profiles.py@@ -1229,7 +1229,7 @@ def _maybe_register_gateway_service(profile_name: str) -> None: if not mgr.supports_runtime_registration(): return # host backend; no-op try: - mgr.register_profile_gateway(profile_name) + mgr.register_profile_gateway(profile_name, start_now=False) except ValueError: # Already registered (e.g. the container-boot reconciler ran # first and brought up a stale slot). That's fine. -
hermes_cli/service_manager.py@@ -77,6 +77,7 @@ def register_profile_gateway( profile: str, *, extra_env: dict[str, str] | None = None, + start_now: bool = True, ) -> None: ... def unregister_profile_gateway(self, profile: str) -> None: ... def list_profile_gateways(self) -> list[str]: ... @@ -175,6 +176,7 @@ def register_profile_gateway( profile: str, *, extra_env: dict[str, str] | None = None, + start_now: bool = True, ) -> None: raise NotImplementedError( f"{type(self).__name__} does not support runtime profile " @@ -823,15 +825,15 @@ def register_profile_gateway( profile: str, *, extra_env: dict[str, str] | None = None, + start_now: bool = True, ) -> None: """Create the s6 service directory for a profile gateway. Triggers ``s6-svscanctl -a`` so s6-svscan picks the new directory - up immediately. The service is created in the *up* state — to - register without auto-starting, follow up with ``stop(profile)`` - (or pass the start flag via the future ``start_now=False`` arg, - which the Phase 4 reconciliation path uses via a ``down`` - marker file written directly). + up immediately. When *start_now* is ``True`` (the default) the + service starts immediately; when ``False`` a ``down`` marker file + is written so s6-supervise leaves the service stopped until the + user explicitly runs ``hermes -p <profile> gateway start``. Raises: ValueError: if the profile name is invalid or the service @@ -879,6 +881,13 @@ def register_profile_gateway( # rationale. _seed_supervise_skeleton(tmp_dir) + # When start_now is False, write a `down` marker so + # s6-supervise does not auto-start the service on rescan. + # Mirrors the same pattern in container_boot.py + # _register_gateway_slot when start=False. + if not start_now: + (tmp_dir / "down").touch() + tmp_dir.rename(svc_dir) except Exception: shutil.rmtree(tmp_dir, ignore_errors=True) -
tests/docker/test_container_restart.py@@ -296,7 +296,8 @@ def test_live_gateway_autostarts_after_real_restart_without_manual_state_stamp( ) if r.returncode == 0 and '"gateway_state"' in r.stdout: state = r.stdout - break + if '"running"' in state: + break time.sleep(0.5) assert '"running"' in state, ( f"gateway never persisted running state pre-restart: {state!r}" -
tests/hermes_cli/test_profiles_s6_hooks.py@@ -54,10 +54,12 @@ def supports_runtime_registration(self) -> bool: def register_profile_gateway( self, profile: str, *, extra_env: dict[str, str] | None = None, + start_now: bool = True, ) -> None: if self.raise_on_register is not None: raise self.raise_on_register self.registered.append(profile) + self.last_start_now = start_now def unregister_profile_gateway(self, profile: str) -> None: if self.raise_on_unregister is not None: @@ -107,6 +109,21 @@ def test_register_calls_through_on_s6(monkeypatch: pytest.MonkeyPatch) -> None: assert mgr.registered == ["coder"] +def test_register_passes_start_now_false(monkeypatch: pytest.MonkeyPatch) -> None: + """_maybe_register_gateway_service must register with start_now=False + so that profile creation does not auto-start a gateway that may + conflict with the main gateway's bot-token lock.""" + _patch_detect_s6(monkeypatch) + mgr = _S6Manager() + monkeypatch.setattr( + "hermes_cli.service_manager.get_service_manager", lambda: mgr, + ) + _maybe_register_gateway_service("coder") + assert mgr.last_start_now is False, ( + "profile creation must not auto-start the gateway service" + ) + + def test_register_swallows_duplicate_value_error( monkeypatch: pytest.MonkeyPatch, ) -> None: -
tests/hermes_cli/test_service_manager.py@@ -572,6 +572,35 @@ def test_s6_register_creates_service_dir_and_triggers_scan( ), f"s6-svscanctl -a not invoked; saw: {fake_subprocess_run}" +def test_s6_register_start_now_false_writes_down_marker( + s6_scandir, fake_subprocess_run, +) -> None: + """When start_now=False, a `down` marker must be written so + s6-supervise does not auto-start the service on rescan.""" + mgr = S6ServiceManager(scandir=s6_scandir) + mgr.register_profile_gateway("coder", start_now=False) + + svc_dir = s6_scandir / "gateway-coder" + assert svc_dir.is_dir() + assert (svc_dir / "down").is_file(), ( + "start_now=False must write a `down` marker file" + ) + + +def test_s6_register_start_now_true_no_down_marker( + s6_scandir, fake_subprocess_run, +) -> None: + """When start_now=True (default), no `down` marker should exist.""" + mgr = S6ServiceManager(scandir=s6_scandir) + mgr.register_profile_gateway("coder") + + svc_dir = s6_scandir / "gateway-coder" + assert svc_dir.is_dir() + assert not (svc_dir / "down").exists(), ( + "start_now=True must NOT write a `down` marker file" + ) + + def test_s6_register_extra_env_is_quoted(s6_scandir, fake_subprocess_run) -> None: mgr = S6ServiceManager(scandir=s6_scandir) mgr.register_profile_gateway(
-
-
#46293 fix(docker): skip gateway reconciliation in dashboard container (autodetect)
files:
hermes_cli/container_boot.py,scripts/release.py,tests/hermes_cli/test_container_boot.pyShow the diff
-
hermes_cli/container_boot.py@@ -207,8 +207,15 @@ def _read_container_argv() -> tuple[str, ...]: return tuple(part.decode("utf-8", "replace") for part in raw.split(b"\0") if part) -def _is_legacy_gateway_run_request(argv: Sequence[str]) -> bool: - """Return True for Docker commands equivalent to `gateway run`.""" +def _strip_container_argv_prefix(argv: Sequence[str]) -> list[str]: + """Strip the s6/wrapper prefix off PID 1 argv, leaving the hermes args. + + The container PID 1 argv looks like + ``/init /opt/hermes/docker/main-wrapper.sh <subcommand> [args...]`` and + the wrapper re-execs ``hermes <subcommand>``. Peel ``init`` → + ``main-wrapper.sh`` → ``hermes`` so callers can match on the bare + subcommand. Shared by the legacy-gateway and dashboard role detectors. + """ args = list(argv) if args and Path(args[0]).name == "init": args = args[1:] @@ -216,11 +223,38 @@ def _is_legacy_gateway_run_request(argv: Sequence[str]) -> bool: args = args[1:] if args and Path(args[0]).name == "hermes": args = args[1:] + return args + + +def _is_legacy_gateway_run_request(argv: Sequence[str]) -> bool: + """Return True for Docker commands equivalent to `gateway run`.""" + args = _strip_container_argv_prefix(argv) if "--no-supervise" in args: return False return len(args) >= 2 and args[0] == "gateway" and args[1] == "run" +def _is_dashboard_container(argv: Sequence[str]) -> bool: + """Return True when the container's command is the dashboard. + + A dashboard-only container (``hermes dashboard ...``) never spawns or + supervises per-profile gateways — that is the gateway container's job. + Reconciling profile gateway s6 slots there is not just wasted work: when + the gateway and dashboard containers share a bind-mounted HERMES_HOME, + both race to ``flock()`` the same ``logs/gateways/<profile>/lock`` files, + producing "Resource busy" failures and an s6-log restart storm. So the + dashboard container skips reconciliation entirely. + + Detected from PID 1 argv (``/proc/1/cmdline``) rather than an operator + flag: the role is a fact about the container's command, not a tunable, + and a flag can be forgotten in a hand-written compose/k8s manifest — + reintroducing the exact storm this prevents. Mirrors the argv handling + in :func:`_is_legacy_gateway_run_request`. + """ + args = _strip_container_argv_prefix(argv) + return bool(args) and args[0] == "dashboard" + + def _read_desired_state(profile_dir: Path) -> str | None: """Read the persisted gateway desired state for reconciliation. @@ -393,6 +427,22 @@ def _write_reconcile_log( def main() -> int: """Entry point invoked from /etc/cont-init.d/02-reconcile-profiles.""" + # A dashboard-only container never spawns or supervises per-profile + # gateways, so reconciling their s6 slots here is pure waste — and + # actively harmful: when the gateway and dashboard containers share a + # bind-mounted HERMES_HOME, both race to flock() the same s6-log lock + # files under logs/gateways/<profile>/lock, producing "Resource busy" + # failures and a restart storm. Detect the role from PID 1 argv and + # skip reconciliation in the dashboard container. No operator flag: + # the role is a fact about the container's command, and a flag can be + # forgotten in a hand-written manifest, reintroducing the storm. + if _is_dashboard_container(_read_container_argv()): + print( + "reconcile: skipping (dashboard container — does not need " + "per-profile gateways)" + ) + return 0 + hermes_home = Path(os.environ.get("HERMES_HOME", "/opt/data")) scandir = Path(os.environ.get("S6_PROFILE_GATEWAY_SCANDIR", "/run/service")) actions = reconcile_profile_gateways( -
scripts/release.py@@ -84,6 +84,7 @@ "[redacted:email]": "synapsesx", "[redacted:email]": "itsflownium", "[redacted:email]": "dirtyren", + "[redacted:email]": "895252509", "[redacted:email]": "zxcasongs", "[redacted:email]": "alfred-smith-0", "[redacted:email]": "tangtaizong666", -
tests/hermes_cli/test_container_boot.py@@ -708,3 +708,144 @@ def test_profiles_default_subdir_is_skipped_with_warning( assert any( "profiles/default/" in record.message for record in caplog.records ) + + +# --------------------------------------------------------------------------- +# Dashboard-container role detection (skip reconcile on the dashboard) +# --------------------------------------------------------------------------- + + [redacted:email]( + "container_argv", + [ + # Bare subcommand (docker run ... dashboard ...). + ("dashboard",), + ("dashboard", "--host", "127.0.0.1", "--no-open"), + # Through s6 /init + the main-wrapper that re-execs `hermes`. + ("/init", "/opt/hermes/docker/main-wrapper.sh", "dashboard"), + ( + "/init", + "/opt/hermes/docker/main-wrapper.sh", + "dashboard", + "--host", + "127.0.0.1", + "--no-open", + ), + # Wrapper that kept the explicit `hermes` argv0. + ("/init", "/opt/hermes/docker/main-wrapper.sh", "hermes", "dashboard"), + ], +) +def test_is_dashboard_container_true_for_dashboard_argv( + container_argv: tuple[str, ...], +) -> None: + """A dashboard command is detected across every wrapper prefix shape.""" + from hermes_cli.container_boot import _is_dashboard_container + + assert _is_dashboard_container(container_argv) is True + + [redacted:email]( + "container_argv", + [ + (), # empty (/proc/1/cmdline unreadable) — not the dashboard + ("gateway", "run"), + ("/init", "/opt/hermes/docker/main-wrapper.sh", "gateway", "run"), + ("/init", "/opt/hermes/docker/main-wrapper.sh", "hermes", "gateway", "run"), + ("chat",), + # A profile literally named "dashboard" must NOT match — the token + # we key on is the SUBCOMMAND, and `gateway run -p dashboard` is a + # gateway container. + ("gateway", "run", "-p", "dashboard"), + ], +) +def test_is_dashboard_container_false_for_non_dashboard_argv( + container_argv: tuple[str, ...], +) -> None: + """Gateway / other commands (and empty argv) are not the dashboard.""" + from hermes_cli.container_boot import _is_dashboard_container + + assert _is_dashboard_container(container_argv) is False + + +def test_main_skips_reconcile_in_dashboard_container( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """main() must NOT reconcile when PID 1 argv is the dashboard command. + + A running profile is seeded so that, if reconcile ran, it would create + the gateway-<profile> slot. Asserting the slot is absent proves the + skip is real, not just a log line. + """ + from hermes_cli import container_boot + + scandir = tmp_path / "run-service"; scandir.mkdir() + _make_profile(tmp_path, "worker", state="running") + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("S6_PROFILE_GATEWAY_SCANDIR", str(scandir)) + monkeypatch.setattr( + container_boot, + "_read_container_argv", + lambda: ("/init", "/opt/hermes/docker/main-wrapper.sh", "dashboard"), + ) + + rc = container_boot.main() + + assert rc == 0 + assert not (scandir / "gateway-worker").exists() + assert not (scandir / "gateway-default").exists() + assert "skipping (dashboard container" in capsys.readouterr().out + + +def test_main_reconciles_in_gateway_container( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """main() reconciles normally when PID 1 argv is the gateway command — + the dashboard skip is scoped strictly to the dashboard role.""" + from hermes_cli import container_boot + + scandir = tmp_path / "run-service"; scandir.mkdir() + _make_profile(tmp_path, "worker", state="running") + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("S6_PROFILE_GATEWAY_SCANDIR", str(scandir)) + monkeypatch.setattr( + container_boot, + "_read_container_argv", + lambda: ("/init", "/opt/hermes/docker/main-wrapper.sh", "gateway", "run"), + ) + + rc = container_boot.main() + + assert rc == 0 + # The worker slot was registered + started (prior_state running). + assert (scandir / "gateway-worker").exists() + assert not (scandir / "gateway-worker" / "down").exists() + + +def test_main_ignores_removed_skip_reconcile_env_var( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The legacy HERMES_SKIP_PROFILE_RECONCILE flag is gone: setting it on a + gateway container must NOT suppress reconciliation. Role is decided by + PID 1 argv alone, so a stale flag in someone's manifest is inert.""" + from hermes_cli import container_boot + + scandir = tmp_path / "run-service"; scandir.mkdir() + _make_profile(tmp_path, "worker", state="running") + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("S6_PROFILE_GATEWAY_SCANDIR", str(scandir)) + monkeypatch.setenv("HERMES_SKIP_PROFILE_RECONCILE", "1") + monkeypatch.setattr( + container_boot, + "_read_container_argv", + lambda: ("/init", "/opt/hermes/docker/main-wrapper.sh", "gateway", "run"), + ) + + rc = container_boot.main() + + assert rc == 0 + # Reconcile still ran despite the stale env var. + assert (scandir / "gateway-worker").exists()
-
2 PRs closed without merging
From the close comments: superseded by another PR 2
Closed issues · 1
- Closed#45963[Bug]: `profile create` auto-starts a doomed resident gateway for every new profile in the s6 container (token-conflict zombies that resurrect on restart)
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.40: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab