// One network gate for the whole run: a broker in the node process that measures the
// connection and tells every browser worker how to treat tests that need external
// resources: run them as usual, run them ONE AT A TIME, or skip them.
//
// Any test can consult it (see __tests__/helpers/network-gate.js); nothing here knows about
// demos, snapdom or any particular suite.
//
// WHY A BROKER IN NODE INSTEAD OF A PROBE PER WORKER
// The suite runs as several test FILES times one browser instance each: six shards under
// BROWSER=all is eighteen workers. Eighteen probes racing the tests for the same link
// measure the run's own congestion, and they disagree with each other: measured 81-159ms
// probing alone against 1669ms and "no response" with the shards running, with different
// shards reaching OPPOSITE verdicts inside one run. One broker gives one answer, probes
// once no matter how many workers ask, and is also the only place a global "one at a time"
// lane can exist: the workers share a link, not a process.
//
// WHY THROUGHPUT, NOT LATENCY
// On the link that prompted this, a 25KB stylesheet came back in 627ms, healthy by any
// round-trip threshold, while six woff2 files totalling 86KB took 6602ms in parallel. A
// KaTeX page pulls about twenty of those, which is how it reaches a 10s test timeout on a
// connection that pings fine. So the probe fetches what a page fetches: a real batch of
// files, in parallel, timed to the last byte, and reports KB/s.
//
// WHY IT IS MEASURED TWICE
// The first reading is taken before any browser starts (vitest.config.js awaits it and
// hands it to the suites): it measures the link's CAPACITY on an idle line, which is what
// a policy decided at collection time needs: a suite sizes its test timeout from it, and
// vitest bakes timeouts in when tests are registered.
//
// But an idle reading is not the whole answer, because the run itself is what saturates
// the link: one run passed exactly this pre-run probe and then timed out 54 demos. So the
// gate is re-read as tests run, under the load they create, and the mode only ever gets
// stricter (see MODES below).
//
// Env overrides (all optional):
// NETWORK_GATE=off / parallel never gate: run everything as usual
// NETWORK_GATE=serial force the one-at-a-time lane
// NETWORK_GATE=skip force skipping (offline work)
// NETWORK_GATE_URLS comma-separated payload URLs instead of the default batch
// NETWORK_GATE_FAST_KBPS / NETWORK_GATE_MIN_KBPS tier thresholds, in KB/s
// NETWORK_GATE_BUDGET_MS / NETWORK_GATE_WARMUP_MS / NETWORK_GATE_TTL_MS
// NETWORK_GATE_LANE_BUDGET_MS total seconds the serial lane may cost the whole run
// Pinned so the probe measures the connection rather than whatever the CDN serves today.
const KATEX = 'https://cdn.jsdelivr.net/npm/katex@0.16.9/dist'
const DEFAULT_PAYLOAD = [
'KaTeX_Main-Regular', 'KaTeX_Math-Italic', 'KaTeX_Size1-Regular',
'KaTeX_AMS-Regular', 'KaTeX_Size2-Regular', 'KaTeX_Caligraphic-Regular',
].map((font) => `${KATEX}/fonts/${font}.woff2`)
// Strictness order. A run only ever ratchets TOWARDS the right: measuring again once the
// lane has calmed the link down would read fast, flip back to parallel, congest it again
// and flip back. A mode is never relaxed inside a run.
const MODES = ['parallel', 'serial', 'skip']
const strictest = (a, b) => MODES[Math.max(MODES.indexOf(a), MODES.indexOf(b))]
const FORCED = {
off: 'parallel', parallel: 'parallel', '0': 'parallel', false: 'parallel', no: 'parallel',
serial: 'serial', slow: 'serial',
skip: 'skip', '1': 'skip', true: 'skip', yes: 'skip',
}
const list = (value) => String(value || '').split(',').map((s) => s.trim()).filter(Boolean)
const num = (value, fallback) => (Number(value) > 0 ? Number(value) : fallback)
async function timedFetch(url, budgetMs, drain) {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), budgetMs)
const started = performance.now()
try {
const res = await fetch(url, { cache: 'no-store', signal: controller.signal })
// The payload IS what is being measured, so it is timed to the last byte. The warmup
// only has to prove the host answers, so it drops the body instead of paying for it.
const body = drain ? await res.arrayBuffer() : await res.body?.cancel().catch(() => {})
return { ms: performance.now() - started, bytes: drain ? body.byteLength : 0 }
} catch {
return null // offline, DNS failure, or slower than the budget
} finally {
clearTimeout(timer)
}
}
/**
* @param {object} [options]
* @param {string[]} [options.payload] URLs fetched in parallel and timed as one batch
* @param {string} [options.warmup] URL fetched first, alone, to pay for DNS + TLS
* @param {number} [options.fastKbps] at or above this the link takes the whole run at once
* @param {number} [options.workers] how many browser workers share the link; scales fastKbps
* @param {number} [options.pageKB] what one network-dependent test is assumed to pull
* @param {number} [options.serialTimeoutMs] how long such a test may take in serial mode
* @param {number} [options.budgetMs] hard cap on the timed batch
* @param {number} [options.warmupMs] budget for the warmup, which pays a cold handshake
* @param {number} [options.ttlMs] how long a reading is reused before re-probing
* @param {number} [options.laneMs] a lease is dropped after this even if never released
* @param {number} [options.laneBudgetMs] total time the serial lane may cost the whole run
* @returns {{ commands: Record<string, Function>, status: Function, enter: Function, leave: Function }}
*/
export function createNetworkGate(options = {}) {
const envPayload = list(process.env.NETWORK_GATE_URLS)
const payload = options.payload ?? (envPayload.length ? envPayload : DEFAULT_PAYLOAD)
// One request to the payload's own host, so the first reading is not a cold handshake.
const warmup = options.warmup ?? new URL('/', payload[0]).href
const budgetMs = num(options.budgetMs ?? process.env.NETWORK_GATE_BUDGET_MS, 4000)
const warmupMs = num(options.warmupMs ?? process.env.NETWORK_GATE_WARMUP_MS, 5000)
const ttlMs = num(options.ttlMs ?? process.env.NETWORK_GATE_TTL_MS, 15000)
// The tiers are expressed in the only unit that answers "will this test finish": KB/s.
// >= fastKbps the link swallows the whole run at once -> parallel
// >= minKbps it works, it just cannot take 18 pullers at once -> serial (one lane)
// below the payload cannot arrive in time even alone -> skip
//
// minKbps is not a taste threshold: it is what one test needs to finish. A page that
// pulls pageKB of fonts and images has serialTimeoutMs to do it in, and below that
// ratio serialising only converts timeouts into slower timeouts.
// "Fast enough" is not a property of the link alone: it is the link divided by how many
// workers pull on it at once. The same connection that swallows one engine's demos is a
// third of itself under BROWSER=all, which is how a run that passed the pre-run probe went
// on to time out on CDN demos. An explicit fastKbps (or the env override) is taken as the
// final word; the default scales.
const workers = num(options.workers, 1)
const fastKbps = num(options.fastKbps ?? process.env.NETWORK_GATE_FAST_KBPS, 120 * workers)
const pageKB = num(options.pageKB, 300)
const serialTimeoutMs = num(options.serialTimeoutMs, 30000)
const minKbps = num(options.minKbps ?? process.env.NETWORK_GATE_MIN_KBPS, pageKB / (serialTimeoutMs / 1000))
// Leases are dropped on their own: a worker that dies mid-test never sends its release,
// and one lost lease would otherwise stall every queued test behind it.
const laneMs = num(options.laneMs, 45000)
// What the whole run may spend running network tests one at a time. Serial mode is a
// fallback, not a plan: every network test in the run queues for the same lane, so its
// cost is the number of such tests times how long each takes, and on a link slow enough
// to need the lane that product is what turns a two-minute suite into a twenty-minute
// one. Past this the mode ratchets to skip. Per worker, because BROWSER=all runs the
// same demos three times and each engine deserves the same allowance.
const laneBudgetMs = num(options.laneBudgetMs ?? process.env.NETWORK_GATE_LANE_BUDGET_MS, 180000 * workers)
const forcedMode = FORCED[String(process.env.NETWORK_GATE ?? '').trim().toLowerCase()]
let cached = null // { mode, reading, kbps, at }
let inflight = null // dedupes the workers that ask at the same moment
let warmed = false
let mode = forcedMode ?? 'parallel' // ratchets towards 'skip', never back
// --- the one-at-a-time lane ---------------------------------------------------------
//
// The lane is what makes a degraded link usable, and it is also the only thing here that
// can spend unbounded wall clock: EVERY network test in the run passes through it, so the
// last one queued waits behind all the others. Unbounded, that is how a suite ends up
// reporting single tests at two and a half minutes, most of it spent parked on a timer
// waiting to be told no. Two bounds, one per test and one per run:
//
// per test a test is refused IMMEDIATELY when the queue ahead of it cannot clear
// inside its own wait budget, instead of parking to discover the same thing.
// The estimate uses what a turn in the lane has actually cost THIS run.
// per run the lane has a total budget. Once spent, the mode ratchets to skip: a link
// that needed this much of it has been measured by the only instrument that
// matters, which is the run itself, and another twenty minutes of one-at-a-
// time downloads teaches nothing.
//
// Both turn a hang into a reported skip, which is the entire purpose of the gate.
let nextToken = 1
const leases = new Map() // token -> { timer, at }
const waiting = [] // [{ grant, timer }]
let laneSpentMs = 0 // wall clock the lane has been held for, this run
let laneServed = 0 // turns that completed, so the average below is a real one
// What one turn costs. The seed only has to be the right order of magnitude: it is
// replaced by a measurement as soon as one network test has been through the lane.
const laneTurnMs = () => (laneServed > 0 ? laneSpentMs / laneServed : 8000)
function release(token) {
const lease = leases.get(token)
if (lease === undefined) return false
clearTimeout(lease.timer)
leases.delete(token)
laneSpentMs += Date.now() - lease.at
laneServed++
const next = waiting.shift()
if (next) next.grant()
return true
}
function grantToken() {
const token = nextToken++
leases.set(token, { at: Date.now(), timer: setTimeout(() => release(token), laneMs) })
return token
}
function enter(maxWaitMs = 120000) {
// `skip` first, and it is a REFUSAL. Read the other way round it says "not serial, so
// help yourself", which hands a free pass to exactly the tests the gate has just
// decided the link cannot serve, and quietly makes the budget below unenforceable.
if (mode === 'skip') return Promise.resolve({ granted: false, token: null, waitedMs: 0 })
if (mode !== 'serial') return Promise.resolve({ granted: true, token: null })
// Budget spent: stop serialising and start skipping, for the rest of the run.
if (laneSpentMs >= laneBudgetMs) {
ratchet('skip', `serial lane spent its ${Math.round(laneBudgetMs / 1000)}s budget on ${laneServed} tests`)
return Promise.resolve({ granted: false, token: null, waitedMs: 0, spentMs: laneSpentMs })
}
if (leases.size === 0) return Promise.resolve({ granted: true, token: grantToken() })
// Refuse now instead of after a long timer when the queue cannot clear in time anyway.
const ahead = leases.size + waiting.length
if (ahead * laneTurnMs() > maxWaitMs) {
return Promise.resolve({ granted: false, token: null, waitedMs: 0, ahead })
}
const queuedAt = Date.now()
return new Promise((resolve) => {
const entry = {
grant: () => { clearTimeout(entry.timer); resolve({ granted: true, token: grantToken() }) },
timer: setTimeout(() => {
const i = waiting.indexOf(entry)
if (i >= 0) waiting.splice(i, 1)
resolve({ granted: false, token: null, waitedMs: Date.now() - queuedAt, ahead })
}, maxWaitMs),
}
waiting.push(entry)
})
}
// --- the probe ----------------------------------------------------------------------
async function measure() {
if (!warmed) {
const cold = await timedFetch(warmup, warmupMs, false)
if (!cold) return { mode: 'skip', kbps: 0, reading: `${new URL(warmup).hostname} unreachable in ${warmupMs}ms` }
warmed = true
}
const started = performance.now()
const results = await Promise.all(payload.map((url) => timedFetch(url, budgetMs, true)))
const elapsed = Math.max(1, performance.now() - started)
const arrived = results.filter(Boolean)
const bytes = arrived.reduce((sum, r) => sum + r.bytes, 0)
const kbps = Math.round(bytes / 1024 / (elapsed / 1000))
// A batch that did not finish inside the budget says nothing about the link's ceiling,
// only that it is under it: measure what DID arrive and let the thresholds judge.
const detail = `${arrived.length}/${results.length} files, ${Math.round(bytes / 1024)}KB in ${Math.round(elapsed)}ms (${kbps}KB/s)`
if (arrived.length < results.length && kbps < minKbps) return { mode: 'skip', kbps, reading: detail }
if (kbps >= fastKbps && arrived.length === results.length) return { mode: 'parallel', kbps, reading: detail }
if (kbps >= minKbps) return { mode: 'serial', kbps, reading: detail }
return { mode: 'skip', kbps, reading: detail }
}
/**
* Move the verdict towards `skip` and record WHY, so the reading every worker reads back
* says which instrument decided: the probe, or the lane running out of budget. Never
* relaxes, and updating `cached` here is what keeps status() from serving the reading
* that was true one tier ago.
*/
function ratchet(nextMode, reading) {
const next = strictest(mode, nextMode)
if (next === mode) return
mode = next
cached = { kbps: cached?.kbps ?? 0, ...cached, mode, reading, at: Date.now() }
}
async function probe() {
const reading = await measure()
mode = strictest(mode, reading.mode)
cached = { ...reading, mode, at: Date.now() }
return cached
}
async function status() {
if (forcedMode) return { mode: forcedMode, kbps: 0, reading: `forced by NETWORK_GATE=${forcedMode}`, at: Date.now() }
// Nothing to learn once it has ratcheted all the way: stop probing a dead link.
if (mode === 'skip') return cached ?? { mode, kbps: 0, reading: 'serial lane budget spent', at: Date.now() }
if (cached && Date.now() - cached.at < ttlMs) return cached
inflight ??= probe().finally(() => { inflight = null })
return inflight
}
// Browser workers reach these over vitest's command channel; see vitest.config.js.
return {
status,
enter,
leave: release,
serialTimeoutMs,
commands: {
netGateStatus: async () => {
const s = await status()
return { mode: s.mode, reading: s.reading, kbps: s.kbps, ok: s.mode !== 'skip', serialTimeoutMs }
},
netGateEnter: (_ctx, maxWaitMs) => enter(maxWaitMs),
netGateLeave: (_ctx, token) => release(token),
},
}
}