/ tests / tools / test_dockerfile_pid1_reaping.py
test_dockerfile_pid1_reaping.py
  1  """Contract tests for the container Dockerfile.
  2  
  3  These tests assert invariants about how the Dockerfile composes its runtime —
  4  they deliberately avoid snapshotting specific package versions, line numbers,
  5  or exact flag choices.  What they DO assert is that the Dockerfile maintains
  6  the properties required for correct production behaviour:
  7  
  8  - A PID-1 init (tini) is installed and wraps the entrypoint, so that orphaned
  9    subprocesses (MCP stdio servers, git, bun, browser daemons) get reaped
 10    instead of accumulating as zombies (#15012).
 11  - Signal forwarding runs through the init so ``docker stop`` triggers
 12    hermes's own graceful-shutdown path.
 13  """
 14  
 15  from __future__ import annotations
 16  
 17  from pathlib import Path
 18  
 19  import pytest
 20  
 21  
 22  REPO_ROOT = Path(__file__).resolve().parents[2]
 23  DOCKERFILE = REPO_ROOT / "Dockerfile"
 24  DOCKERIGNORE = REPO_ROOT / ".dockerignore"
 25  
 26  
 27  @pytest.fixture(scope="module")
 28  def dockerfile_text() -> str:
 29      if not DOCKERFILE.exists():
 30          pytest.skip("Dockerfile not present in this checkout")
 31      return DOCKERFILE.read_text()
 32  
 33  
 34  def _dockerfile_instructions(dockerfile_text: str) -> list[str]:
 35      instructions: list[str] = []
 36      current = ""
 37  
 38      for raw_line in dockerfile_text.splitlines():
 39          line = raw_line.strip()
 40          if not line or line.startswith("#"):
 41              continue
 42  
 43          continued = line.removesuffix("\\").strip()
 44          current = f"{current} {continued}".strip()
 45          if not line.endswith("\\"):
 46              instructions.append(current)
 47              current = ""
 48  
 49      return instructions
 50  
 51  
 52  def _run_steps(dockerfile_text: str) -> list[str]:
 53      return [
 54          instruction
 55          for instruction in _dockerfile_instructions(dockerfile_text)
 56          if instruction.startswith("RUN ")
 57      ]
 58  
 59  
 60  def test_dockerfile_installs_an_init_for_zombie_reaping(dockerfile_text):
 61      """Some init (tini, dumb-init, catatonit) must be installed.
 62  
 63      Without a PID-1 init that handles SIGCHLD, hermes accumulates zombie
 64      processes from MCP stdio subprocesses, git operations, browser
 65      daemons, etc.  In long-running Docker deployments this eventually
 66      exhausts the PID table.
 67      """
 68      # Accept any of the common reapers.  The contract is behavioural:
 69      # something must be installed that reaps orphans.
 70      known_inits = ("tini", "dumb-init", "catatonit")
 71      installed = any(name in dockerfile_text for name in known_inits)
 72      assert installed, (
 73          "No PID-1 init detected in Dockerfile (looked for: "
 74          f"{', '.join(known_inits)}). Without an init process to reap "
 75          "orphaned subprocesses, hermes accumulates zombies in Docker "
 76          "deployments. See issue #15012."
 77      )
 78  
 79  
 80  def test_dockerfile_entrypoint_routes_through_the_init(dockerfile_text):
 81      """The ENTRYPOINT must invoke the init, not the entrypoint script directly.
 82  
 83      Installing tini is only half the fix — the container must actually run
 84      with tini as PID 1.  If the ENTRYPOINT executes the shell script
 85      directly, the shell becomes PID 1 and will ``exec`` into hermes,
 86      which then runs as PID 1 without any zombie reaping.
 87      """
 88      # Find the last uncommented ENTRYPOINT line — Docker honours the final one.
 89      entrypoint_line = None
 90      for raw_line in dockerfile_text.splitlines():
 91          line = raw_line.strip()
 92          if line.startswith("#"):
 93              continue
 94          if line.startswith("ENTRYPOINT"):
 95              entrypoint_line = line
 96  
 97      assert entrypoint_line is not None, "Dockerfile is missing an ENTRYPOINT directive"
 98  
 99      known_inits = ("tini", "dumb-init", "catatonit")
100      routes_through_init = any(name in entrypoint_line for name in known_inits)
101      assert routes_through_init, (
102          f"ENTRYPOINT does not route through an init: {entrypoint_line!r}. "
103          "If tini is only installed but not wired into ENTRYPOINT, hermes "
104          "still runs as PID 1 and zombies will accumulate (#15012)."
105      )
106  
107  
108  def test_dockerfile_installs_tui_dependencies(dockerfile_text):
109      assert "ui-tui/package.json" in dockerfile_text
110      assert "ui-tui/packages/hermes-ink/package-lock.json" in dockerfile_text
111      assert any(
112          "ui-tui" in step and "npm" in step and (" install" in step or " ci" in step)
113          for step in _run_steps(dockerfile_text)
114      )
115  
116  
117  def test_dockerfile_builds_tui_assets(dockerfile_text):
118      assert any(
119          "ui-tui" in step and "npm" in step and "run build" in step
120          for step in _run_steps(dockerfile_text)
121      )
122  
123  
124  def test_dockerfile_materializes_local_tui_ink_package(dockerfile_text):
125      assert any(
126          "ui-tui" in step
127          and "node_modules/@hermes/ink" in step
128          and "packages/hermes-ink" in step
129          and "rm -rf packages/hermes-ink/node_modules" in step
130          and "npm install --omit=dev" in step
131          and "--prefix node_modules/@hermes/ink" in step
132          and "rm -rf node_modules/@hermes/ink/node_modules/react" in step
133          and "await import('@hermes/ink')" in step
134          for step in _run_steps(dockerfile_text)
135      )
136  
137  
138  def test_dockerignore_excludes_nested_dependency_dirs():
139      if not DOCKERIGNORE.exists():
140          pytest.skip(".dockerignore not present in this checkout")
141  
142      text = DOCKERIGNORE.read_text()
143  
144      assert "**/node_modules" in text
145      assert "**/.venv" in text