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

fix(sentry): 过滤 plugin-http abort 的 BadResourceId 噪音

取消请求时 fetch_cancel 会碰到已销毁的 native resource,变成 unhandledrejection。主路径转成 AbortError,Sentry 不再上报这条竞态。

Fixes QMAI-2

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi 1 неделя назад
Родитель
Сommit
c2abf47e8f

+ 18 - 0
src/instrument.ts

@@ -1,4 +1,8 @@
 import * as Sentry from "@sentry/react"
+import {
+  isTauriInvalidResourceIdError,
+  shouldDropSentryEvent,
+} from "@/lib/tauri-resource-error"
 
 const SENTRY_DSN =
   import.meta.env.VITE_SENTRY_DSN ??
@@ -33,12 +37,26 @@ if (import.meta.env.MODE !== "test") {
       userInfo: false,
       httpBodies: [],
     },
+    // QMAI-2: plugin-http abort races reject a bare string after the
+    // native resource is already dropped. Not an app bug.
+    ignoreErrors: [/The resource id \d+ is invalid/i],
     beforeBreadcrumb(breadcrumb) {
       if (typeof breadcrumb.message === "string" && breadcrumb.message.length > 200) {
         breadcrumb.message = `${breadcrumb.message.slice(0, 200)}…`
       }
       return breadcrumb
     },
+    beforeSend(event, hint) {
+      if (shouldDropSentryEvent(event, hint.originalException)) {
+        return null
+      }
+      return event
+    },
+  })
+
+  window.addEventListener("unhandledrejection", (event) => {
+    if (!isTauriInvalidResourceIdError(event.reason)) return
+    event.preventDefault()
   })
 }
 

+ 4 - 2
src/lib/llm-client.ts

@@ -10,6 +10,8 @@ import {
   type RequestOverrides,
 } from "./llm-providers"
 import { getHttpFetch, isFetchNetworkError } from "./tauri-fetch"
