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