docs(internet-connection-monitor): require an init process to reap Chrome's orphans - #34
Merged
Merged
Conversation
…rome's orphans Scope: internet-connection-monitor/ only. No other subproject is affected. Every probe launches headless Chrome, which forks its own zygote, GPU and renderer processes. chromedp kills only the top-level Chrome process when a probe ends, so those children are orphaned and re-parented onto PID 1. When PID 1 is the monitor binary itself -- the default, since the image has a bare ENTRYPOINT -- nothing ever calls wait() on them and each becomes a permanent zombie, roughly five per probe. In a real deployment this filled the container's cgroup PID limit (pids.max=11741) every ~96 minutes, producing 253 kernel fork-rejections over 18 days: cgroup: fork rejected by pids controller in /system.slice/docker-<id>.scope It went unnoticed because the container recovered fast enough that 10-minute metric buckets never went empty. Running with an init puts tini at PID 1, which reaps those orphans as they exit. Measured with --pids-limit 400 and an otherwise identical workload: without --init the container hit the ceiling at t=40s with 348 zombies and was dead by t=120s; with --init it held at 66-81 PIDs and 0 zombies. Sets init: true in both compose files under internet-connection-monitor/deployments/ and documents the requirement in that subproject's README.md and TROUBLESHOOTING.md, including the symptoms to recognize it by and a check for what is actually running as PID 1. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VJHwLHJ9R5jKKiVbgXgVTc
NickBorgers
force-pushed
the
docs/require-init-for-pid-reaping
branch
from
August 29, 2026 04:42
a9cef94 to
b535e4e
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Scope:
internet-connection-monitor/only. No other subproject in this monorepo is touched, and nothing here applies to them — the bug is specific to a service that launches headless Chrome on a loop. All paths below are relative tointernet-connection-monitor/.Problem
Run the internet-connection-monitor container without an init process and it leaks a zombie on every probe. Eventually it cannot
fork()at all.Impact (measured in a real deployment)
The container hit its cgroup PID ceiling (
pids.max=11741) every ~96 minutes and got fork-rejected by the kernel:That fired 253 times over 18 days, evenly spaced — roughly 14x/day. It went unnoticed because the container recovers fast enough that 10-minute metric buckets never went empty, so dashboards looked healthy throughout.
Leak rate, growing linearly with no plateau (~5 zombies per probe):
Cause
The image has a bare
ENTRYPOINT ["/app/internet-monitor"], so the monitor binary is PID 1.Each probe launches headless Chrome via chromedp. Chrome forks its own zygote, GPU and per-navigation renderer processes. chromedp starts and waits on exactly one process — the top-level Chrome — and kills only that PID when the probe ends. Chrome's children survive the instant, are orphaned, and re-parent onto PID 1. Nothing ever calls
wait()on them, so each becomes a permanent zombie.This is not a skipped-cleanup bug, and it is not caused by any particular site failing. Every probe leaks identically, success or error.
Fix
Run with an init process, so tini is PID 1 and reaps the orphans. Measured with
--pids-limit 400and an otherwise identical workload:--init--initChanges, all under
internet-connection-monitor/:deployments/docker-compose.ymlanddeployments/docker-compose.with-stack.yml— setinit: trueREADME.md— a "Running the container" section stating the requirement, andinit: truein the GHCR compose snippetTROUBLESHOOTING.md— a "Zombie Processes / Container Cannot Fork" entry with the symptoms, a check for what is actually running as PID 1, and the fixHistory
This replaces #33, which fixed the same leak inside the Go binary with an init-style
wait4(-1, ...)reaper plus a process-group kill. That approach worked, but adversarial review found it carried a PID-reuse hazard: reaping Chrome early frees its PID, and the process-group kill still holds that stale number, so it could signal an unrelated process group. It also raced chromedp's owncmd.Wait(), safe only because chromedp currently discards that error.Since a standard init fixes the leak completely and carries none of that risk, the documentation fix is the better trade. #33 is closed in favor of this.
One real trade is being made. A process-group kill alone does not fix the leak — killing the group SIGKILLs Chrome and its children together, so Chrome cannot reap them and they still orphan onto PID 1. Reaping genuinely requires an init. So this PR makes the binary depend on being run correctly rather than being self-sufficient. Given that an init is the normal way to run any container that spawns subprocesses, that seems like the right trade.
Verified: both compose files parse (
docker compose config), andinit: truerenders.🤖 Generated with Claude Code
https://claude.ai/code/session_01VJHwLHJ9R5jKKiVbgXgVTc