diff --git a/scripts/check-mcp-release-due.mjs b/scripts/check-mcp-release-due.mjs index 0aa6d3a92e..fa92e5d767 100644 --- a/scripts/check-mcp-release-due.mjs +++ b/scripts/check-mcp-release-due.mjs @@ -5,6 +5,9 @@ import { pathToFileURL } from "node:url"; import { buildMcpReleaseIssue, buildMcpReleaseReport, latestSemverTag, MCP_RELEASE_DUE_MARKER } from "./mcp-release-core.mjs"; const packageJsonPath = "packages/loopover-mcp/package.json"; +// Per-request timeout so a hung api.github.com connection can't block the unattended mcp-release-watch job +// indefinitely, matching the connection guard sibling release/observability scripts already use (#7014). +const GITHUB_REQUEST_TIMEOUT_MS = 30_000; async function main() { const args = parseArgs(process.argv.slice(2)); @@ -144,6 +147,7 @@ async function githubRequest({ token, method, path, body }) { "x-github-api-version": "2022-11-28", }, body: body ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS), }); const text = await response.text(); const payload = text ? JSON.parse(text) : null; diff --git a/scripts/smoke-observability-metrics.mjs b/scripts/smoke-observability-metrics.mjs old mode 100755 new mode 100644 index 78bd82e7f4..6d1b61a1f2 --- a/scripts/smoke-observability-metrics.mjs +++ b/scripts/smoke-observability-metrics.mjs @@ -47,20 +47,21 @@ const body = { const push = await fetch("http://otel-collector:4318/v1/metrics", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify(body) + body: JSON.stringify(body), + signal: AbortSignal.timeout(${JSON.stringify(timeoutMs)}) }); if (!push.ok) throw new Error("collector rejected smoke metric: " + push.status + " " + await push.text()); const deadline = Date.now() + ${JSON.stringify(timeoutMs)}; let last = ""; while (Date.now() <= deadline) { - const res = await fetch("http://otel-collector:8889/metrics"); + const res = await fetch("http://otel-collector:8889/metrics", { signal: AbortSignal.timeout(${JSON.stringify(timeoutMs)}) }); if (res.ok) { const text = await res.text(); if (text.includes(metricName)) { // Second check: the app's own /metrics is basic-shape sane (real HELP/TYPE lines exist), not just // that the process answers 200. Independent of the collector path above -- this is the app's own // in-process registry (src/selfhost/metrics.ts), not something the collector could mask a break in. - const appRes = await fetch("http://localhost:8787/metrics"); + const appRes = await fetch("http://localhost:8787/metrics", { signal: AbortSignal.timeout(${JSON.stringify(timeoutMs)}) }); if (!appRes.ok) throw new Error("app /metrics returned " + appRes.status); const appText = await appRes.text(); if (!appText.includes("# HELP loopover_uptime_seconds") || !appText.includes("# TYPE loopover_uptime_seconds gauge")) { diff --git a/scripts/smoke-observability-traces.mjs b/scripts/smoke-observability-traces.mjs old mode 100755 new mode 100644 index 173610661f..f3aa0e7d11 --- a/scripts/smoke-observability-traces.mjs +++ b/scripts/smoke-observability-traces.mjs @@ -48,13 +48,14 @@ const body = { const push = await fetch("http://otel-collector:4318/v1/traces", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify(body) + body: JSON.stringify(body), + signal: AbortSignal.timeout(${JSON.stringify(timeoutMs)}) }); if (!push.ok) throw new Error("collector rejected smoke trace: " + push.status + " " + await push.text()); const deadline = Date.now() + ${JSON.stringify(timeoutMs)}; let last = ""; while (Date.now() <= deadline) { - const res = await fetch("http://tempo:3200/api/traces/" + traceId); + const res = await fetch("http://tempo:3200/api/traces/" + traceId, { signal: AbortSignal.timeout(${JSON.stringify(timeoutMs)}) }); if (res.ok) { const json = await res.json(); if (JSON.stringify(json).includes("selfhost.observability.smoke")) { diff --git a/test/unit/observability-release-fetch-timeout.test.ts b/test/unit/observability-release-fetch-timeout.test.ts new file mode 100644 index 0000000000..cc6e4b3cd4 --- /dev/null +++ b/test/unit/observability-release-fetch-timeout.test.ts @@ -0,0 +1,28 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +// #7014: these unattended, CI-workflow-driven scripts make outbound fetches with no per-request timeout, so a +// single hung connection could block the job past its intended deadline (or indefinitely). Every fetch they +// make must now carry an AbortSignal timeout. Asserted structurally because the scripts run against live +// container/GitHub endpoints that a unit test can't stand up. + +it("check-mcp-release-due's githubRequest fetch carries an AbortSignal timeout", () => { + const src = readFileSync("scripts/check-mcp-release-due.mjs", "utf8"); + const fetchCount = (src.match(/\bfetch\(/g) ?? []).length; + const timeoutCount = (src.match(/AbortSignal\.timeout\(/g) ?? []).length; + expect(fetchCount).toBe(1); + expect(timeoutCount).toBe(1); +}); + +describe("smoke-observability scripts (#7014): every generated fetch is timeout-guarded", () => { + for (const path of ["scripts/smoke-observability-traces.mjs", "scripts/smoke-observability-metrics.mjs"]) { + it(`${path} bounds every fetch with AbortSignal.timeout`, () => { + const src = readFileSync(path, "utf8"); + const fetchCount = (src.match(/\bawait fetch\(/g) ?? []).length; + const timeoutCount = (src.match(/AbortSignal\.timeout\(/g) ?? []).length; + expect(fetchCount, `${path}: expected fetch calls`).toBeGreaterThan(0); + // One timeout per fetch — no un-bounded outbound call is left in the generated smoke script. + expect(timeoutCount, `${path}: every fetch guarded`).toBe(fetchCount); + }); + } +});