From 38ebc24f77bb4b81ff129cca2a15a2abb7f0c403 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 19 Jun 2026 06:52:09 +0200 Subject: [PATCH] fix(github): cancel gh-read bodies on timeout --- scripts/gh-read.ts | 51 +++++++++++++++++++++++++++++++----- test/scripts/gh-read.test.ts | 15 ++++++++++- 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/scripts/gh-read.ts b/scripts/gh-read.ts index d3d692d2379..ce1d7f9fbd0 100644 --- a/scripts/gh-read.ts +++ b/scripts/gh-read.ts @@ -43,6 +43,11 @@ type GitHubJsonOptions = { timeoutMs?: number; }; +type GitHubBodyReadOptions = { + signal?: AbortSignal; + timeoutPromise?: Promise; +}; + export function parseRepoArg(args: string[]): string | null { for (let i = 0; i < args.length; i += 1) { const arg = args[i]; @@ -174,11 +179,11 @@ function createAppJwt(appId: string, privateKeyPem: string) { async function withGitHubFetchTimeout( label: string, timeoutMs: number, - run: (signal: AbortSignal) => Promise, + run: (signal: AbortSignal, timeoutPromise: Promise) => Promise, ): Promise { const controller = new AbortController(); let timeout: ReturnType | undefined; - const timeoutPromise = new Promise((_resolve, reject) => { + const timeoutPromise = new Promise((_resolve, reject) => { timeout = setTimeout(() => { const error = new Error(`${label} exceeded timeout of ${timeoutMs}ms`); reject(error); @@ -194,9 +199,35 @@ async function withGitHubFetchTimeout( } } +function cancelReaderSoon(reader: ReadableStreamDefaultReader): void { + void Promise.resolve() + .then(() => reader.cancel()) + .catch(() => undefined); +} + +async function readGitHubErrorChunk( + reader: ReadableStreamDefaultReader, + timeoutPromise: Promise | undefined, + markCanceled: () => void, +): Promise> { + const read = reader.read(); + if (!timeoutPromise) { + return await read; + } + return await Promise.race([ + read, + timeoutPromise.catch((error: unknown) => { + markCanceled(); + cancelReaderSoon(reader); + throw error; + }), + ]); +} + export async function readBoundedGitHubErrorText( response: Response, maxChars = GITHUB_ERROR_BODY_MAX_CHARS, + options: Pick = {}, ): Promise { if (!response.body) { return ""; @@ -206,10 +237,13 @@ export async function readBoundedGitHubErrorText( const decoder = new TextDecoder(); let text = ""; let truncated = false; + let canceled = false; try { while (text.length <= maxChars) { - const { done, value } = await reader.read(); + const { done, value } = await readGitHubErrorChunk(reader, options.timeoutPromise, () => { + canceled = true; + }); if (done) { text += decoder.decode(); break; @@ -225,7 +259,7 @@ export async function readBoundedGitHubErrorText( } finally { if (truncated) { await reader.cancel().catch(() => undefined); - } else { + } else if (!canceled) { reader.releaseLock(); } } @@ -236,12 +270,15 @@ export async function readBoundedGitHubErrorText( export async function readBoundedGitHubJson( response: Response, maxBytes = GITHUB_JSON_BODY_MAX_BYTES, + options: GitHubBodyReadOptions = {}, ): Promise { const text = await readBoundedResponseText(response, "GitHub API", maxBytes, { createTooLargeError: (message) => Object.assign(new Error(message), { code: "ETOOBIG", }), + signal: options.signal, + timeoutPromise: options.timeoutPromise, }); return JSON.parse(text) as T; } @@ -260,7 +297,7 @@ export async function githubJson( return await withGitHubFetchTimeout( `GitHub API ${init?.method ?? "GET"} ${path}`, timeoutMs, - async (signal) => { + async (signal, timeoutPromise) => { const response = await fetchImpl(`https://api.github.com${path}`, { method: init?.method ?? "GET", headers: { @@ -275,11 +312,11 @@ export async function githubJson( }); if (!response.ok) { - const text = await readBoundedGitHubErrorText(response); + const text = await readBoundedGitHubErrorText(response, undefined, { timeoutPromise }); fail(`${init?.method ?? "GET"} ${path} failed (${response.status}): ${text}`); } - return await readBoundedGitHubJson(response); + return await readBoundedGitHubJson(response, undefined, { signal, timeoutPromise }); }, ); } diff --git a/test/scripts/gh-read.test.ts b/test/scripts/gh-read.test.ts index 9e8a5478979..ab8c79d80e5 100644 --- a/test/scripts/gh-read.test.ts +++ b/test/scripts/gh-read.test.ts @@ -85,8 +85,19 @@ describe("gh-read helpers", () => { }); it("times out stalled GitHub API response body reads", async () => { + let canceled = false; vi.useFakeTimers(); - const response = new Response(new ReadableStream({}), { status: 200 }); + const response = new Response( + new ReadableStream({ + pull() { + return new Promise(() => {}); + }, + cancel() { + canceled = true; + }, + }), + { status: 200 }, + ); const request = githubJson("/app/installations", "token", undefined, { timeoutMs: 5, fetchImpl: (() => Promise.resolve(response)) as typeof fetch, @@ -98,6 +109,8 @@ describe("gh-read helpers", () => { await vi.advanceTimersByTimeAsync(5); await rejection; + await Promise.resolve(); + expect(canceled).toBe(true); }); it("bounds GitHub API error response bodies", async () => {