#!/usr/bin/env bash # browser-slot.sh — run ONE browser command inside a memory-floored, N-slot rendezvous. # # WHY A SEMAPHORE AND NOT A LOCK. A single lock made every agent's browser proof wait behind one # chrome: measured 2026-09-25, the queue sat 10-19 deep for hours. The real constraint is MEMORY, # not exclusivity — a headless chrome costs ~300-500MB — so the right shape is "as many browsers as # the box can hold", which is a counting semaphore with a memory floor, not a mutex. # # HOW: N slot files. Try each with flock -n (no wait). If all are busy, POLL every slot. Before # taking any slot, require MemAvailable above the floor — a slot that is free while the box is out # of memory is an invitation to be OOM-killed mid-measurement, which reads as a flaky gate. # # ★ EXIT 75 MEANS "NOT MEASURED", NEVER "FAILED". flock returns 1 on timeout, the same code a # failing gate uses, and that once turned a busy queue into five red product gates. 75 is # EX_TEMPFAIL: distinct from 0 and from any gate's own verdict, so a caller can report it as the # precondition it is. # # Usage: internal/browser-slot.sh [args...] # ONE SLOT (2026-09-25, project rule): on this machine one agent runs browsers and memory is the limit; # everyone else proves changes headless. The semaphore stays so a second run QUEUES instead of racing. # Env: BROWSER_SLOTS (default 1), BROWSER_SLOT_DIR (default /tmp), # BROWSER_MEM_FLOOR_MB (default 2500), BROWSER_LOCK_WAIT (default 900), # BROWSER_MEM_WAIT (default 300), BROWSER_MAX_HOLD (default 900 s), # BROWSER_DISK_FLOOR_MB (default 1500; checked on / and on $TMPDIR or /tmp) set -u SLOTS="${BROWSER_SLOTS:-1}" DIR="${BROWSER_SLOT_DIR:-/tmp}" # SLOT_BASE, never BASE: a variable the CALLER exported stays exported in bash, so assigning BASE # here rewrote the caller's BASE for every gate this runs. Seven gates read process.env.BASE, and # check:upg-map-data-painted measured /tmp/doxa-maps-browser-gate.lock/... and SKIPPED with exit 0. SLOT_BASE="$DIR/doxa-maps-browser-gate.lock" FLOOR_MB="${BROWSER_MEM_FLOOR_MB:-2500}" WAIT_S="${BROWSER_LOCK_WAIT:-900}" MEM_WAIT_S="${BROWSER_MEM_WAIT:-300}" BUSY=75 MAX_HOLD="${BROWSER_MAX_HOLD:-900}" KILL_AFTER="${BROWSER_KILL_AFTER:-15}" DISK_FLOOR_MB="${BROWSER_DISK_FLOOR_MB:-1500}" # ★ A GATE THAT IS LEGITIMATELY LONG DECLARES ITS OWN LIMIT, in its header: @browser-slot-max-hold 2700 # check:host-locale-contract loads every leaf twice and hit the 900 s default three times on full # runs (NOT MEASURED, not a verdict). An env prefix in its package.json script could not fix that — # this wrapper reads its limit before the script's own environment exists — so the gate carries the # number itself and it holds however the gate is invoked (node , or bun run