Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions scripts/check-mcp-release-due.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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;
Expand Down
7 changes: 4 additions & 3 deletions scripts/smoke-observability-metrics.mjs
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -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")) {
Expand Down
5 changes: 3 additions & 2 deletions scripts/smoke-observability-traces.mjs
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -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")) {
Expand Down
28 changes: 28 additions & 0 deletions test/unit/observability-release-fetch-timeout.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
}
});