Skip to content

Commit ffbe684

Browse files
authored
fix(scripts): bound outbound fetches in unattended release/observability scripts (#7014) (#7044)
1 parent 28b6e9e commit ffbe684

4 files changed

Lines changed: 39 additions & 5 deletions

File tree

scripts/check-mcp-release-due.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import { pathToFileURL } from "node:url";
55
import { buildMcpReleaseIssue, buildMcpReleaseReport, latestSemverTag, MCP_RELEASE_DUE_MARKER } from "./mcp-release-core.mjs";
66

77
const packageJsonPath = "packages/loopover-mcp/package.json";
8+
// Per-request timeout so a hung api.github.com connection can't block the unattended mcp-release-watch job
9+
// indefinitely, matching the connection guard sibling release/observability scripts already use (#7014).
10+
const GITHUB_REQUEST_TIMEOUT_MS = 30_000;
811

912
async function main() {
1013
const args = parseArgs(process.argv.slice(2));
@@ -144,6 +147,7 @@ async function githubRequest({ token, method, path, body }) {
144147
"x-github-api-version": "2022-11-28",
145148
},
146149
body: body ? JSON.stringify(body) : undefined,
150+
signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS),
147151
});
148152
const text = await response.text();
149153
const payload = text ? JSON.parse(text) : null;

scripts/smoke-observability-metrics.mjs

100755100644
Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,20 +47,21 @@ const body = {
4747
const push = await fetch("http://otel-collector:4318/v1/metrics", {
4848
method: "POST",
4949
headers: { "content-type": "application/json" },
50-
body: JSON.stringify(body)
50+
body: JSON.stringify(body),
51+
signal: AbortSignal.timeout(${JSON.stringify(timeoutMs)})
5152
});
5253
if (!push.ok) throw new Error("collector rejected smoke metric: " + push.status + " " + await push.text());
5354
const deadline = Date.now() + ${JSON.stringify(timeoutMs)};
5455
let last = "";
5556
while (Date.now() <= deadline) {
56-
const res = await fetch("http://otel-collector:8889/metrics");
57+
const res = await fetch("http://otel-collector:8889/metrics", { signal: AbortSignal.timeout(${JSON.stringify(timeoutMs)}) });
5758
if (res.ok) {
5859
const text = await res.text();
5960
if (text.includes(metricName)) {
6061
// Second check: the app's own /metrics is basic-shape sane (real HELP/TYPE lines exist), not just
6162
// that the process answers 200. Independent of the collector path above -- this is the app's own
6263
// in-process registry (src/selfhost/metrics.ts), not something the collector could mask a break in.
63-
const appRes = await fetch("http://localhost:8787/metrics");
64+
const appRes = await fetch("http://localhost:8787/metrics", { signal: AbortSignal.timeout(${JSON.stringify(timeoutMs)}) });
6465
if (!appRes.ok) throw new Error("app /metrics returned " + appRes.status);
6566
const appText = await appRes.text();
6667
if (!appText.includes("# HELP loopover_uptime_seconds") || !appText.includes("# TYPE loopover_uptime_seconds gauge")) {

scripts/smoke-observability-traces.mjs

100755100644
Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,14 @@ const body = {
4848
const push = await fetch("http://otel-collector:4318/v1/traces", {
4949
method: "POST",
5050
headers: { "content-type": "application/json" },
51-
body: JSON.stringify(body)
51+
body: JSON.stringify(body),
52+
signal: AbortSignal.timeout(${JSON.stringify(timeoutMs)})
5253
});
5354
if (!push.ok) throw new Error("collector rejected smoke trace: " + push.status + " " + await push.text());
5455
const deadline = Date.now() + ${JSON.stringify(timeoutMs)};
5556
let last = "";
5657
while (Date.now() <= deadline) {
57-
const res = await fetch("http://tempo:3200/api/traces/" + traceId);
58+
const res = await fetch("http://tempo:3200/api/traces/" + traceId, { signal: AbortSignal.timeout(${JSON.stringify(timeoutMs)}) });
5859
if (res.ok) {
5960
const json = await res.json();
6061
if (JSON.stringify(json).includes("selfhost.observability.smoke")) {
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { readFileSync } from "node:fs";
2+
import { describe, expect, it } from "vitest";
3+
4+
// #7014: these unattended, CI-workflow-driven scripts make outbound fetches with no per-request timeout, so a
5+
// single hung connection could block the job past its intended deadline (or indefinitely). Every fetch they
6+
// make must now carry an AbortSignal timeout. Asserted structurally because the scripts run against live
7+
// container/GitHub endpoints that a unit test can't stand up.
8+
9+
it("check-mcp-release-due's githubRequest fetch carries an AbortSignal timeout", () => {
10+
const src = readFileSync("scripts/check-mcp-release-due.mjs", "utf8");
11+
const fetchCount = (src.match(/\bfetch\(/g) ?? []).length;
12+
const timeoutCount = (src.match(/AbortSignal\.timeout\(/g) ?? []).length;
13+
expect(fetchCount).toBe(1);
14+
expect(timeoutCount).toBe(1);
15+
});
16+
17+
describe("smoke-observability scripts (#7014): every generated fetch is timeout-guarded", () => {
18+
for (const path of ["scripts/smoke-observability-traces.mjs", "scripts/smoke-observability-metrics.mjs"]) {
19+
it(`${path} bounds every fetch with AbortSignal.timeout`, () => {
20+
const src = readFileSync(path, "utf8");
21+
const fetchCount = (src.match(/\bawait fetch\(/g) ?? []).length;
22+
const timeoutCount = (src.match(/AbortSignal\.timeout\(/g) ?? []).length;
23+
expect(fetchCount, `${path}: expected fetch calls`).toBeGreaterThan(0);
24+
// One timeout per fetch — no un-bounded outbound call is left in the generated smoke script.
25+
expect(timeoutCount, `${path}: every fetch guarded`).toBe(fetchCount);
26+
});
27+
}
28+
});

0 commit comments

Comments
 (0)