diff --git a/scripts/openclaw-cross-os-release-checks.ts b/scripts/openclaw-cross-os-release-checks.ts index 0a8cf2bd9c2..23902047d37 100644 --- a/scripts/openclaw-cross-os-release-checks.ts +++ b/scripts/openclaw-cross-os-release-checks.ts @@ -2554,6 +2554,7 @@ async function configureDiscordSmoke(params) { export async function readBoundedCrossOsResponseText( response: Response, maxChars = CROSS_OS_FETCH_BODY_MAX_CHARS, + options: { signal?: AbortSignal | null } = {}, ): Promise { if (!response.body) { return ""; @@ -2563,10 +2564,11 @@ export async function readBoundedCrossOsResponseText( const decoder = new TextDecoder(); let text = ""; let truncated = false; + let aborted = false; try { while (text.length <= maxChars) { - const { done, value } = await reader.read(); + const { done, value } = await readCrossOsResponseChunk(reader, options.signal); if (done) { text += decoder.decode(); break; @@ -2579,8 +2581,11 @@ export async function readBoundedCrossOsResponseText( break; } } + } catch (error) { + aborted = options.signal?.aborted === true; + throw error; } finally { - if (truncated) { + if (truncated || aborted) { await reader.cancel().catch(() => undefined); } else { reader.releaseLock(); @@ -2590,6 +2595,36 @@ export async function readBoundedCrossOsResponseText( return truncated ? `${text}\n[truncated]` : text; } +function readCrossOsResponseChunk( + reader: ReadableStreamDefaultReader, + signal?: AbortSignal | null, +): Promise> { + if (!signal) { + return reader.read(); + } + if (signal.aborted) { + throw crossOsAbortReason(signal); + } + return new Promise((resolveRead, rejectRead) => { + const onAbort = () => rejectRead(crossOsAbortReason(signal)); + signal.addEventListener("abort", onAbort, { once: true }); + reader + .read() + .then(resolveRead, rejectRead) + .finally(() => { + signal.removeEventListener("abort", onAbort); + }); + }); +} + +function crossOsAbortReason(signal: AbortSignal): Error { + const reason = signal.reason; + if (reason instanceof Error) { + return reason; + } + return new Error(typeof reason === "string" ? reason : "The operation was aborted."); +} + export function dashboardHtmlMarkerStatus(html: string): { app: boolean; ready: boolean; @@ -2647,11 +2682,12 @@ async function waitForDiscordMessage(params) { let response; let text; try { + const init = buildDiscordFetchInit(params.token); response = await fetch( `https://discord.com/api/v10/channels/${params.channelId}/messages?limit=20`, - buildDiscordFetchInit(params.token), + init, ); - text = await readBoundedCrossOsResponseText(response); + text = await readBoundedCrossOsResponseText(response, undefined, { signal: init.signal }); } catch { await sleep(2_000); continue; @@ -2680,20 +2716,21 @@ export function buildDiscordFetchInit(token, init = {}) { } async function postDiscordMessage(params) { + const init = buildDiscordFetchInit(params.token, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + content: params.content, + flags: 4096, + }), + }); const response = await fetch( `https://discord.com/api/v10/channels/${params.channelId}/messages`, - buildDiscordFetchInit(params.token, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - content: params.content, - flags: 4096, - }), - }), + init, ); - const text = await readBoundedCrossOsResponseText(response); + const text = await readBoundedCrossOsResponseText(response, undefined, { signal: init.signal }); if (!response.ok) { throw new Error(`Failed to post Discord smoke message: ${text}`); } @@ -3525,10 +3562,11 @@ async function runDashboardSmoke(params) { attempt += 1; logStream.write(`${new Date().toISOString()} attempt=${attempt} url=${dashboardUrl}\n`); try { + const signal = AbortSignal.timeout(CROSS_OS_DASHBOARD_FETCH_TIMEOUT_MS); const response = await fetch(dashboardUrl, { - signal: AbortSignal.timeout(CROSS_OS_DASHBOARD_FETCH_TIMEOUT_MS), + signal, }); - const html = await readBoundedCrossOsResponseText(response); + const html = await readBoundedCrossOsResponseText(response, undefined, { signal }); const markers = dashboardHtmlMarkerStatus(html); const assetUrls = resolveDashboardAssetUrls(dashboardUrl, html); if (response.ok && markers.ready) { diff --git a/test/scripts/openclaw-cross-os-release-checks.test.ts b/test/scripts/openclaw-cross-os-release-checks.test.ts index 68635e14f30..f66486627de 100644 --- a/test/scripts/openclaw-cross-os-release-checks.test.ts +++ b/test/scripts/openclaw-cross-os-release-checks.test.ts @@ -166,6 +166,31 @@ describe("scripts/openclaw-cross-os-release-checks", () => { expect(CROSS_OS_FETCH_BODY_MAX_CHARS).toBeGreaterThan(1024); }); + it("keeps cross-OS fetch timeouts active while reading response bodies", async () => { + let canceled = false; + const abortController = new AbortController(); + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("partial")); + }, + cancel() { + canceled = true; + }, + }), + ); + + const text = readBoundedCrossOsResponseText(response, 1024, { + signal: abortController.signal, + }); + + await delay(0); + abortController.abort(new Error("cross-os body timed out")); + + await expect(text).rejects.toThrow("cross-os body timed out"); + expect(canceled).toBe(true); + }); + it("requires dashboard root markers and same-origin asset URLs", () => { const html = [ "OpenClaw Control",