Files
mangaBookmark/docs/adr/0005-on-demand-browser.md
T
sulthan 2a3bb6922d Move the browser off the VPS to its own unit (#46) (#52)
Closes #46 once deployed.

The headless browser leaves the API stack and becomes its own compose unit
(`chrome/docker-compose.yml`) intended for the home machine, reached over the
tailnet. No fallback sidecar is left on the VPS.

The backend needs no code change — `BROWSER_WS_URL` was already the only
coupling. Its default is now empty rather than a pinned Docker IP, so an
unconfigured or unreachable browser degrades exactly as it always has: plain-TLS
libraries unaffected, kagane/novelfull logged and skipped, stored covers still
served.

### What shipped

- `chrome/docker-compose.yml` + `chrome/.env.example` — the browser unit, with
  the CDP port bound to `${BROWSER_BIND_ADDR}` (no default) and the resource
  limits from the epic: 512 MiB / 1 GiB memory+swap, `oom_score_adj 800`,
  halved CPU weight, shm 1 GiB -> 128 MiB.
- API stack drops the service, its `depends_on` and the `browser` network.
- `bookmark-api` gains the `default` network. Dropping `browser` had left it on
  `db` alone, which is `internal: true` — no published port and, worse, no
  egress for the poller at all. Caught by actually bringing the stack up.
- ADR-0006 for the topology; `DEPLOY.md` §7 for first-time setup of the browser
  machine; `REDEPLOY.md` §8 for its independent update cadence; architecture
  diagrams, config tables and troubleshooting rows across README/AGENTS/env.

### Verified locally

- Browser unit builds and runs: Chrome 151, UA carries no `HeadlessChrome`,
  all limits applied as declared.
- **Live smoke passes through the new unit**: `TestSmokeKaganeImage` fetched
  56710 bytes of `image/webp`, `TestSmokeKaganeGet` got a 200 with a real
  chapter list. The challenge cleared under the reduced 128 MiB shm.
- Bind isolation proven: refused on the host's non-loopback address, accepted
  on the configured one.
- 321 MiB peak of the 512 MiB cap after a full solve; 0 restarts, no OOM kill.
- API stack comes up clean, `/healthz` 200; egress confirmed present on
  `default` and absent on `db`.
- `go test ./...`, `go vet`, `gofmt` clean.

### Left to the operator

Provisioning the home machine, the Tailscale ACL, setting `BROWSER_WS_URL` in
production, and observing acceptance criteria 5-7 (covers with the machine off,
several days of zero OOM/restarts, VPS memory improvement). `DEPLOY.md` §7 now
carries the before/after `free -m` reading those need.

Reviewed-on: #52
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
2026-08-09 15:28:21 +07:00

2.6 KiB

ADR-0005: On-demand browser sidecar

Date: 2026-08-09 Status: accepted

Superseded in part by ADR-0006: the lifecycle below is unchanged, but the service no longer lives in the API stack and the name headless-shell is gone.

Decision

Keep the headless-shell service and its CDP port alive, but start Google Chrome only when the first CDP connection arrives. The entrypoint supervises a socat front-end, serializes browser start/reap state with flock, and tracks each connection with a marker named for its helper PID. A reaper stops Chrome after 300 seconds with no live markers. Marker reconciliation covers a helper killed before its cleanup trap runs.

Chrome runs in its own process group so reap sends the termination signal to Chrome and its renderer children. The explicit /home/chrome/profile user-data directory remains: Chrome remaps remote debugging to loopback on modern builds, and Chrome ignores remote-debugging flags on a default profile. socat therefore continues to front Chrome's loopback CDP port.

The profile is a named Compose volume. Clearance cookies survive both a reap and docker compose up --build; the browser still starts with a fresh debugger UUID, so chromedp must keep endpoint discovery enabled and must not use chromedp.NoModifyURL.

The socat front-end and explicit profile are retained because Chromium remaps a non-loopback debugging address to loopback since M113, while Chrome ignores the remote-debugging flags on a default profile since Chrome 136. Flag tuning is deliberately not adopted: its roughly 30% idle-footprint saving is irrelevant to a browser that exists for seconds per wake and risks an untested fingerprint.

Constraints

The 300-second floor is deliberate. Chromium batches cookie persistence on a roughly 31-second timer, and Go's default HTTP transport can keep the discovery connection parked for about 90 seconds after use. Reaping only with zero live connections holds Chrome through both windows and through the poller's staggered batch plus cover prefetch.

The anti-bot properties remain unchanged: a plausible non-UTC timezone, a Chrome-version-derived User-Agent without HeadlessChrome, and no automation flag. A remote browser restart can surface as context.Canceled, the same error as a caller deadline, so the backend wraps cancellation observed with a closed CDP connection as browser interrupted; the focused test asserts that classification without killing a real browser. The same process-group stop runs during supervisor shutdown, not only during idle reap, so Chrome can flush its cookie batch before a container rebuild or graceful stop.