From 096cd231ea7425790280de89c671a71ba0400d66 Mon Sep 17 00:00:00 2001 From: juyaohuidt Date: Sun, 2 Aug 2026 19:46:50 +0800 Subject: [PATCH] fix(github-copilot): cancel unread device-flow error bodies before throwing (#112268) * fix(github-copilot): cancel unread device-flow error bodies before throwing postGitHubDeviceFlowForm now drops the unread response stream before it raises on a non-OK HTTP status. GitHub OAuth error responses ship a JSON body (`{error, error_description}`) that this helper never consumes, so the underlying fetch connection previously stayed open holding the payload until the caller's release() ran, keeping the device-flow socket half-drained on every 4xx/5xx. Mirrors the recently-merged qqbot/kilocode/tlon fixes for the same 'cancel body before throwing' pattern (#110008, #109950, #112059). * style(github-copilot): clarify response release ordering --------- Co-authored-by: Peter Steinberger --- extensions/github-copilot/login.test.ts | 63 +++++++++++++++++++++++++ extensions/github-copilot/login.ts | 2 + 2 files changed, 65 insertions(+) diff --git a/extensions/github-copilot/login.test.ts b/extensions/github-copilot/login.test.ts index e5d0ee4a5f81..7aeeb2194d39 100644 --- a/extensions/github-copilot/login.test.ts +++ b/extensions/github-copilot/login.test.ts @@ -130,6 +130,35 @@ describe("runGitHubCopilotDeviceFlow — HTTP error propagation", () => { ); }); + it("cancels the unread device code body before throwing on non-OK", async () => { + let canceled = false; + const body = new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode( + JSON.stringify({ error: "server_error", error_description: "boom" }), + ), + ); + }, + cancel() { + canceled = true; + }, + }); + mocks.fetchWithSsrFGuard.mockImplementation(async () => ({ + response: new Response(body, { + status: 502, + headers: { "Content-Type": "application/json" }, + }), + finalUrl: DEVICE_CODE_URL, + release: vi.fn(async () => {}), + })); + + await expect(runGitHubCopilotDeviceFlow({ showCode: vi.fn() })).rejects.toThrow( + "GitHub device code failed: HTTP 502", + ); + expect(canceled).toBe(true); + }); + it("throws with failureLabel on non-OK access token response", async () => { let callIdx = 0; mocks.fetchWithSsrFGuard.mockImplementation(async () => { @@ -145,6 +174,40 @@ describe("runGitHubCopilotDeviceFlow — HTTP error propagation", () => { ); }); + it("cancels the unread access token body before throwing on non-OK", async () => { + let canceled = false; + let callIdx = 0; + mocks.fetchWithSsrFGuard.mockImplementation(async () => { + callIdx += 1; + if (callIdx === 1) { + return guardResponse(VALID_DEVICE_CODE_BODY); + } + const body = new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode(JSON.stringify({ error: "bad_verification_code" })), + ); + }, + cancel() { + canceled = true; + }, + }); + return { + response: new Response(body, { + status: 401, + headers: { "Content-Type": "application/json" }, + }), + finalUrl: ACCESS_TOKEN_URL, + release: vi.fn(async () => {}), + }; + }); + + await expect(runDeviceFlowAfterFirstPoll({ showCode: vi.fn(async () => {}) })).rejects.toThrow( + "GitHub device token failed: HTTP 401", + ); + expect(canceled).toBe(true); + }); + it("rejects a malformed access token response", async () => { let callIdx = 0; mocks.fetchWithSsrFGuard.mockImplementation(async () => { diff --git a/extensions/github-copilot/login.ts b/extensions/github-copilot/login.ts index 48e957ae9119..09b569ac4ca7 100644 --- a/extensions/github-copilot/login.ts +++ b/extensions/github-copilot/login.ts @@ -161,6 +161,8 @@ async function postGitHubDeviceFlowForm(params: { }); try { if (!response.ok) { + // Release closes the dispatcher, so cancel its unread response body first. + await response.body?.cancel().catch(() => undefined); throw new Error(`${params.failureLabel}: HTTP ${response.status}`); } return parseJsonResponse(