browser-slot.sh: stop overwriting the caller's BASE, and let a waiter take any slot that frees

Two defects, both reproduced before the fix and re-measured after.

1. It assigned BASE for its lock path. A variable the caller exported
   stays exported in bash, so every gate run through the slot saw
   BASE=/tmp/doxa-maps-browser-gate.lock. Seven gates read
   process.env.BASE; check:upg-map-data-painted printed "SKIP — nothing
   served at /tmp/doxa-maps-browser-gate.lock/…" and exited 0. Renamed
   to SLOT_BASE. Proof: with BASE=/x set, a child printed the lock path
   before and /x after.
2. With every slot busy, waiters queued on slot 1 alone, so a slot that
   freed was never taken and two slots behaved as one queue. It now
   polls every slot with flock -n every 2 s until the deadline and
   still exits 75 on timeout. Proof, in a private slot dir with slot 1
   held 12 s and slot 2 freed at ~3 s: a caller waited 11.5 s before and
   4.3 s after. A free slot is still taken at once (66 ms).

check-upg-map-data-painted: its unreachable-server SKIP exited 0 and
its wrong-tree refusal exited 2. Both now exit 75, the repo's "not
measured" code. Its existing tree-identity check is what makes the
:4173 default safe, since another agent's tree there is refused.
This commit is contained in:
doxa-maps-88655c70 2026-09-25 10:03:11 +00:00
parent 63083372c8
commit be3620ef1c
2 changed files with 32 additions and 13 deletions

View file

@ -23,13 +23,16 @@ set -u
SLOTS="${BROWSER_SLOTS:-2}"
DIR="${BROWSER_SLOT_DIR:-/tmp}"
BASE="$DIR/doxa-maps-browser-gate.lock"
# 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 "$BASE" || echo "$BASE$1"; }
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
@ -58,10 +61,22 @@ while [ "$i" -le "$SLOTS" ]; do
i=$((i + 1))
done
# Everything busy: queue on slot 1, and report a timeout as UNMEASURED rather than as a verdict.
exec 9>"$(slot_path 1)"
if flock -w "$WAIT_S" 9; then
exec "$@"
fi
# 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"

View file

@ -58,9 +58,12 @@ const MIN_ZONE_RATIO = 0.9
let reachable = false
try { const r = await fetch(urls[0], { signal: AbortSignal.timeout(5000) }); reachable = r.ok } catch { /* precondition */ }
if (!reachable) {
console.log(`UPG-MAP-DATA: SKIP — nothing served at ${urls[0]}.`)
console.log(' Serve the built tree: python3 -m http.server 4173 --directory public (or set BASE)')
process.exit(0)
// NOT MEASURED, exit 75 — never exit 0. Through browser-slot.sh this line once read
// "SKIP — nothing served at /tmp/doxa-maps-browser-gate.lock/…" (the slot had overwritten BASE)
// and EXITED 0, so a wave counted a gate that measured nothing as green.
console.log(`UPG-MAP-DATA: NOT MEASURED — nothing served at ${urls[0]}.`)
console.log(' Serve THIS checkout (internal/serve-strict.mjs on your own port) and pass BASE=http://127.0.0.1:<port>/doxa-maps')
process.exit(75)
}
// ★ "ANSWERS 200" AND "IS SERVING THIS BUILD" ARE DIFFERENT QUESTIONS. This gate reads the counter
@ -70,15 +73,16 @@ if (!reachable) {
// See _serves-this-tree.mjs (bundler-2, BUG-BROWSER-GATES-CAN-MEASURE-ANOTHER-WORKTREES-TREE):
// :4173 on this box was once held by a different worktree and a gate reported three confident runs
// about code nobody had changed.
// ⚠ NOT MEASURED rather than SKIP, exit 2: "nothing to check here" and "I cannot attribute what I
// saw" are different facts, and only the second one should stop you trusting the numbers.
// ⚠ NOT MEASURED, exit 75 — the repo's EX_TEMPFAIL for "not measured" (a9550858), which the wave
// runner reports as ⊘. It was exit 2 here, a code nothing else in the repo reads that way.
// This check is also what makes the :4173 default safe: another agent's tree there is refused.
const TREE_BASE = String(urls[0]).split('/doxa-maps/')[0]
const treeCheck = await servesThisTree(TREE_BASE, ROOT)
if (!treeCheck.ok) {
console.error('UPG-MAP-DATA: NOT MEASURED — the server is not serving this checkout.')
console.error(` ${treeCheck.why}`)
console.error(' The polygon and pin counts below would describe someone else\'s build.')
process.exit(2)
process.exit(75)
}
const WALK = `(root) => { const o = []; const rec = (r) => { for (const e of r.querySelectorAll('*')) { o.push(e); if (e.shadowRoot) rec(e.shadowRoot) } }; rec(root); return o }`