Two problems, one change. LOAD. Seven agents each running a wave put 7+ chrome-headless renders on one box; measured today it reached load 47 with 8GB of swap, and a browser gate that is swapping measures the swap, not the product. The wave runner now holds browser steps while 1-min load is at or above LOAD_MAX (12) and takes a coarse flock before each one, so they queue instead of dogpiling. Thresholds, cadence and the 20-minute cap are bundler-3's wait-for-quiet.sh verbatim, and BROWSER_GATE_LOCK selects the rendezvous, so every agent lands on one lock whichever entry point they use. Static gates are untouched: in gates:post that is 38 free, 4 queued. The load wait is taken ONCE per wave, not once per gate. Per-step it multiplied the same hold by the number of browser gates — an hour of a wave doing nothing, which is how a load fix becomes a reason to stop running gates. The flock is what serializes; the wait only decides when to join the queue. Browser steps are identified from the gate FILE (a playwright import), not a hand-kept list that goes stale the first time someone adds a gate — the one missing from such a list is exactly the one that rejoins the dogpile. `verify` is caught too: it imports no playwright itself but spawns three gates that render, and it already marks them browser:true, so that declaration is honoured rather than duplicated. COVERAGE. Yesterday's guard made check:provider-switch and check:packet-browser SKIP in every wave, because their default ports (4173, 4898) belong to other worktrees on this machine. Skipping was honest but a gate that skips every time is a gate nobody runs, so on a mismatch they now start a short-lived server over THIS checkout and measure that instead — the point was never the port, it was being able to prove which build answered. MEASURED: a served tree whose manifest differs by one key but has all the same pages -> "serves a DIFFERENT build", then "serving this checkout on :28384 instead", then a real verdict (16 -> 5 mapbox requests). packet-browser likewise, once it re-navigates to the new origin — a dynamic import across origins is refused by a plain static server, which cost one failed run to learn. gates:post 40/42, the only skips being the two that genuinely have no input (packets-all, zone-census); it was 4 before this. The lock path default is product-named. The shared file several shells export carries an internal code name, and check:no-codename refuses to let a repo we hand over carry one — it caught this commit's first draft.
87 lines
4.3 KiB
JavaScript
87 lines
4.3 KiB
JavaScript
/**
|
|
* _serves-this-tree.mjs — a browser gate must prove the server it talks to is THIS build.
|
|
*
|
|
* THE FAILURE THIS EXISTS FOR, MEASURED. Browser gates here default to
|
|
* http://127.0.0.1:4173 and adopt whatever answers 200 on it. On this machine that port was
|
|
* held by an unrelated process belonging to a different worktree, and the gospel-resources
|
|
* gate spent three runs reporting on a build that was not the one on disk — first a PASS,
|
|
* then a mount timeout that looked exactly like a regression in code that had nothing to do
|
|
* with it. A gate that can report on the wrong tree is worse than no gate: it produces
|
|
* confident findings about code nobody changed, and hides real ones.
|
|
*
|
|
* "Answers 200" and "is serving this checkout" are different questions. This asks the second.
|
|
*
|
|
* THE FINGERPRINT is public/doxa-maps/manifest.json, which generate-tree rewrites on every
|
|
* build and which names every bundle and profile in the tree. If the bytes served differ
|
|
* from the bytes on disk, it is a different build — a different worktree, or a stale server
|
|
* someone forgot to restart, both of which produce the same lie.
|
|
*
|
|
* Deliberately NOT fatal on its own: the caller decides whether to start its own server
|
|
* instead, or to skip. What it must never do is quietly continue.
|
|
*/
|
|
import { readFileSync } from 'node:fs'
|
|
import { createHash } from 'node:crypto'
|
|
import { resolve } from 'node:path'
|
|
|
|
const MANIFEST = 'doxa-maps/manifest.json'
|
|
|
|
/**
|
|
* @param {string} base e.g. http://127.0.0.1:4173 (a trailing /doxa-maps/ is tolerated)
|
|
* @param {string} repo absolute path to the repo root
|
|
* @returns {Promise<{ok:boolean, why:string}>}
|
|
*/
|
|
export async function servesThisTree(base, repo) {
|
|
let local
|
|
try {
|
|
local = createHash('sha256').update(readFileSync(resolve(repo, 'public', MANIFEST))).digest('hex')
|
|
} catch {
|
|
return { ok: false, why: `no local public/${MANIFEST} to compare against — build the tree first` }
|
|
}
|
|
const root = String(base).replace(/\/+$/, '').replace(/\/doxa-maps$/, '')
|
|
const r = await fetch(`${root}/${MANIFEST}`).catch(() => null)
|
|
if (!r || !r.ok) return { ok: false, why: `nothing serving ${MANIFEST} at ${root}` }
|
|
const served = createHash('sha256').update(Buffer.from(await r.arrayBuffer())).digest('hex')
|
|
if (served !== local) {
|
|
return { ok: false, why: `${root} serves a DIFFERENT build than public/ — another worktree, or a server that needs restarting` }
|
|
}
|
|
return { ok: true, why: 'same build' }
|
|
}
|
|
|
|
|
|
/**
|
|
* Start a short-lived static server over THIS checkout, so a gate whose default port belongs
|
|
* to another worktree can still MEASURE instead of skipping.
|
|
*
|
|
* Skipping was the honest thing to do once the mismatch was visible, but a gate that skips on
|
|
* every wave is a gate nobody is running: check:provider-switch and check:packet-browser both
|
|
* went quiet the moment the guard landed, because their default ports (4173, 4898) are held by
|
|
* other worktrees on this box. Serving our own tree costs a few hundred milliseconds and gives
|
|
* the gate the one thing it needs — a server it can prove is this build.
|
|
*
|
|
* @param {string} dir absolute directory to serve (public/, or the repo root)
|
|
* @returns {Promise<{origin:string, stop:() => void}|null>} null when no server could be started
|
|
*/
|
|
export async function serveThisTree(dir) {
|
|
const { spawn } = await import('node:child_process')
|
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
// An ephemeral high port, re-rolled on collision — two agents doing this at once must not
|
|
// fight over one number.
|
|
const port = 20000 + Math.floor(Math.random() * 20000)
|
|
const child = spawn('python3', ['-m', 'http.server', String(port), '--bind', '127.0.0.1', '--directory', dir],
|
|
{ stdio: 'ignore', detached: false })
|
|
const origin = `http://127.0.0.1:${port}`
|
|
let up = false
|
|
for (let i = 0; i < 40 && !up; i++) {
|
|
await new Promise((r) => setTimeout(r, 100))
|
|
up = await fetch(origin + '/', { signal: AbortSignal.timeout(500) }).then((r) => r.ok).catch(() => false)
|
|
}
|
|
if (up) {
|
|
// unref so a caller that forgets to stop() still exits — a gate that hangs on its own
|
|
// helper is a worse failure than the one it was written to catch.
|
|
child.unref()
|
|
return { origin, stop: () => { try { child.kill() } catch { /* already gone */ } } }
|
|
}
|
|
try { child.kill() } catch { /* nothing to kill */ }
|
|
}
|
|
return null
|
|
}
|