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 <steipete@gmail.com>
This commit is contained in:
juyaohuidt
2026-08-02 19:46:50 +08:00
committed by GitHub
parent 397e09e1af
commit 096cd231ea
2 changed files with 65 additions and 0 deletions
+63
View File
@@ -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<Uint8Array>({
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<Uint8Array>({
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 () => {
+2
View File
@@ -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(