+import { isUserAbortError } from "./user-abort"
+import { isTauriInvalidResourceIdError } from "./tauri-resource-error"
 import { countReasoningCharsInLine, extractReasoningTextFromLine } from "./reasoning-detector"
 import {
   formatReasoningReplayRiskForError,
@@ -523,7 +525,7 @@ async function streamChatHeld(
     try {
       response = await sendRequest(requestInit)
     } catch (err) {
-      if (signal?.aborted || (combinedSignal?.aborted && !timeoutFired)) {
+      if (signal?.aborted || (combinedSignal?.aborted && !timeoutFired) || isTauriInvalidResourceIdError(err)) {
         onDone()
         return
       }
@@ -889,7 +891,7 @@ async function streamChatHeld(
       finishRequestTrace(activeRequestTrace, "success", streamUsage)
       onDone()
     } catch (err) {
-      if (err instanceof Error && (err.name === "AbortError" || (signal?.aborted))) {
+      if (isUserAbortError(err, signal)) {
         finishRequestTrace(activeRequestTrace, "cancelled", streamUsage)
         onDone()
         return

+ 18 - 0
src/lib/tauri-fetch.test.ts

@@ -17,3 +17,21 @@ test("uses native fetch in a plain browser without Tauri internals", async () =>
   await expect(httpFetch("https://example.com")).resolves.toBeInstanceOf(Response)
   expect(nativeFetch).toHaveBeenCalledWith("https://example.com")
 })
+
+test("maps plugin-http invalid resource id rejections to AbortError", async () => {
+  const pluginFetch = vi.fn(async () => {
+    return Promise.reject("The resource id 2199732775 is invalid.")
+  })
+  vi.doMock("@tauri-apps/plugin-http", () => ({
+    fetch: pluginFetch,
+  }))
+  vi.stubGlobal("window", { __TAURI_INTERNALS__: {} })
+
+  const { getHttpFetch, resetHttpFetchForTests } = await import("./tauri-fetch")
+  resetHttpFetchForTests()
+  const httpFetch = await getHttpFetch()
+
+  await expect(httpFetch("https://example.com")).rejects.toMatchObject({
+    name: "AbortError",
+  })
+})

+ 16 - 1
src/lib/tauri-fetch.ts

@@ -17,8 +17,23 @@
  * from any environment without crashing at module load.
  */
 
+import { asAbortError, isTauriInvalidResourceIdError } from "./tauri-resource-error"
+
 let pluginFetchPromise: Promise<typeof globalThis.fetch> | null = null
 
+function wrapPluginHttpFetch(pluginFetch: typeof globalThis.fetch): typeof globalThis.fetch {
+  return (async (input, init) => {
+    try {
+      return await pluginFetch(input, init)
+    } catch (error) {
+      if (isTauriInvalidResourceIdError(error)) {
+        throw asAbortError()
+      }
+      throw error
+    }
+  }) as typeof globalThis.fetch
+}
+
 /**
  * True when running outside a browser / webview (vitest, SSR, any
  * Node-based tooling). The Tauri plugin is importable in Node
@@ -47,7 +62,7 @@ export function getHttpFetch(): Promise<typeof globalThis.fetch> {
       pluginFetchPromise = Promise.resolve(globalThis.fetch.bind(globalThis))
     } else {
       pluginFetchPromise = import("@tauri-apps/plugin-http")
-        .then((m) => m.fetch as unknown as typeof globalThis.fetch)
+        .then((m) => wrapPluginHttpFetch(m.fetch as unknown as typeof globalThis.fetch))
         .catch(() => globalThis.fetch.bind(globalThis))
     }
   }

+ 49 - 0
src/lib/tauri-resource-error.spec.ts

@@ -0,0 +1,49 @@
+import { describe, expect, it } from "vitest"
+import {
+  asAbortError,
+  isTauriInvalidResourceIdError,
+  shouldDropSentryEvent,
+} from "./tauri-resource-error"
+
+describe("tauri-resource-error", () => {
+  it("detects the raw plugin-http string", () => {
+    expect(isTauriInvalidResourceIdError("The resource id 2199732775 is invalid.")).toBe(true)
+  })
+
+  it("detects Error and Sentry UnhandledRejection wrappers", () => {
+    expect(isTauriInvalidResourceIdError(new Error("The resource id 1 is invalid."))).toBe(true)
+    expect(
+      isTauriInvalidResourceIdError(
+        "UnhandledRejection: Non-Error promise rejection captured with value: The resource id 4231939784 is invalid.",
+      ),
+    ).toBe(true)
+  })
+
+  it("ignores unrelated failures", () => {
+    expect(isTauriInvalidResourceIdError(new Error("Failed to fetch"))).toBe(false)
+    expect(isTauriInvalidResourceIdError("Request cancelled")).toBe(false)
+    expect(isTauriInvalidResourceIdError(undefined)).toBe(false)
+  })
+
+  it("builds an AbortError for caller-facing cancel paths", () => {
+    expect(asAbortError().name).toBe("AbortError")
+  })
+
+  it("drops Sentry events for this race", () => {
+    expect(
+      shouldDropSentryEvent(
+        {
+          exception: {
+            values: [
+              {
+                type: "UnhandledRejection",
+                value: "Non-Error promise rejection captured with value: The resource id 4231939784 is invalid.",
+              },
+            ],
+          },
+        },
+      ),
+    ).toBe(true)
+    expect(shouldDropSentryEvent({ message: "Failed to fetch" })).toBe(false)
+  })
+})

+ 44 - 0
src/lib/tauri-resource-error.ts

@@ -0,0 +1,44 @@
+/**
+ * Tauri drops a native resource (HTTP request/response handle) and then
+ * a later IPC still uses that id. plugin-http does this on abort:
+ * `fetch_cancel` / `fetch_cancel_body` are fire-and-forget, so the
+ * rejection becomes an unhandled string, not a JS Error.
+ *
+ * Matches QMAI-2 / `Error::BadResourceId`.
+ */
+const INVALID_RESOURCE_ID = /The resource id \d+ is invalid/i
+
+export function isTauriInvalidResourceIdError(error: unknown): boolean {
+  if (typeof error === "string") return INVALID_RESOURCE_ID.test(error)
+  if (error instanceof Error) return INVALID_RESOURCE_ID.test(error.message)
+  if (error && typeof error === "object") {
+    const message = "message" in error && typeof error.message === "string" ? error.message : ""
+    const value = "value" in error && typeof error.value === "string" ? error.value : ""
+    return INVALID_RESOURCE_ID.test(message) || INVALID_RESOURCE_ID.test(value)
+  }
+  return false
+}
+
+export function asAbortError(message = "The operation was aborted."): Error {
+  if (typeof DOMException !== "undefined") {
+    return new DOMException(message, "AbortError")
+  }
+  const error = new Error(message)
+  error.name = "AbortError"
+  return error
+}
+
+export function shouldDropSentryEvent(
+  event: {
+    message?: string
+    exception?: { values?: Array<{ type?: string; value?: string }> }
+  },
+  originalException?: unknown,
+): boolean {
+  if (isTauriInvalidResourceIdError(originalException)) return true
+  if (isTauriInvalidResourceIdError(event.message)) return true
+  return (event.exception?.values ?? []).some(
+    (value) =>
+      isTauriInvalidResourceIdError(value.value) || isTauriInvalidResourceIdError(value.type),
+  )
+}

+ 5 - 0
src/lib/user-abort.spec.ts

@@ -21,4 +21,9 @@ describe("user-abort", () => {
   it("ignores unrelated errors", () => {
     expect(() => rethrowIfUserAbort(new Error("timeout"))).not.toThrow()
   })
+
+  it("treats Tauri invalid resource id as abort", () => {
+    expect(isUserAbortError("The resource id 2199732775 is invalid.")).toBe(true)
+    expect(isUserAbortError(new Error("The resource id 1 is invalid."))).toBe(true)
+  })
 })

+ 3 - 0
src/lib/user-abort.ts

@@ -1,3 +1,5 @@
+import { isTauriInvalidResourceIdError } from "./tauri-resource-error"
+
 export const USER_ABORT_MESSAGE = "已停止生成"
 
 export function throwIfAborted(signal?: AbortSignal): void {
@@ -6,6 +8,7 @@ export function throwIfAborted(signal?: AbortSignal): void {
 
 export function isUserAbortError(error: unknown, signal?: AbortSignal): boolean {
   if (signal?.aborted) return true
+  if (isTauriInvalidResourceIdError(error)) return true
   if (!(error instanceof Error)) return false
   if (error.message === USER_ABORT_MESSAGE) return true
   if (error.name === "AbortError") return true