From bc1840b196dcc9d438861e53301a3dbbeab6974f Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 7 Apr 2026 15:46:02 -0500 Subject: [PATCH] fix(opencode): clear webfetch timeouts on failed fetches (#21378) --- packages/opencode/src/tool/webfetch.ts | 20 +++++--- packages/opencode/test/tool/webfetch.test.ts | 53 ++++++++++++++++++++ 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/tool/webfetch.ts b/packages/opencode/src/tool/webfetch.ts index a66e66c097..559afd6771 100644 --- a/packages/opencode/src/tool/webfetch.ts +++ b/packages/opencode/src/tool/webfetch.ts @@ -3,6 +3,7 @@ import { Tool } from "./tool" import TurndownService from "turndown" import DESCRIPTION from "./webfetch.txt" import { abortAfterAny } from "../util/abort" +import { iife } from "@/util/iife" const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds @@ -62,15 +63,18 @@ export const WebFetchTool = Tool.define("webfetch", { "Accept-Language": "en-US,en;q=0.9", } - const initial = await fetch(params.url, { signal, headers }) + const response = await iife(async () => { + try { + const initial = await fetch(params.url, { signal, headers }) - // Retry with honest UA if blocked by Cloudflare bot detection (TLS fingerprint mismatch) - const response = - initial.status === 403 && initial.headers.get("cf-mitigated") === "challenge" - ? await fetch(params.url, { signal, headers: { ...headers, "User-Agent": "opencode" } }) - : initial - - clearTimeout() + // Retry with honest UA if blocked by Cloudflare bot detection (TLS fingerprint mismatch) + return initial.status === 403 && initial.headers.get("cf-mitigated") === "challenge" + ? await fetch(params.url, { signal, headers: { ...headers, "User-Agent": "opencode" } }) + : initial + } finally { + clearTimeout() + } + }) if (!response.ok) { throw new Error(`Request failed with status code: ${response.status}`) diff --git a/packages/opencode/test/tool/webfetch.test.ts b/packages/opencode/test/tool/webfetch.test.ts index 088f3dd16d..c37ba3e08a 100644 --- a/packages/opencode/test/tool/webfetch.test.ts +++ b/packages/opencode/test/tool/webfetch.test.ts @@ -17,6 +17,8 @@ const ctx = { ask: async () => {}, } +type TimerID = ReturnType + async function withFetch( mockFetch: (input: string | URL | Request, init?: RequestInit) => Promise, fn: () => Promise, @@ -30,6 +32,32 @@ async function withFetch( } } +async function withTimers(fn: (state: { ids: TimerID[]; cleared: TimerID[] }) => Promise) { + const set = globalThis.setTimeout + const clear = globalThis.clearTimeout + const ids: TimerID[] = [] + const cleared: TimerID[] = [] + + globalThis.setTimeout = ((...args: Parameters) => { + const id = set(...args) + ids.push(id) + return id + }) as typeof setTimeout + + globalThis.clearTimeout = ((id?: TimerID) => { + if (id !== undefined) cleared.push(id) + return clear(id) + }) as typeof clearTimeout + + try { + await fn({ ids, cleared }) + } finally { + ids.forEach(clear) + globalThis.setTimeout = set + globalThis.clearTimeout = clear + } +} + describe("tool.webfetch", () => { test("returns image responses as file attachments", async () => { const bytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]) @@ -98,4 +126,29 @@ describe("tool.webfetch", () => { }, ) }) + + test("clears timeout when fetch rejects", async () => { + await withTimers(async ({ ids, cleared }) => { + await withFetch( + async () => { + throw new Error("boom") + }, + async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const webfetch = await WebFetchTool.init() + await expect( + webfetch.execute({ url: "https://example.com/file.txt", format: "text" }, ctx), + ).rejects.toThrow("boom") + }, + }) + }, + ) + + expect(ids).toHaveLength(1) + expect(cleared).toHaveLength(1) + expect(cleared[0]).toBe(ids[0]) + }) + }) })