Просмотр исходного кода

fix(opencode): clear webfetch timeouts on failed fetches (#21378)

Aiden Cline 1 неделя назад
Родитель
Сommit
bc1840b196
2 измененных файлов с 65 добавлено и 8 удалено
  1. 12 8
      packages/opencode/src/tool/webfetch.ts
  2. 53 0
      packages/opencode/test/tool/webfetch.test.ts

+ 12 - 8
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}`)

+ 53 - 0
packages/opencode/test/tool/webfetch.test.ts

@@ -17,6 +17,8 @@ const ctx = {
   ask: async () => {},
 }
 
+type TimerID = ReturnType<typeof setTimeout>
+
 async function withFetch(
   mockFetch: (input: string | URL | Request, init?: RequestInit) => Promise<Response>,
   fn: () => Promise<void>,
@@ -30,6 +32,32 @@ async function withFetch(
   }
 }
 
+async function withTimers(fn: (state: { ids: TimerID[]; cleared: TimerID[] }) => Promise<void>) {
+  const set = globalThis.setTimeout
+  const clear = globalThis.clearTimeout
+  const ids: TimerID[] = []
+  const cleared: TimerID[] = []
+
+  globalThis.setTimeout = ((...args: Parameters<typeof setTimeout>) => {
+    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])
+    })
+  })
 })