From 25f5edaa5dfdcadb685c679ed12354646e086eda Mon Sep 17 00:00:00 2001 From: Devin Michael Date: Thu, 3 Sep 2026 22:50:07 +0700 Subject: [PATCH 1/3] check-live-surfaces: bounded concurrency and timeouts as failed checks, not crashes The first production run opened ~190 connections at once for the map-link sweep and died on an unhandled connect timeout. Link checks now run 8 at a time with a 20 s timeout each; a network error is reported as status 0 on that check instead of aborting the run. Against production after #50 and docs#34 deployed: 132 passed, 0 failed. Co-Authored-By: Claude Fable 5.1 --- scripts/check-live-surfaces.mjs | 49 ++++++++++++++++++++++++--------- 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/scripts/check-live-surfaces.mjs b/scripts/check-live-surfaces.mjs index e947ba9..e586330 100644 --- a/scripts/check-live-surfaces.mjs +++ b/scripts/check-live-surfaces.mjs @@ -28,18 +28,43 @@ function check(name, condition, detail = '') { } const cache = new Map(); +// A network failure is a failed check, not a crash: status 0 with the error in the body. async function fetchText(url) { if (cache.has(url)) return cache.get(url); - const res = await fetch(url, { redirect: 'manual', headers: { 'user-agent': 'next-docs-live-check/1' } }); - const body = await res.text(); - const out = { status: res.status, headers: res.headers, body, bytes: Buffer.byteLength(body, 'utf8') }; + let out; + try { + const res = await fetch(url, { + redirect: 'manual', + headers: { 'user-agent': 'next-docs-live-check/1' }, + signal: AbortSignal.timeout(20_000), + }); + const body = await res.text(); + out = { status: res.status, headers: res.headers, body, bytes: Buffer.byteLength(body, 'utf8') }; + } catch (error) { + out = { status: 0, headers: new Headers(), body: '', bytes: 0, error: error?.cause?.code ?? error?.name ?? String(error) }; + } cache.set(url, out); return out; } +// Bounded concurrency so ~200 link checks do not open ~200 connections at once. +async function mapLimit(items, limit, fn) { + const results = new Array(items.length); + let next = 0; + await Promise.all( + Array.from({ length: Math.min(limit, items.length) }, async () => { + while (next < items.length) { + const i = next++; + results[i] = await fn(items[i]); + } + }), + ); + return results; +} + async function page(url) { const r = await fetchText(url); - check(`200 ${url}`, r.status === 200, `status ${r.status}`); + check(`200 ${url}`, r.status === 200, `status ${r.status}${r.error ? ` (${r.error})` : ''}`); return r; } @@ -90,15 +115,13 @@ if (capabilityMap) { for (const w of c.webhooks) if (w.url) linked.add(w.url); } let broken = 0; - await Promise.all( - [...linked].map(async (u) => { - const r = await fetchText(u); - if (r.status !== 200) { - broken += 1; - failures.push(`map link ${u} returned ${r.status}`); - } - }), - ); + await mapLimit([...linked], 8, async (u) => { + const r = await fetchText(u); + if (r.status !== 200) { + broken += 1; + failures.push(`map link ${u} returned ${r.status}${r.error ? ` (${r.error})` : ''}`); + } + }); check(`all ${linked.size} capability-map links resolve`, broken === 0); for (const b of capabilityMap.bundles) { From 833e51ed7ffb08f565263bf8b93a59212ef092b2 Mon Sep 17 00:00:00 2001 From: Devin Michael Date: Thu, 3 Sep 2026 23:04:28 +0700 Subject: [PATCH 2/3] check-live-surfaces: side-effect-only forEachLimit; drop the unused results array Co-Authored-By: Claude Fable 5.1 --- scripts/check-live-surfaces.mjs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/scripts/check-live-surfaces.mjs b/scripts/check-live-surfaces.mjs index e586330..d3c7826 100644 --- a/scripts/check-live-surfaces.mjs +++ b/scripts/check-live-surfaces.mjs @@ -48,18 +48,14 @@ async function fetchText(url) { } // Bounded concurrency so ~200 link checks do not open ~200 connections at once. -async function mapLimit(items, limit, fn) { - const results = new Array(items.length); +// Side effects only; callers record results themselves. +async function forEachLimit(items, limit, fn) { let next = 0; await Promise.all( Array.from({ length: Math.min(limit, items.length) }, async () => { - while (next < items.length) { - const i = next++; - results[i] = await fn(items[i]); - } + while (next < items.length) await fn(items[next++]); }), ); - return results; } async function page(url) { @@ -115,7 +111,7 @@ if (capabilityMap) { for (const w of c.webhooks) if (w.url) linked.add(w.url); } let broken = 0; - await mapLimit([...linked], 8, async (u) => { + await forEachLimit([...linked], 8, async (u) => { const r = await fetchText(u); if (r.status !== 200) { broken += 1; From 9b937895381887c71a7272e2227310bd77bbf88a Mon Sep 17 00:00:00 2001 From: Devin Michael Date: Thu, 3 Sep 2026 23:11:47 +0700 Subject: [PATCH 3/3] check-live-surfaces: a throwing item is a failed check, not a crashed sweep Co-Authored-By: Claude Fable 5.1 --- scripts/check-live-surfaces.mjs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/scripts/check-live-surfaces.mjs b/scripts/check-live-surfaces.mjs index d3c7826..822969c 100644 --- a/scripts/check-live-surfaces.mjs +++ b/scripts/check-live-surfaces.mjs @@ -48,12 +48,20 @@ async function fetchText(url) { } // Bounded concurrency so ~200 link checks do not open ~200 connections at once. -// Side effects only; callers record results themselves. +// Side effects only; callers record results themselves. An exception from one +// item is recorded as a failed check and does not stop the other workers. async function forEachLimit(items, limit, fn) { let next = 0; await Promise.all( Array.from({ length: Math.min(limit, items.length) }, async () => { - while (next < items.length) await fn(items[next++]); + while (next < items.length) { + const item = items[next++]; + try { + await fn(item); + } catch (error) { + failures.push(`check for ${String(item)} threw: ${error?.message ?? error}`); + } + } }), ); }