- 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.
89 lines
4.4 KiB
Bash
Executable file
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"
|