From 39a617d5a4c90cf42cc020acf0a8df771c10a28f Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Wed, 22 Jul 2026 00:39:03 -0700 Subject: [PATCH] Cap seed API stall with a shared raced budget and explicit CLI exit Abort signals do not cancel the TCP connect phase, so an unreachable API stalled the seed ~10s before degrading. All API calls now share one deadline, the roll fetch races it, and the CLI exits explicitly. Co-Authored-By: Claude Fable 5 --- skill/scripts/concept-seed.mjs | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/skill/scripts/concept-seed.mjs b/skill/scripts/concept-seed.mjs index 2280c100f..93ad63a66 100644 --- a/skill/scripts/concept-seed.mjs +++ b/skill/scripts/concept-seed.mjs @@ -79,6 +79,13 @@ const here = dirname(fileURLToPath(import.meta.url)); const CATALOG_DIR = process.env.IMPECCABLE_CATALOG_DIR || here; const API_BASE = (process.env.IMPECCABLE_API_URL || 'https://impeccable.style/api').replace(/\/$/, ''); const API_TIMEOUT_MS = Number(process.env.IMPECCABLE_API_TIMEOUT || 4000); +// All API calls in one seed run share a single deadline so an unreachable +// network degrades after one timeout total, never one timeout per call. +let apiDeadline = null; +function apiBudgetMs() { + if (apiDeadline === null) apiDeadline = Date.now() + API_TIMEOUT_MS; + return Math.max(0, apiDeadline - Date.now()); +} const localStates = new Map(); function loadLocal(catalogDir = CATALOG_DIR) { @@ -120,9 +127,15 @@ async function fetchRoll({ scope, key, mode, reroll }) { const params = new URLSearchParams({ scope, key, reroll: String(reroll) }); if (mode) params.set('mode', mode); const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), API_TIMEOUT_MS); + const timer = setTimeout(() => controller.abort(), apiBudgetMs()); try { - const response = await fetch(`${API_BASE}/roll?${params}`, { signal: controller.signal }); + // Race the budget explicitly: abort signals do not reliably cancel the + // TCP connect phase, so a blackholed route would otherwise stall ~10s. + const response = await Promise.race([ + fetch(`${API_BASE}/roll?${params}`, { signal: controller.signal }), + new Promise(resolveTimeout => setTimeout(() => resolveTimeout(null), apiBudgetMs())), + ]); + if (!response) return null; if (!response.ok) return null; const roll = await response.json(); if (!Array.isArray(roll.challengers) || roll.challengers.length === 0) return null; @@ -143,7 +156,7 @@ function telemetryDisabled() { export async function pingChosen({ chosenId, key, scope, mode }) { if (telemetryDisabled() || !chosenId) return false; const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), API_TIMEOUT_MS); + const timer = setTimeout(() => controller.abort(), apiBudgetMs()); try { await fetch(`${API_BASE}/chosen`, { method: 'POST', @@ -543,4 +556,7 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); process.exitCode = 1; } + // A raced-out fetch may still hold a socket; exit explicitly so the CLI + // never lingers on a dead network path after output is written. + process.exit(process.exitCode ?? 0); }