doxa-maps/internal/browser-slot.sh
doxa-maps-88655c70 cfdbea7f9c
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Host locale: gate the profile-config contract, forward ?lang= into staging embeds, stop losing gate stderr
- check:host-locale-contract: every leaf loaded with ONLY profile-config.locale="ar" must draw at
  least half the Arabic the same page draws at ?lang=ar; regions-list is the positive control.
  Fails on this commit for 18 leaves (bundles that never provide appI18n, and every composite).
- check:staging-lang: the staging page at ?lang=ar embeds its leaf with lang=ar and the Gospel map
  renders gospel.mode.gap in Arabic. staging-forge.js (iframe + open link) and
  staging-reference-v2.html now forward ?lang= / ?locale= via withLang(); fails on the parent.
- browser-slot.sh: `exec 9>f 2>/dev/null` redirected the whole script's stderr, and the gate
  exec'd after it, to /dev/null — FAIL lines and "not measuring" messages were being discarded.
  The redirection is now scoped to the open.
- verify-map-paint: the two regions map leaves join the default list (both paint, local and live).
- Vision card reads ?lang= / ?locale= like the rest of the library.
2026-09-25 12:18:26 +00:00

89 lines
4.4 KiB
Bash
Executable file

#!/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, WAIT on slot 1. 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 <command> [args...]
# Env: BROWSER_SLOTS (default 2), BROWSER_SLOT_DIR (default /tmp),
# BROWSER_MEM_FLOOR_MB (default 2500), BROWSER_LOCK_WAIT (default 900),
# BROWSER_MEM_WAIT (default 300)
set -u
SLOTS="${BROWSER_SLOTS:-2}"
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
slot_path() { [ "$1" = "1" ] && echo "$SLOT_BASE" || echo "$SLOT_BASE$1"; }
mem_available_mb() {
awk '/^MemAvailable:/ { printf "%d", $2 / 1024 }' /proc/meminfo 2>/dev/null || echo 999999
}
# Hold off while the box is below the floor. Proceeding anyway is not an option here the way it is
# for a load average: load makes a measurement slow, memory makes it get killed halfway through.
waited=0
while [ "$(mem_available_mb)" -lt "$FLOOR_MB" ]; do
if [ "$waited" -ge "$MEM_WAIT_S" ]; then
echo "browser-slot: MemAvailable $(mem_available_mb)MB below floor ${FLOOR_MB}MB after ${waited}s — not measuring" >&2
exit "$BUSY"
fi
sleep 10
waited=$((waited + 10))
done
# Fast path: any free slot, in a FIXED order so two processes can never take them in opposite
# orders. -n means "do not wait": if this slot is busy we move on rather than queue behind it.
#
# ★ `{ exec 9>…; } 2>/dev/null`, NEVER `exec 9>… 2>/dev/null`. A bare exec applies EVERY redirection
# on its line to this shell for good, so the old form sent stderr to /dev/null for the rest of the
# script AND for the gate exec'd below. Measured 2026-09-25: every gate run through this wrapper lost
# its stderr — verify-map-paint exited 1 on two leaves with its FAIL lines (console.error) gone, and
# this script's own "not measuring" message vanished too. The braces scope the 2>/dev/null to the
# open alone; fd 9 still persists, which is the only thing the exec is for.
i=1
while [ "$i" -le "$SLOTS" ]; do
{ exec 9>"$(slot_path "$i")"; } 2>/dev/null || true
if flock -n 9; then
exec "$@"
fi
i=$((i + 1))
done
# Everything busy: keep POLLING EVERY SLOT until the deadline, and report a timeout as UNMEASURED
# rather than as a verdict. This used to queue on slot 1 alone (flock -w on slot 1), so a slot that
# freed up was never taken: measured 2026-09-25 (dx-shepherd), ten waiters were piled on slot 1 while
# slot 2 sat free, and with slot 1 held 12 s and slot 2 freed at ~3 s a caller waited 11.5 s — the
# two-slot semaphore had collapsed into ONE queue. Polling takes the first slot that frees (~4 s in
# the same test). Trade-off, accepted: polling is not first-come-first-served, so under heavy
# contention a waiter can lose a few rounds; an idle slot beside a queue is strictly worse.
deadline=$(( $(date +%s) + WAIT_S ))
while [ "$(date +%s)" -lt "$deadline" ]; do
i=1
while [ "$i" -le "$SLOTS" ]; do
{ exec 9>"$(slot_path "$i")"; } 2>/dev/null || true
if flock -n 9; then exec "$@"; fi
i=$((i + 1))
done
sleep 2
done
echo "browser-slot: no slot within ${WAIT_S}s (${SLOTS} slots, floor ${FLOOR_MB}MB) — not measuring" >&2
exit "$BUSY"