Skip to content

Commit b05aefa

Browse files
committed
fix(release): bound beta smoke waits
1 parent fc6fd9a commit b05aefa

2 files changed

Lines changed: 136 additions & 12 deletions

File tree

scripts/release-beta-smoke.ts

Lines changed: 72 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,26 @@ interface Options {
1414
skipTelegram: boolean;
1515
}
1616

17+
export type RunOptions = {
18+
capture?: boolean;
19+
timeoutMs?: number;
20+
};
21+
22+
export type WorkflowRunInfo = {
23+
conclusion: string | null;
24+
html_url: string;
25+
status: string;
26+
updated_at: string;
27+
};
28+
29+
export type PollRunOptions = {
30+
pollIntervalMs?: number;
31+
readRun?: (repo: string, runId: string) => WorkflowRunInfo;
32+
sleep?: (ms: number) => Promise<void>;
33+
timeoutMs?: number;
34+
now?: () => number;
35+
};
36+
1737
function usage(): string {
1838
return `Usage: pnpm release:beta-smoke -- --beta beta4 [options]
1939
@@ -88,15 +108,43 @@ function requireValue(argv: string[], index: number, flag: string): string {
88108
}
89109

90110
const CAPTURE_MAX_BUFFER_BYTES = 32 * 1024 * 1024;
111+
const DEFAULT_COMMAND_TIMEOUT_MS = readPositiveInt(
112+
process.env.OPENCLAW_RELEASE_BETA_SMOKE_COMMAND_MS,
113+
10 * 60_000,
114+
);
115+
const TELEGRAM_POLL_INTERVAL_MS = readPositiveInt(
116+
process.env.OPENCLAW_RELEASE_BETA_SMOKE_POLL_INTERVAL_MS,
117+
30_000,
118+
);
119+
const TELEGRAM_POLL_TIMEOUT_MS = readPositiveInt(
120+
process.env.OPENCLAW_RELEASE_BETA_SMOKE_POLL_TIMEOUT_MS,
121+
4 * 60 * 60_000,
122+
);
123+
124+
function readPositiveInt(raw: string | undefined, fallback: number): number {
125+
const text = (raw ?? "").trim();
126+
if (!/^\d+$/u.test(text)) {
127+
return fallback;
128+
}
129+
const parsed = Number(text);
130+
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
131+
}
91132

92-
function run(command: string, args: string[], input?: { capture?: boolean }): string {
133+
export function run(command: string, args: string[], input?: RunOptions): string {
134+
const timeoutMs = input?.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
93135
const result = spawnSync(command, args, {
94136
encoding: "utf8",
137+
killSignal: "SIGKILL",
95138
maxBuffer: CAPTURE_MAX_BUFFER_BYTES,
96139
stdio: input?.capture ? ["ignore", "pipe", "pipe"] : "inherit",
140+
timeout: timeoutMs,
97141
});
98-
if (result.status !== 0) {
99-
const reason = result.status ?? result.signal ?? result.error?.message ?? "unknown";
142+
if (result.error || result.status !== 0) {
143+
const errorCode = (result.error as NodeJS.ErrnoException | undefined)?.code;
144+
const reason =
145+
errorCode === "ETIMEDOUT"
146+
? `timed out after ${timeoutMs}ms`
147+
: (result.status ?? result.signal ?? result.error?.message ?? "unknown");
100148
const stderr = result.stderr ? `\n${result.stderr}` : "";
101149
throw new Error(`${command} ${args.join(" ")} failed with ${reason}${stderr}`);
102150
}
@@ -161,7 +209,7 @@ function runParallels(beta: string, model: string): void {
161209
"150m",
162210
...forwarded.map(shellQuote),
163211
].join(" ");
164-
run("bash", ["-lc", command]);
212+
run("bash", ["-lc", command], { timeoutMs: 155 * 60_000 });
165213
}
166214

167215
function ghJson(repo: string, pathSuffix: string): unknown {
@@ -268,14 +316,22 @@ async function dispatchTelegram(options: Options, packageSpec: string): Promise<
268316
});
269317
}
270318

271-
async function pollRun(repo: string, runId: string): Promise<void> {
319+
export async function pollRun(
320+
repo: string,
321+
runId: string,
322+
options: PollRunOptions = {},
323+
): Promise<void> {
324+
const started = (options.now ?? Date.now)();
325+
const timeoutMs = Math.max(1, options.timeoutMs ?? TELEGRAM_POLL_TIMEOUT_MS);
326+
const pollIntervalMs = Math.max(1, options.pollIntervalMs ?? TELEGRAM_POLL_INTERVAL_MS);
327+
const sleep =
328+
options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
329+
const readRun =
330+
options.readRun ??
331+
((currentRepo: string, currentRunId: string) =>
332+
ghJson(currentRepo, `actions/runs/${currentRunId}`) as WorkflowRunInfo);
272333
for (;;) {
273-
const info = ghJson(repo, `actions/runs/${runId}`) as {
274-
conclusion: string | null;
275-
html_url: string;
276-
status: string;
277-
updated_at: string;
278-
};
334+
const info = readRun(repo, runId);
279335
console.log(
280336
`Telegram workflow ${runId}: ${info.status}${info.conclusion ? `/${info.conclusion}` : ""} updated=${info.updated_at}`,
281337
);
@@ -288,7 +344,11 @@ async function pollRun(repo: string, runId: string): Promise<void> {
288344
console.log(info.html_url);
289345
return;
290346
}
291-
await new Promise((resolve) => setTimeout(resolve, 30_000));
347+
const elapsedMs = (options.now ?? Date.now)() - started;
348+
if (elapsedMs >= timeoutMs) {
349+
throw new Error(`Telegram workflow ${runId} did not complete within ${timeoutMs}ms`);
350+
}
351+
await sleep(Math.min(pollIntervalMs, timeoutMs - elapsedMs));
292352
}
293353
}
294354

test/scripts/release-beta-smoke.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import {
33
mergeTelegramProofIntoReleaseBody,
44
parseArgs,
55
parseWorkflowRunIdFromOutput,
6+
pollRun,
7+
run,
68
selectNewestDispatchedRunId,
79
} from "../../scripts/release-beta-smoke.ts";
810

@@ -97,4 +99,66 @@ describe("release-beta-smoke", () => {
9799

98100
expect(merged.indexOf("actions/runs/123")).toBeLessThan(merged.indexOf("### Assets"));
99101
});
102+
103+
it("bounds child command hangs", () => {
104+
expect(() =>
105+
run(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {
106+
capture: true,
107+
timeoutMs: 50,
108+
}),
109+
).toThrow(/timed out after 50ms/u);
110+
});
111+
112+
it("uses a non-ignorable timeout signal for trapped children", () => {
113+
expect(() =>
114+
run(
115+
process.execPath,
116+
["-e", "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)"],
117+
{
118+
capture: true,
119+
timeoutMs: 50,
120+
},
121+
),
122+
).toThrow(/timed out after 50ms/u);
123+
});
124+
125+
it("stops polling Telegram workflow runs after the timeout budget", async () => {
126+
let now = 0;
127+
const sleeps: number[] = [];
128+
129+
await expect(
130+
pollRun("openclaw/openclaw", "123", {
131+
now: () => now,
132+
pollIntervalMs: 400,
133+
readRun: () => ({
134+
conclusion: null,
135+
html_url: "https://github.com/openclaw/openclaw/actions/runs/123",
136+
status: "queued",
137+
updated_at: "2026-05-28T12:00:00Z",
138+
}),
139+
sleep: async (ms) => {
140+
sleeps.push(ms);
141+
now += ms;
142+
},
143+
timeoutMs: 1000,
144+
}),
145+
).rejects.toThrow("Telegram workflow 123 did not complete within 1000ms");
146+
expect(sleeps).toEqual([400, 400, 200]);
147+
});
148+
149+
it("returns when the Telegram workflow succeeds", async () => {
150+
await expect(
151+
pollRun("openclaw/openclaw", "123", {
152+
readRun: () => ({
153+
conclusion: "success",
154+
html_url: "https://github.com/openclaw/openclaw/actions/runs/123",
155+
status: "completed",
156+
updated_at: "2026-05-28T12:00:00Z",
157+
}),
158+
sleep: async () => {
159+
throw new Error("sleep should not run after completion");
160+
},
161+
}),
162+
).resolves.toBeUndefined();
163+
});
100164
});

0 commit comments

Comments
 (0)