From a52bdde1f77d6e66b28bd1e94091b314ebab3fce Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 15:46:00 +0900 Subject: [PATCH 1/5] =?UTF-8?q?=E2=9C=A8=20=E4=B8=BA=20GM.download/GM=5Fdo?= =?UTF-8?q?wnload=20=E5=AE=9E=E7=8E=B0=20onloadend=20=E5=9B=9E=E8=B0=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对齐 GM_xmlhttpRequest 的 onloadend 语义:无论下载以 onload/save_cancelled/ ontimeout/onerror 中的哪一种结束,onloadend 都会紧随其后触发一次,覆盖 browser 与 native 两种 downloadMode 的所有终态分支。 Co-Authored-By: Claude Sonnet 5 --- src/app/service/content/gm_api/gm_api.ts | 13 ++ .../content/gm_api/gm_download.test.ts | 160 ++++++++++++++++++ src/types/scriptcat.d.ts | 2 + src/types/scriptcat.zh-CN.d.ts | 2 + 4 files changed, 177 insertions(+) create mode 100644 src/app/service/content/gm_api/gm_download.test.ts diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 945d5e936..1843db9a9 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -1027,10 +1027,12 @@ export default class GMApi extends GM_Base { switch (data.action) { case "onload": details.onload?.(makeCallbackParam({ ...data.data })); + details.onloadend?.(makeCallbackParam({ ...data.data })); retPromiseResolve?.(data.data); break; case "save_cancelled": // saveAs cancelled by user,TM 视为下载成功 details.onload?.(makeCallbackParam({ ...data.data })); + details.onloadend?.(makeCallbackParam({ ...data.data })); retPromiseResolve?.(data.data); break; case "onprogress": @@ -1039,16 +1041,19 @@ export default class GMApi extends GM_Base { break; case "ontimeout": details.ontimeout?.(makeCallbackParam({})); + details.onloadend?.(makeCallbackParam({})); retPromiseReject?.(new Error("Timeout ERROR")); break; case "onerror": details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError); + details.onloadend?.(makeCallbackParam({ error: "unknown" })); retPromiseReject?.(new Error("Unknown ERROR")); break; default: LoggerCore.logger().warn("GM_download resp is error", { data, }); + details.onloadend?.(makeCallbackParam({})); retPromiseReject?.(new Error("Unexpected Internal ERROR")); break; } @@ -1100,6 +1105,7 @@ export default class GMApi extends GM_Base { releaseResources(); if (!aborted) { details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError); + details.onloadend?.(makeCallbackParam({ error: "unknown" })); retPromiseReject?.(e instanceof Error ? e : new Error("GM_download connect ERROR")); } return; @@ -1121,21 +1127,25 @@ export default class GMApi extends GM_Base { switch (data.action) { case "onload": details.onload?.(makeCallbackParam({ ...data.data })); + details.onloadend?.(makeCallbackParam({ ...data.data })); retPromiseResolve?.(data.data); releaseResources(); break; case "save_cancelled": // saveAs cancelled by user,TM 视为下载成功 details.onload?.(makeCallbackParam({ ...data.data })); + details.onloadend?.(makeCallbackParam({ ...data.data })); retPromiseResolve?.(data.data); releaseResources(); break; case "ontimeout": details.ontimeout?.(makeCallbackParam({})); + details.onloadend?.(makeCallbackParam({})); retPromiseReject?.(new Error("Timeout ERROR")); releaseResources(); break; case "onerror": details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError); + details.onloadend?.(makeCallbackParam({ error: "unknown" })); retPromiseReject?.(new Error("Unknown ERROR")); releaseResources(); break; @@ -1143,6 +1153,7 @@ export default class GMApi extends GM_Base { LoggerCore.logger().warn("GM_download resp is error", { data, }); + details.onloadend?.(makeCallbackParam({})); retPromiseReject?.(new Error("Unexpected Internal ERROR")); releaseResources(); break; @@ -1163,9 +1174,11 @@ export default class GMApi extends GM_Base { }, ontimeout: () => { details.ontimeout?.(makeCallbackParam({})); + details.onloadend?.(makeCallbackParam({})); }, onerror: () => { details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError); + details.onloadend?.(makeCallbackParam({ error: "unknown" })); }, } as GMTypes.XHRDetails; if (typeof details.headers === "object") { diff --git a/src/app/service/content/gm_api/gm_download.test.ts b/src/app/service/content/gm_api/gm_download.test.ts new file mode 100644 index 000000000..ef61983da --- /dev/null +++ b/src/app/service/content/gm_api/gm_download.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it, vi } from "vitest"; +import type { MessageConnect } from "@Packages/message/types"; +import GMApi from "./gm_api"; + +vi.mock("./gm_xhr", async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + GM_xmlhttpRequest: vi.fn((_a: unknown, xhrParams: any) => { + queueMicrotask(() => { + xhrParams.onerror?.(); + }); + return { retPromise: Promise.resolve(), abort: vi.fn() }; + }), + }; +}); + +async function flushMicrotasks(times = 10) { + for (let i = 0; i < times; i++) { + await Promise.resolve(); + } +} + +function createFakeConnect() { + let messageHandler: ((data: any) => void) | undefined; + const conn = { + onMessage(cb: (data: any) => void) { + messageHandler = cb; + }, + sendMessage: vi.fn(), + disconnect: vi.fn(), + onDisconnect: vi.fn(), + } as unknown as MessageConnect; + return { + conn, + emit(data: any) { + messageHandler?.(data); + }, + }; +} + +function createFakeA(conn: MessageConnect) { + return { + isInvalidContext: () => false, + connect: vi.fn().mockResolvedValue(conn), + }; +} + +describe.concurrent("GM_download onloadend", () => { + it.concurrent("downloadMode=browser:onload 触发后应同时调用 onloadend,且携带相同数据", async () => { + const { conn, emit } = createFakeConnect(); + const fakeA = createFakeA(conn); + const onload = vi.fn(); + const onloadend = vi.fn(); + const details: GMTypes.DownloadDetails = { + url: "https://example.com/a.zip", + name: "a.zip", + downloadMode: "browser", + onload, + onloadend, + }; + GMApi._GM_download(fakeA as any, details, false); + await flushMicrotasks(); + + const payload = { loaded: 10, total: 10, mode: "native" }; + emit({ action: "onload", data: payload }); + + expect(onload).toHaveBeenCalledTimes(1); + expect(onloadend).toHaveBeenCalledTimes(1); + expect(onloadend).toHaveBeenCalledWith(expect.objectContaining(payload)); + }); + + it.concurrent( + "downloadMode=browser:save_cancelled 触发后应同时调用 onload 与 onloadend(TM 视为成功)", + async () => { + const { conn, emit } = createFakeConnect(); + const fakeA = createFakeA(conn); + const onload = vi.fn(); + const onloadend = vi.fn(); + const details: GMTypes.DownloadDetails = { + url: "https://example.com/a.zip", + name: "a.zip", + downloadMode: "browser", + onload, + onloadend, + }; + GMApi._GM_download(fakeA as any, details, false); + await flushMicrotasks(); + + emit({ action: "save_cancelled", data: { loaded: 5, total: 10 } }); + + expect(onload).toHaveBeenCalledTimes(1); + expect(onloadend).toHaveBeenCalledTimes(1); + } + ); + + it.concurrent("downloadMode=browser:ontimeout 触发后应同时调用 ontimeout 与 onloadend", async () => { + const { conn, emit } = createFakeConnect(); + const fakeA = createFakeA(conn); + const ontimeout = vi.fn(); + const onloadend = vi.fn(); + const details: GMTypes.DownloadDetails = { + url: "https://example.com/a.zip", + name: "a.zip", + downloadMode: "browser", + ontimeout, + onloadend, + }; + GMApi._GM_download(fakeA as any, details, false); + await flushMicrotasks(); + + emit({ action: "ontimeout" }); + + expect(ontimeout).toHaveBeenCalledTimes(1); + expect(onloadend).toHaveBeenCalledTimes(1); + }); + + it.concurrent("downloadMode=browser:onerror 触发后应同时调用 onerror 与 onloadend", async () => { + const { conn, emit } = createFakeConnect(); + const fakeA = createFakeA(conn); + const onerror = vi.fn(); + const onloadend = vi.fn(); + const details: GMTypes.DownloadDetails = { + url: "https://example.com/a.zip", + name: "a.zip", + downloadMode: "browser", + onerror, + onloadend, + }; + GMApi._GM_download(fakeA as any, details, false); + await flushMicrotasks(); + + emit({ action: "onerror" }); + + expect(onerror).toHaveBeenCalledTimes(1); + expect(onloadend).toHaveBeenCalledTimes(1); + }); + + it.concurrent("downloadMode=native:xhr 阶段 onerror(未取得 blob)应同时调用 onerror 与 onloadend", async () => { + const fakeA = { + isInvalidContext: () => false, + connect: vi.fn(), + }; + const onerror = vi.fn(); + const onloadend = vi.fn(); + const details: GMTypes.DownloadDetails = { + url: "https://example.com/a.zip", + name: "a.zip", + downloadMode: "native", + onerror, + onloadend, + }; + GMApi._GM_download(fakeA as any, details, false); + await flushMicrotasks(); + + expect(onerror).toHaveBeenCalledTimes(1); + expect(onloadend).toHaveBeenCalledTimes(1); + expect(fakeA.connect).not.toHaveBeenCalled(); + }); +}); diff --git a/src/types/scriptcat.d.ts b/src/types/scriptcat.d.ts index 85a45b9d0..b0767365f 100644 --- a/src/types/scriptcat.d.ts +++ b/src/types/scriptcat.d.ts @@ -735,6 +735,8 @@ declare namespace GMTypes { totalSize: number; }>; ontimeout?: (arg1?: any) => void; + /** Fires after onload/onerror/ontimeout, once the download has finished either way. */ + onloadend?: Listener; } interface NotificationThis extends NotificationDetails { diff --git a/src/types/scriptcat.zh-CN.d.ts b/src/types/scriptcat.zh-CN.d.ts index 4c99999cb..4c8eb5a6a 100644 --- a/src/types/scriptcat.zh-CN.d.ts +++ b/src/types/scriptcat.zh-CN.d.ts @@ -741,6 +741,8 @@ declare namespace GMTypes { totalSize: number; }>; ontimeout?: (arg1?: any) => void; + /** 下载结束时触发(无论 onload/onerror/ontimeout 中的哪一个),在其之后调用。 */ + onloadend?: Listener; } interface NotificationThis extends NotificationDetails { From 48f204bf6a81b88c401615fbce961b776d0b02ac Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 22:43:42 +0900 Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=20GM.down?= =?UTF-8?q?load/GM=5Fdownload=20onloadend=20=E7=9A=84=E5=9B=9B=E5=A4=84?= =?UTF-8?q?=E9=81=97=E7=95=99=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - native 模式下 xhr 因 ontimeout/onerror 失败后,底层实现仍会在其 onloadend 带上已收数据拼出的 Blob;补充 xhrFailed 标志位拦截,避免把部分数据当作 下载成功经 chrome.downloads 落盘为被截断的文件,且避免用户 onloadend 被 触发两次。 - browser 模式下 a.connect() 失败此前未被捕获,导致 GM.download 返回的 promise 永远 pending;补上与 native 模式一致的 try/catch,通过 onerror/ onloadend/reject 通知调用方。 - 所有终态分支(onload/save_cancelled/ontimeout/onerror/default)新增 try/finally,防止用户 onloadend 抛错时跳过 retPromiseResolve/Reject 与 releaseResources(避免 promise 悬挂、blob URL 泄漏)。 - 补上 src/template/scriptcat.d.tpl 中 DownloadDetails.onloadend 声明, 修复内置编辑器仍将 onloadend 报告为未知属性的问题。 新增 6 个回归测试覆盖上述场景。 Co-Authored-By: Claude Sonnet 5 --- src/app/service/content/gm_api/gm_api.ts | 156 ++++++++++------ .../content/gm_api/gm_download.test.ts | 170 ++++++++++++++++++ src/template/scriptcat.d.tpl | 2 + 3 files changed, 277 insertions(+), 51 deletions(-) diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 1843db9a9..55ea692f5 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -1007,65 +1007,99 @@ export default class GMApi extends GM_Base { // ignored } } - const con = await a.connect("GM_download", [ - { - method: details.method, - downloadMode: "browser", // 默认使用xhr下载 - url: url as string, - name: details.name, - headers: details.headers, - saveAs: details.saveAs, - conflictAction: details.conflictAction, - timeout: details.timeout, - cookie: details.cookie, - anonymous: details.anonymous, - } as GMTypes.DownloadDetails, - ]); + let con: MessageConnect; + try { + con = await a.connect("GM_download", [ + { + method: details.method, + downloadMode: "browser", // 默认使用xhr下载 + url: url as string, + name: details.name, + headers: details.headers, + saveAs: details.saveAs, + conflictAction: details.conflictAction, + timeout: details.timeout, + cookie: details.cookie, + anonymous: details.anonymous, + } as GMTypes.DownloadDetails, + ]); + } catch (e) { + // 后台连接失败:通过 onerror / reject 通知调用方,行为与 “onMessage 收到 onerror” 一致, + // 否则 GM.download 的 promise 会永远 pending(issue: 无 native XHR 后备路径可兜底)。 + if (!aborted) { + try { + details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError); + details.onloadend?.(makeCallbackParam({ error: "unknown" })); + } finally { + retPromiseReject?.(e instanceof Error ? e : new Error("GM_download connect ERROR")); + } + } + return; + } if (aborted) return; connect = con; connect.onMessage((data) => { switch (data.action) { case "onload": - details.onload?.(makeCallbackParam({ ...data.data })); - details.onloadend?.(makeCallbackParam({ ...data.data })); - retPromiseResolve?.(data.data); + try { + details.onload?.(makeCallbackParam({ ...data.data })); + details.onloadend?.(makeCallbackParam({ ...data.data })); + } finally { + retPromiseResolve?.(data.data); + } break; case "save_cancelled": // saveAs cancelled by user,TM 视为下载成功 - details.onload?.(makeCallbackParam({ ...data.data })); - details.onloadend?.(makeCallbackParam({ ...data.data })); - retPromiseResolve?.(data.data); + try { + details.onload?.(makeCallbackParam({ ...data.data })); + details.onloadend?.(makeCallbackParam({ ...data.data })); + } finally { + retPromiseResolve?.(data.data); + } break; case "onprogress": details.onprogress?.(makeCallbackParam({ ...data.data, mode: "browser" })); retPromiseReject?.(new Error("Timeout ERROR")); break; case "ontimeout": - details.ontimeout?.(makeCallbackParam({})); - details.onloadend?.(makeCallbackParam({})); - retPromiseReject?.(new Error("Timeout ERROR")); + try { + details.ontimeout?.(makeCallbackParam({})); + details.onloadend?.(makeCallbackParam({})); + } finally { + retPromiseReject?.(new Error("Timeout ERROR")); + } break; case "onerror": - details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError); - details.onloadend?.(makeCallbackParam({ error: "unknown" })); - retPromiseReject?.(new Error("Unknown ERROR")); + try { + details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError); + details.onloadend?.(makeCallbackParam({ error: "unknown" })); + } finally { + retPromiseReject?.(new Error("Unknown ERROR")); + } break; default: LoggerCore.logger().warn("GM_download resp is error", { data, }); - details.onloadend?.(makeCallbackParam({})); - retPromiseReject?.(new Error("Unexpected Internal ERROR")); + try { + details.onloadend?.(makeCallbackParam({})); + } finally { + retPromiseReject?.(new Error("Unexpected Internal ERROR")); + } break; } }); } else { // native + // xhr 已因 ontimeout/onerror 失败:即使失败前已收到部分数据,底层 XHR 实现仍会在其 + // onloadend 中带上由已收数据拼出的 Blob。必须以此标志位拦截,否则会把这段部分数据 + // 当作下载成功,误经 chrome.downloads 落盘为被截断的文件。 + let xhrFailed = false; const xhrParams = { url: url, fetch: true, // 跟随TM使用 fetch; 使用 fetch 避免 1) 大量数据存放offscreen xhr 2) vivaldi offscreen client block responseType: "blob", onloadend: async (res) => { - if (aborted) return; + if (aborted || xhrFailed) return; const response = res.response; if (!(response instanceof Blob)) return; @@ -1104,9 +1138,12 @@ export default class GMApi extends GM_Base { // 行为与 “onMessage 收到 onerror” 一致,保持外层 contract 不变。 releaseResources(); if (!aborted) { - details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError); - details.onloadend?.(makeCallbackParam({ error: "unknown" })); - retPromiseReject?.(e instanceof Error ? e : new Error("GM_download connect ERROR")); + try { + details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError); + details.onloadend?.(makeCallbackParam({ error: "unknown" })); + } finally { + retPromiseReject?.(e instanceof Error ? e : new Error("GM_download connect ERROR")); + } } return; } @@ -1126,36 +1163,51 @@ export default class GMApi extends GM_Base { connect.onMessage((data) => { switch (data.action) { case "onload": - details.onload?.(makeCallbackParam({ ...data.data })); - details.onloadend?.(makeCallbackParam({ ...data.data })); - retPromiseResolve?.(data.data); - releaseResources(); + try { + details.onload?.(makeCallbackParam({ ...data.data })); + details.onloadend?.(makeCallbackParam({ ...data.data })); + } finally { + retPromiseResolve?.(data.data); + releaseResources(); + } break; case "save_cancelled": // saveAs cancelled by user,TM 视为下载成功 - details.onload?.(makeCallbackParam({ ...data.data })); - details.onloadend?.(makeCallbackParam({ ...data.data })); - retPromiseResolve?.(data.data); - releaseResources(); + try { + details.onload?.(makeCallbackParam({ ...data.data })); + details.onloadend?.(makeCallbackParam({ ...data.data })); + } finally { + retPromiseResolve?.(data.data); + releaseResources(); + } break; case "ontimeout": - details.ontimeout?.(makeCallbackParam({})); - details.onloadend?.(makeCallbackParam({})); - retPromiseReject?.(new Error("Timeout ERROR")); - releaseResources(); + try { + details.ontimeout?.(makeCallbackParam({})); + details.onloadend?.(makeCallbackParam({})); + } finally { + retPromiseReject?.(new Error("Timeout ERROR")); + releaseResources(); + } break; case "onerror": - details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError); - details.onloadend?.(makeCallbackParam({ error: "unknown" })); - retPromiseReject?.(new Error("Unknown ERROR")); - releaseResources(); + try { + details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError); + details.onloadend?.(makeCallbackParam({ error: "unknown" })); + } finally { + retPromiseReject?.(new Error("Unknown ERROR")); + releaseResources(); + } break; default: LoggerCore.logger().warn("GM_download resp is error", { data, }); - details.onloadend?.(makeCallbackParam({})); - retPromiseReject?.(new Error("Unexpected Internal ERROR")); - releaseResources(); + try { + details.onloadend?.(makeCallbackParam({})); + } finally { + retPromiseReject?.(new Error("Unexpected Internal ERROR")); + releaseResources(); + } break; } }); @@ -1173,10 +1225,12 @@ export default class GMApi extends GM_Base { details.onprogress?.(makeCallbackParam({ ...e, mode: "native" })); }, ontimeout: () => { + xhrFailed = true; details.ontimeout?.(makeCallbackParam({})); details.onloadend?.(makeCallbackParam({})); }, onerror: () => { + xhrFailed = true; details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError); details.onloadend?.(makeCallbackParam({ error: "unknown" })); }, diff --git a/src/app/service/content/gm_api/gm_download.test.ts b/src/app/service/content/gm_api/gm_download.test.ts index ef61983da..0c05404f3 100644 --- a/src/app/service/content/gm_api/gm_download.test.ts +++ b/src/app/service/content/gm_api/gm_download.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import type { MessageConnect } from "@Packages/message/types"; import GMApi from "./gm_api"; +import { GM_xmlhttpRequest } from "./gm_xhr"; vi.mock("./gm_xhr", async (importOriginal) => { const actual = await importOriginal>(); @@ -158,3 +159,172 @@ describe.concurrent("GM_download onloadend", () => { expect(fakeA.connect).not.toHaveBeenCalled(); }); }); + +// 非 concurrent:以下用例通过 mockImplementationOnce 定制 GM_xmlhttpRequest 的行为, +// 与 describe.concurrent 中并发触发的默认 mock 共享同一队列会有竞态,因此单独放在顺序执行的 describe 中。 +describe("GM_download 补充回归测试(native 部分下载 / browser connect 失败 / onloadend 抛错)", () => { + it("native 模式:onerror 后 xhr onloadend 携带非空 Blob,不应触发浏览器下载", async () => { + const fakeA = { + isInvalidContext: () => false, + connect: vi.fn(), + }; + const onerror = vi.fn(); + const onloadend = vi.fn(); + vi.mocked(GM_xmlhttpRequest).mockImplementationOnce((_a: unknown, xhrParams: any) => { + queueMicrotask(() => { + xhrParams.onerror?.(); + xhrParams.onloadend?.({ response: new Blob(["partial data"]) }); + }); + return { retPromise: Promise.resolve(), abort: vi.fn() }; + }); + const details: GMTypes.DownloadDetails = { + url: "https://example.com/a.zip", + name: "a.zip", + downloadMode: "native", + onerror, + onloadend, + }; + GMApi._GM_download(fakeA as any, details, false); + await flushMicrotasks(); + + expect(onerror).toHaveBeenCalledTimes(1); + expect(onloadend).toHaveBeenCalledTimes(1); + expect(fakeA.connect).not.toHaveBeenCalled(); + }); + + it("native 模式:ontimeout 后 xhr onloadend 携带非空 Blob,不应触发浏览器下载", async () => { + const fakeA = { + isInvalidContext: () => false, + connect: vi.fn(), + }; + const ontimeout = vi.fn(); + const onloadend = vi.fn(); + vi.mocked(GM_xmlhttpRequest).mockImplementationOnce((_a: unknown, xhrParams: any) => { + queueMicrotask(() => { + xhrParams.ontimeout?.(); + xhrParams.onloadend?.({ response: new Blob(["partial data"]) }); + }); + return { retPromise: Promise.resolve(), abort: vi.fn() }; + }); + const details: GMTypes.DownloadDetails = { + url: "https://example.com/a.zip", + name: "a.zip", + downloadMode: "native", + ontimeout, + onloadend, + }; + GMApi._GM_download(fakeA as any, details, false); + await flushMicrotasks(); + + expect(ontimeout).toHaveBeenCalledTimes(1); + expect(onloadend).toHaveBeenCalledTimes(1); + expect(fakeA.connect).not.toHaveBeenCalled(); + }); + + it("downloadMode=browser:a.connect 失败应触发 onerror/onloadend 并 reject retPromise", async () => { + const onerror = vi.fn(); + const onloadend = vi.fn(); + const connectError = new Error("connect failed"); + const fakeA = { + isInvalidContext: () => false, + connect: vi.fn().mockRejectedValue(connectError), + }; + const details: GMTypes.DownloadDetails = { + url: "https://example.com/a.zip", + name: "a.zip", + downloadMode: "browser", + onerror, + onloadend, + }; + const { retPromise } = GMApi._GM_download(fakeA as any, details, true); + await flushMicrotasks(); + + expect(onerror).toHaveBeenCalledTimes(1); + expect(onloadend).toHaveBeenCalledTimes(1); + await expect(retPromise).rejects.toBe(connectError); + }); + + it("downloadMode=browser:onload 触发时 onloadend 抛错,不应阻止 retPromise resolve", async () => { + const { conn, emit } = createFakeConnect(); + const fakeA = createFakeA(conn); + const onloadend = vi.fn(() => { + throw new Error("boom"); + }); + const details: GMTypes.DownloadDetails = { + url: "https://example.com/a.zip", + name: "a.zip", + downloadMode: "browser", + onloadend, + }; + const { retPromise } = GMApi._GM_download(fakeA as any, details, true); + await flushMicrotasks(); + + const payload = { loaded: 10, total: 10 }; + expect(() => emit({ action: "onload", data: payload })).toThrow("boom"); + + await expect(retPromise).resolves.toEqual(expect.objectContaining(payload)); + }); + + it("downloadMode=native:下载成功后 onloadend 抛错,不应阻止 retPromise resolve 与 releaseResources(revokeObjectURL)", async () => { + const revokeSpy = vi.spyOn(URL, "revokeObjectURL"); + const { conn, emit } = createFakeConnect(); + const fakeA = createFakeA(conn); + vi.mocked(GM_xmlhttpRequest).mockImplementationOnce((_a: unknown, xhrParams: any) => { + queueMicrotask(() => { + xhrParams.onloadend?.({ response: new Blob(["full data"]) }); + }); + return { retPromise: Promise.resolve(), abort: vi.fn() }; + }); + const onloadend = vi.fn(() => { + throw new Error("boom"); + }); + const details: GMTypes.DownloadDetails = { + url: "https://example.com/a.zip", + name: "a.zip", + downloadMode: "native", + onloadend, + }; + const { retPromise } = GMApi._GM_download(fakeA as any, details, true); + await flushMicrotasks(); + + const payload = { loaded: 10, total: 10 }; + expect(() => emit({ action: "onload", data: payload })).toThrow("boom"); + await expect(retPromise).resolves.toEqual(payload); + + await new Promise((r) => setTimeout(r, 5)); + expect(revokeSpy).toHaveBeenCalled(); + }); + + it("downloadMode=native:完整成功流程只触发一次 onload/onloadend,并释放 blob URL", async () => { + const revokeSpy = vi.spyOn(URL, "revokeObjectURL"); + const { conn, emit } = createFakeConnect(); + const fakeA = createFakeA(conn); + vi.mocked(GM_xmlhttpRequest).mockImplementationOnce((_a: unknown, xhrParams: any) => { + queueMicrotask(() => { + xhrParams.onloadend?.({ response: new Blob(["full data"]) }); + }); + return { retPromise: Promise.resolve(), abort: vi.fn() }; + }); + const onload = vi.fn(); + const onloadend = vi.fn(); + const details: GMTypes.DownloadDetails = { + url: "https://example.com/a.zip", + name: "a.zip", + downloadMode: "native", + onload, + onloadend, + }; + GMApi._GM_download(fakeA as any, details, false); + await flushMicrotasks(); + + expect(fakeA.connect).toHaveBeenCalledTimes(1); + const payload = { loaded: 20, total: 20 }; + emit({ action: "onload", data: payload }); + + expect(onload).toHaveBeenCalledTimes(1); + expect(onloadend).toHaveBeenCalledTimes(1); + + await new Promise((r) => setTimeout(r, 5)); + expect(revokeSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/template/scriptcat.d.tpl b/src/template/scriptcat.d.tpl index e89cb2b05..134af07f3 100644 --- a/src/template/scriptcat.d.tpl +++ b/src/template/scriptcat.d.tpl @@ -690,6 +690,8 @@ declare namespace GMTypes { totalSize: number; }>; ontimeout?: (arg1?: any) => void; + // 下载结束时触发(无论 onload/onerror/ontimeout 中的哪一个),在其之后调用。 + onloadend?: Listener; } interface NotificationThis extends NotificationDetails { From d67f672358e5d669b717cdaf53ffd6d06c839282 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:07:25 +0900 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=20GM.down?= =?UTF-8?q?load=20onloadend=20=E4=B8=BB=E5=9B=9E=E8=B0=83=E6=8A=9B?= =?UTF-8?q?=E9=94=99=E6=8A=91=E5=88=B6=20loadend=20=E4=B8=8E=20abort=20?= =?UTF-8?q?=E7=AB=9E=E6=80=81=E4=B8=A4=E5=A4=84=E9=81=97=E7=95=99=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 withLoadEnd 共享辅助函数,将「主回调」(onload/onerror/ontimeout) 与 onloadend 分别置于内层 try/finally:此前两者共享同一个 try 区块,一旦 主回调抛错会直接跳到外层 finally,导致 onloadend 从未被调用,违反其 “总是紧随主回调之后触发”的约定。该辅助函数应用于 browser 模式、native 模式浏览器下载阶段的所有终态分支,以及 native 模式 xhr 阶段的 ontimeout/onerror。 - native 模式 xhr 阶段的 ontimeout/onerror 新增无条件的 retPromiseReject 调用(置于 finally 内,不再依赖 GM_xmlhttpRequest 内部 retPromise 延后 reject 再经由 `if (aborted) return` 转发)。此前若使用者在 onloadend 回调内呼叫 download.abort(),aborted 旗标会被提前设为 true,导致延后的 reject 被短路跳过,GM.download 返回的 promise 永久 pending。Promise 的 resolve/reject 本身具幂等性,早/晚两处调用互不冲突。 新增 2 个回归测试:browser 模式 onerror 主回调抛错、native 模式 xhr 阶段 失败后于 onloadend 内呼叫 abort()。均已验证在修复前会失败(前者断言失败, 后者测试超时挂起),修复后转为通过。 Co-Authored-By: Claude Sonnet 5 --- src/app/service/content/gm_api/gm_api.ts | 94 ++++++++++++++----- .../content/gm_api/gm_download.test.ts | 50 ++++++++++ 2 files changed, 120 insertions(+), 24 deletions(-) diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 55ea692f5..9990a2dd2 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -991,6 +991,15 @@ export default class GMApi extends GM_Base { } return retParam as K; }; + // 保证「主回调」(onload/onerror/ontimeout) 与 onloadend 各自独立执行: + // 若主回调抛错,onloadend 仍必须照常触发,反之亦然。 + const withLoadEnd = (primary: () => void, loadend: () => void) => { + try { + primary(); + } finally { + loadend(); + } + }; const handle = async () => { const url = await urlPromiseLike; const downloadMode = details.downloadMode || "native"; // native = sc_default; browser = chrome api @@ -1028,8 +1037,10 @@ export default class GMApi extends GM_Base { // 否则 GM.download 的 promise 会永远 pending(issue: 无 native XHR 后备路径可兜底)。 if (!aborted) { try { - details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError); - details.onloadend?.(makeCallbackParam({ error: "unknown" })); + withLoadEnd( + () => details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError), + () => details.onloadend?.(makeCallbackParam({ error: "unknown" })) + ); } finally { retPromiseReject?.(e instanceof Error ? e : new Error("GM_download connect ERROR")); } @@ -1042,16 +1053,20 @@ export default class GMApi extends GM_Base { switch (data.action) { case "onload": try { - details.onload?.(makeCallbackParam({ ...data.data })); - details.onloadend?.(makeCallbackParam({ ...data.data })); + withLoadEnd( + () => details.onload?.(makeCallbackParam({ ...data.data })), + () => details.onloadend?.(makeCallbackParam({ ...data.data })) + ); } finally { retPromiseResolve?.(data.data); } break; case "save_cancelled": // saveAs cancelled by user,TM 视为下载成功 try { - details.onload?.(makeCallbackParam({ ...data.data })); - details.onloadend?.(makeCallbackParam({ ...data.data })); + withLoadEnd( + () => details.onload?.(makeCallbackParam({ ...data.data })), + () => details.onloadend?.(makeCallbackParam({ ...data.data })) + ); } finally { retPromiseResolve?.(data.data); } @@ -1062,16 +1077,20 @@ export default class GMApi extends GM_Base { break; case "ontimeout": try { - details.ontimeout?.(makeCallbackParam({})); - details.onloadend?.(makeCallbackParam({})); + withLoadEnd( + () => details.ontimeout?.(makeCallbackParam({})), + () => details.onloadend?.(makeCallbackParam({})) + ); } finally { retPromiseReject?.(new Error("Timeout ERROR")); } break; case "onerror": try { - details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError); - details.onloadend?.(makeCallbackParam({ error: "unknown" })); + withLoadEnd( + () => details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError), + () => details.onloadend?.(makeCallbackParam({ error: "unknown" })) + ); } finally { retPromiseReject?.(new Error("Unknown ERROR")); } @@ -1139,8 +1158,10 @@ export default class GMApi extends GM_Base { releaseResources(); if (!aborted) { try { - details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError); - details.onloadend?.(makeCallbackParam({ error: "unknown" })); + withLoadEnd( + () => details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError), + () => details.onloadend?.(makeCallbackParam({ error: "unknown" })) + ); } finally { retPromiseReject?.(e instanceof Error ? e : new Error("GM_download connect ERROR")); } @@ -1164,8 +1185,10 @@ export default class GMApi extends GM_Base { switch (data.action) { case "onload": try { - details.onload?.(makeCallbackParam({ ...data.data })); - details.onloadend?.(makeCallbackParam({ ...data.data })); + withLoadEnd( + () => details.onload?.(makeCallbackParam({ ...data.data })), + () => details.onloadend?.(makeCallbackParam({ ...data.data })) + ); } finally { retPromiseResolve?.(data.data); releaseResources(); @@ -1173,8 +1196,10 @@ export default class GMApi extends GM_Base { break; case "save_cancelled": // saveAs cancelled by user,TM 视为下载成功 try { - details.onload?.(makeCallbackParam({ ...data.data })); - details.onloadend?.(makeCallbackParam({ ...data.data })); + withLoadEnd( + () => details.onload?.(makeCallbackParam({ ...data.data })), + () => details.onloadend?.(makeCallbackParam({ ...data.data })) + ); } finally { retPromiseResolve?.(data.data); releaseResources(); @@ -1182,8 +1207,10 @@ export default class GMApi extends GM_Base { break; case "ontimeout": try { - details.ontimeout?.(makeCallbackParam({})); - details.onloadend?.(makeCallbackParam({})); + withLoadEnd( + () => details.ontimeout?.(makeCallbackParam({})), + () => details.onloadend?.(makeCallbackParam({})) + ); } finally { retPromiseReject?.(new Error("Timeout ERROR")); releaseResources(); @@ -1191,8 +1218,10 @@ export default class GMApi extends GM_Base { break; case "onerror": try { - details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError); - details.onloadend?.(makeCallbackParam({ error: "unknown" })); + withLoadEnd( + () => details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError), + () => details.onloadend?.(makeCallbackParam({ error: "unknown" })) + ); } finally { retPromiseReject?.(new Error("Unknown ERROR")); releaseResources(); @@ -1226,13 +1255,30 @@ export default class GMApi extends GM_Base { }, ontimeout: () => { xhrFailed = true; - details.ontimeout?.(makeCallbackParam({})); - details.onloadend?.(makeCallbackParam({})); + try { + withLoadEnd( + () => details.ontimeout?.(makeCallbackParam({})), + () => details.onloadend?.(makeCallbackParam({})) + ); + } finally { + // 必须在此直接 settle:若使用者在 onloadend 内呼叫 abort(), + // 只会设置 aborted 旗标,不代表这次下载没有失败。原本仅靠 + // GM_xmlhttpRequest 内部 retPromise 之后才 reject 的路径, + // 会被外层 `if (aborted) return` 短路,导致 GM.download 的 + // promise 永久 pending。settle 具幂等性,重复调用是安全的。 + retPromiseReject?.(new Error("Native Download ERROR")); + } }, onerror: () => { xhrFailed = true; - details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError); - details.onloadend?.(makeCallbackParam({ error: "unknown" })); + try { + withLoadEnd( + () => details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError), + () => details.onloadend?.(makeCallbackParam({ error: "unknown" })) + ); + } finally { + retPromiseReject?.(new Error("Native Download ERROR")); + } }, } as GMTypes.XHRDetails; if (typeof details.headers === "object") { diff --git a/src/app/service/content/gm_api/gm_download.test.ts b/src/app/service/content/gm_api/gm_download.test.ts index 0c05404f3..61993afb8 100644 --- a/src/app/service/content/gm_api/gm_download.test.ts +++ b/src/app/service/content/gm_api/gm_download.test.ts @@ -327,4 +327,54 @@ describe("GM_download 补充回归测试(native 部分下载 / browser connect await new Promise((r) => setTimeout(r, 5)); expect(revokeSpy).toHaveBeenCalled(); }); + + it("downloadMode=browser:onerror 主回调抛错,仍应调用 onloadend 并 reject retPromise", async () => { + const { conn, emit } = createFakeConnect(); + const fakeA = createFakeA(conn); + const onerror = vi.fn(() => { + throw new Error("boom"); + }); + const onloadend = vi.fn(); + const details: GMTypes.DownloadDetails = { + url: "https://example.com/a.zip", + name: "a.zip", + downloadMode: "browser", + onerror, + onloadend, + }; + const { retPromise } = GMApi._GM_download(fakeA as any, details, true); + await flushMicrotasks(); + + expect(() => emit({ action: "onerror" })).toThrow("boom"); + expect(onerror).toHaveBeenCalledTimes(1); + expect(onloadend).toHaveBeenCalledTimes(1); + await expect(retPromise).rejects.toThrow("Unknown ERROR"); + }); + + it("native 模式:xhr 阶段失败后于用户 onloadend 内呼叫 abort(),retPromise 仍应 reject 而非永久 pending", async () => { + const fakeA = { + isInvalidContext: () => false, + connect: vi.fn(), + }; + vi.mocked(GM_xmlhttpRequest).mockImplementationOnce((_a: unknown, xhrParams: any) => { + queueMicrotask(() => { + xhrParams.onerror?.(); + }); + // 模拟真实 GM_xmlhttpRequest:内部 retPromise 最终也会 reject(比 xhrParams.onerror 更晚触发)。 + return { retPromise: Promise.reject(new Error("mock xhr error")), abort: vi.fn() }; + }); + const abortHolder: { fn?: () => void } = {}; + const details: GMTypes.DownloadDetails = { + url: "https://example.com/a.zip", + name: "a.zip", + downloadMode: "native", + onloadend: () => { + abortHolder.fn?.(); + }, + }; + const { retPromise, abort } = GMApi._GM_download(fakeA as any, details, true); + abortHolder.fn = abort; + + await expect(retPromise).rejects.toThrow(); + }); }); From edf4d0cce059df0ead8e544286313457239e6e87 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:24:22 +0900 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=20GM.down?= =?UTF-8?q?load=20=E5=8E=9F=E7=94=9F=20XHR=20=E9=98=B6=E6=AE=B5=E7=9A=84?= =?UTF-8?q?=E5=86=85=E9=83=A8=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E9=81=97?= =?UTF-8?q?=E7=95=99=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - xhr 阶段(ontimeout/onerror)改为在呼叫使用者回调前先清空 nativeAbort。 此前若使用者在 onloadend 内呼叫 download.abort(),会经由 nativeAbort() 无条件断开内部 GM_xmlhttpRequest 的消息连线;而该内部请求要到 details.onerror 返回后才会置位 reqDone,其 abort() 又只在提供了 onabort 回调时才合成 loadend——下载场景未提供 onabort,等于内部请求 永远收不到自己的 onloadend,其 retPromise 永久 pending,也无法完成 refCleanup。清空 nativeAbort 后,abort() 中的 nativeAbort?.() 变为 空操作,内部请求得以自然走完生命周期。 - xhr 阶段的使用者回调(含新增的 onloadend)现在被 try/catch 包裹: 例外不再同步向上传播回 GM_xmlhttpRequest 内部的消息处理循环——那里 在 code===-1 协议错误分支会在 details.onerror 返回后才安排合成 onloadend(因为该分支预期不会有真正的 onloadend 消息到达),一旦 同步抛出会跳过这个排程,同样导致内部请求永久卡住。改为透过 queueMicrotask 异步重新抛出,例外仍可被观察到,但不再阻断内部 生命周期收尾。 新增 1 个回归测试并强化既有 abort 竞态测试: - 验证 xhr 阶段失败后于 onloadend 内呼叫 abort(),不会再触发内部 GM_xmlhttpRequest 自身的 abort(即不中断其消息连线)。 - 验证 xhr 阶段使用者回调抛错时,不会同步向上传播,而是透过 queueMicrotask 异步重新抛出。 均已验证在修复前会失败(前者断言失败,后者同步抛出未被捕获), 修复后转为通过。 Co-Authored-By: Claude Sonnet 5 --- src/app/service/content/gm_api/gm_api.ts | 19 ++++++++ .../content/gm_api/gm_download.test.ts | 44 ++++++++++++++++++- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 9990a2dd2..2c9426314 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -1255,11 +1255,25 @@ export default class GMApi extends GM_Base { }, ontimeout: () => { xhrFailed = true; + // XHR 阶段已终结:清空 nativeAbort,避免使用者在 onloadend 内呼叫 + // download.abort() 时经由 nativeAbort() 强制断开内部 XHR 的消息连线 + // (其 abort() 会无条件 disconnect),导致内部 XHR 收不到自己真正的 + // onloadend 消息、其 retPromise 永久 pending、refCleanup 也无法执行。 + // 清空后 abort() 中的 nativeAbort?.() 变为空操作,内部 XHR 可自然收尾。 + nativeAbort = null; try { withLoadEnd( () => details.ontimeout?.(makeCallbackParam({})), () => details.onloadend?.(makeCallbackParam({})) ); + } catch (err) { + // 不能让使用者回调的例外同步传播回 GM_xmlhttpRequest 的内部消息处理循环: + // 那会中断其状态机(跳过 reqDone 置位、协议错误分支下合成 onloadend 的 + // 排程),导致内部请求永久卡住、无法完成清理。改为异步重新抛出,让例外 + // 仍可被观察到(如全局错误上报),但不阻断内部生命周期收尾。 + queueMicrotask(() => { + throw err; + }); } finally { // 必须在此直接 settle:若使用者在 onloadend 内呼叫 abort(), // 只会设置 aborted 旗标,不代表这次下载没有失败。原本仅靠 @@ -1271,11 +1285,16 @@ export default class GMApi extends GM_Base { }, onerror: () => { xhrFailed = true; + nativeAbort = null; try { withLoadEnd( () => details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError), () => details.onloadend?.(makeCallbackParam({ error: "unknown" })) ); + } catch (err) { + queueMicrotask(() => { + throw err; + }); } finally { retPromiseReject?.(new Error("Native Download ERROR")); } diff --git a/src/app/service/content/gm_api/gm_download.test.ts b/src/app/service/content/gm_api/gm_download.test.ts index 61993afb8..78779be0e 100644 --- a/src/app/service/content/gm_api/gm_download.test.ts +++ b/src/app/service/content/gm_api/gm_download.test.ts @@ -351,17 +351,18 @@ describe("GM_download 补充回归测试(native 部分下载 / browser connect await expect(retPromise).rejects.toThrow("Unknown ERROR"); }); - it("native 模式:xhr 阶段失败后于用户 onloadend 内呼叫 abort(),retPromise 仍应 reject 而非永久 pending", async () => { + it("native 模式:xhr 阶段失败后于用户 onloadend 内呼叫 abort(),retPromise 仍应 reject 而非永久 pending,且不应中断内部 XHR", async () => { const fakeA = { isInvalidContext: () => false, connect: vi.fn(), }; + const innerAbort = vi.fn(); vi.mocked(GM_xmlhttpRequest).mockImplementationOnce((_a: unknown, xhrParams: any) => { queueMicrotask(() => { xhrParams.onerror?.(); }); // 模拟真实 GM_xmlhttpRequest:内部 retPromise 最终也会 reject(比 xhrParams.onerror 更晚触发)。 - return { retPromise: Promise.reject(new Error("mock xhr error")), abort: vi.fn() }; + return { retPromise: Promise.reject(new Error("mock xhr error")), abort: innerAbort }; }); const abortHolder: { fn?: () => void } = {}; const details: GMTypes.DownloadDetails = { @@ -376,5 +377,44 @@ describe("GM_download 补充回归测试(native 部分下载 / browser connect abortHolder.fn = abort; await expect(retPromise).rejects.toThrow(); + // XHR 阶段已失败:呼叫外层 abort() 不应再中断内部 XHR(否则内部 XHR 收不到 + // 自己真正的 onloadend 消息,其自身生命周期会永久卡住、无法完成清理)。 + expect(innerAbort).not.toHaveBeenCalled(); + }); + + it("native 模式:xhr 阶段用户回调抛错不应同步向上传播(避免打断 GM_xmlhttpRequest 内部状态机)", async () => { + const fakeA = { + isInvalidContext: () => false, + connect: vi.fn(), + }; + const capturedHandlers: { onerror?: () => void; ontimeout?: () => void } = {}; + vi.mocked(GM_xmlhttpRequest).mockImplementationOnce((_a: unknown, xhrParams: any) => { + capturedHandlers.onerror = xhrParams.onerror; + capturedHandlers.ontimeout = xhrParams.ontimeout; + return { retPromise: Promise.reject(new Error("mock xhr error")), abort: vi.fn() }; + }); + // 拦截 queueMicrotask:避免测试环境中真的抛出未捕获例外,同时保留对其排程内容的断言能力。 + const queueMicrotaskSpy = vi.spyOn(globalThis, "queueMicrotask").mockImplementation(() => {}); + const onerror = vi.fn(() => { + throw new Error("boom"); + }); + const details: GMTypes.DownloadDetails = { + url: "https://example.com/a.zip", + name: "a.zip", + downloadMode: "native", + onerror, + }; + GMApi._GM_download(fakeA as any, details, false); + await flushMicrotasks(); + + // 模拟 GM_xmlhttpRequest 内部(例如 code===-1 协议错误分支)同步呼叫 xhrParams.onerror(): + // 若此处同步抛出,会中断该分支后续的 reqDone 置位与合成 onloadend 排程。 + expect(() => capturedHandlers.onerror?.()).not.toThrow(); + expect(onerror).toHaveBeenCalledTimes(1); + expect(queueMicrotaskSpy).toHaveBeenCalledTimes(1); + const deferredThrow = queueMicrotaskSpy.mock.calls[0][0] as () => void; + expect(deferredThrow).toThrow("boom"); + + queueMicrotaskSpy.mockRestore(); }); }); From 214f0c497c1f8cfdcd037cd1933bfb184e496470 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:49:52 +0900 Subject: [PATCH 5/5] update --- src/app/service/content/gm_api/gm_api.ts | 280 +++++++++-------------- 1 file changed, 109 insertions(+), 171 deletions(-) diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 2c9426314..ddc640e77 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -991,13 +991,83 @@ export default class GMApi extends GM_Base { } return retParam as K; }; - // 保证「主回调」(onload/onerror/ontimeout) 与 onloadend 各自独立执行: - // 若主回调抛错,onloadend 仍必须照常触发,反之亦然。 - const withLoadEnd = (primary: () => void, loadend: () => void) => { + // 保证「主回调」(onload/onerror/ontimeout) 与 onloadend 各自独立执行,且无论是否抛错, + // settle(resolve/reject retPromise,必要时 releaseResources)最终都会被调用一次。 + // onError 用于 native xhr 的 ontimeout/onerror:不能让使用者回调的例外同步传播回 + // GM_xmlhttpRequest 的内部消息处理循环(会中断其状态机,导致内部请求永久卡住), + // 改为异步重新抛出,让例外仍可被观察到(如全局错误上报),但不阻断内部生命周期收尾。 + const withLoadEnd = ( + primary: () => void, + loadendData: Record, + settle: () => void, + onError?: (err: unknown) => void + ) => { try { - primary(); + try { + primary(); + } finally { + details.onloadend?.(makeCallbackParam(loadendData)); + } + } catch (err) { + if (onError) { + onError(err); + } else { + throw err; + } } finally { - loadend(); + settle(); + } + }; + // browser 与 native 两条路径最终都经由后台 GM_download 回传同一组消息(onload/save_cancelled/ + // onerror),此处共用同一份处理逻辑;releaseResources 仅 native 路径需要,browser 路径不传。 + const handleDownloadMessage = (data: { action?: string; data?: any }, releaseResources?: () => void) => { + switch (data.action) { + case "onload": + case "save_cancelled": // saveAs cancelled by user,TM 视为下载成功 + withLoadEnd( + () => details.onload?.(makeCallbackParam({ ...data.data })), + { ...data.data }, + () => { + retPromiseResolve?.(data.data); + releaseResources?.(); + } + ); + break; + case "onprogress": + details.onprogress?.(makeCallbackParam({ ...data.data, mode: "browser" })); + retPromiseReject?.(new Error("Timeout ERROR")); + break; + case "ontimeout": + withLoadEnd( + () => details.ontimeout?.(makeCallbackParam({})), + {}, + () => { + retPromiseReject?.(new Error("Timeout ERROR")); + releaseResources?.(); + } + ); + break; + case "onerror": + withLoadEnd( + () => details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError), + { error: "unknown" }, + () => { + retPromiseReject?.(new Error("Unknown ERROR")); + releaseResources?.(); + } + ); + break; + default: + LoggerCore.logger().warn("GM_download resp is error", { data }); + withLoadEnd( + () => {}, + {}, + () => { + retPromiseReject?.(new Error("Unexpected Internal ERROR")); + releaseResources?.(); + } + ); + break; } }; const handle = async () => { @@ -1036,77 +1106,17 @@ export default class GMApi extends GM_Base { // 后台连接失败:通过 onerror / reject 通知调用方,行为与 “onMessage 收到 onerror” 一致, // 否则 GM.download 的 promise 会永远 pending(issue: 无 native XHR 后备路径可兜底)。 if (!aborted) { - try { - withLoadEnd( - () => details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError), - () => details.onloadend?.(makeCallbackParam({ error: "unknown" })) - ); - } finally { - retPromiseReject?.(e instanceof Error ? e : new Error("GM_download connect ERROR")); - } + withLoadEnd( + () => details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError), + { error: "unknown" }, + () => retPromiseReject?.(e instanceof Error ? e : new Error("GM_download connect ERROR")) + ); } return; } if (aborted) return; connect = con; - connect.onMessage((data) => { - switch (data.action) { - case "onload": - try { - withLoadEnd( - () => details.onload?.(makeCallbackParam({ ...data.data })), - () => details.onloadend?.(makeCallbackParam({ ...data.data })) - ); - } finally { - retPromiseResolve?.(data.data); - } - break; - case "save_cancelled": // saveAs cancelled by user,TM 视为下载成功 - try { - withLoadEnd( - () => details.onload?.(makeCallbackParam({ ...data.data })), - () => details.onloadend?.(makeCallbackParam({ ...data.data })) - ); - } finally { - retPromiseResolve?.(data.data); - } - break; - case "onprogress": - details.onprogress?.(makeCallbackParam({ ...data.data, mode: "browser" })); - retPromiseReject?.(new Error("Timeout ERROR")); - break; - case "ontimeout": - try { - withLoadEnd( - () => details.ontimeout?.(makeCallbackParam({})), - () => details.onloadend?.(makeCallbackParam({})) - ); - } finally { - retPromiseReject?.(new Error("Timeout ERROR")); - } - break; - case "onerror": - try { - withLoadEnd( - () => details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError), - () => details.onloadend?.(makeCallbackParam({ error: "unknown" })) - ); - } finally { - retPromiseReject?.(new Error("Unknown ERROR")); - } - break; - default: - LoggerCore.logger().warn("GM_download resp is error", { - data, - }); - try { - details.onloadend?.(makeCallbackParam({})); - } finally { - retPromiseReject?.(new Error("Unexpected Internal ERROR")); - } - break; - } - }); + connect.onMessage((data) => handleDownloadMessage(data)); } else { // native // xhr 已因 ontimeout/onerror 失败:即使失败前已收到部分数据,底层 XHR 实现仍会在其 @@ -1157,14 +1167,11 @@ export default class GMApi extends GM_Base { // 行为与 “onMessage 收到 onerror” 一致,保持外层 contract 不变。 releaseResources(); if (!aborted) { - try { - withLoadEnd( - () => details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError), - () => details.onloadend?.(makeCallbackParam({ error: "unknown" })) - ); - } finally { - retPromiseReject?.(e instanceof Error ? e : new Error("GM_download connect ERROR")); - } + withLoadEnd( + () => details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError), + { error: "unknown" }, + () => retPromiseReject?.(e instanceof Error ? e : new Error("GM_download connect ERROR")) + ); } return; } @@ -1181,65 +1188,7 @@ export default class GMApi extends GM_Base { } connect = con; - connect.onMessage((data) => { - switch (data.action) { - case "onload": - try { - withLoadEnd( - () => details.onload?.(makeCallbackParam({ ...data.data })), - () => details.onloadend?.(makeCallbackParam({ ...data.data })) - ); - } finally { - retPromiseResolve?.(data.data); - releaseResources(); - } - break; - case "save_cancelled": // saveAs cancelled by user,TM 视为下载成功 - try { - withLoadEnd( - () => details.onload?.(makeCallbackParam({ ...data.data })), - () => details.onloadend?.(makeCallbackParam({ ...data.data })) - ); - } finally { - retPromiseResolve?.(data.data); - releaseResources(); - } - break; - case "ontimeout": - try { - withLoadEnd( - () => details.ontimeout?.(makeCallbackParam({})), - () => details.onloadend?.(makeCallbackParam({})) - ); - } finally { - retPromiseReject?.(new Error("Timeout ERROR")); - releaseResources(); - } - break; - case "onerror": - try { - withLoadEnd( - () => details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError), - () => details.onloadend?.(makeCallbackParam({ error: "unknown" })) - ); - } finally { - retPromiseReject?.(new Error("Unknown ERROR")); - releaseResources(); - } - break; - default: - LoggerCore.logger().warn("GM_download resp is error", { - data, - }); - try { - details.onloadend?.(makeCallbackParam({})); - } finally { - retPromiseReject?.(new Error("Unexpected Internal ERROR")); - releaseResources(); - } - break; - } - }); + connect.onMessage((data) => handleDownloadMessage(data, releaseResources)); // 后台主动断连(例如 SW 重启、扩展更新)也释放 URL,避免长尾泄漏。 // releaseResources 通过 released 标志位幂等,与 onMessage 内部的释放调用顺序无关。 @@ -1261,43 +1210,32 @@ export default class GMApi extends GM_Base { // onloadend 消息、其 retPromise 永久 pending、refCleanup 也无法执行。 // 清空后 abort() 中的 nativeAbort?.() 变为空操作,内部 XHR 可自然收尾。 nativeAbort = null; - try { - withLoadEnd( - () => details.ontimeout?.(makeCallbackParam({})), - () => details.onloadend?.(makeCallbackParam({})) - ); - } catch (err) { - // 不能让使用者回调的例外同步传播回 GM_xmlhttpRequest 的内部消息处理循环: - // 那会中断其状态机(跳过 reqDone 置位、协议错误分支下合成 onloadend 的 - // 排程),导致内部请求永久卡住、无法完成清理。改为异步重新抛出,让例外 - // 仍可被观察到(如全局错误上报),但不阻断内部生命周期收尾。 - queueMicrotask(() => { - throw err; - }); - } finally { - // 必须在此直接 settle:若使用者在 onloadend 内呼叫 abort(), - // 只会设置 aborted 旗标,不代表这次下载没有失败。原本仅靠 - // GM_xmlhttpRequest 内部 retPromise 之后才 reject 的路径, - // 会被外层 `if (aborted) return` 短路,导致 GM.download 的 - // promise 永久 pending。settle 具幂等性,重复调用是安全的。 - retPromiseReject?.(new Error("Native Download ERROR")); - } + // 必须在此直接 settle:若使用者在 onloadend 内呼叫 abort(),只会设置 aborted + // 旗标,不代表这次下载没有失败。原本仅靠 GM_xmlhttpRequest 内部 retPromise 之后 + // 才 reject 的路径,会被外层 `if (aborted) return` 短路,导致 GM.download 的 + // promise 永久 pending。settle 具幂等性,重复调用是安全的。 + withLoadEnd( + () => details.ontimeout?.(makeCallbackParam({})), + {}, + () => retPromiseReject?.(new Error("Native Download ERROR")), + (err) => + queueMicrotask(() => { + throw err; + }) + ); }, onerror: () => { xhrFailed = true; nativeAbort = null; - try { - withLoadEnd( - () => details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError), - () => details.onloadend?.(makeCallbackParam({ error: "unknown" })) - ); - } catch (err) { - queueMicrotask(() => { - throw err; - }); - } finally { - retPromiseReject?.(new Error("Native Download ERROR")); - } + withLoadEnd( + () => details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError), + { error: "unknown" }, + () => retPromiseReject?.(new Error("Native Download ERROR")), + (err) => + queueMicrotask(() => { + throw err; + }) + ); }, } as GMTypes.XHRDetails; if (typeof details.headers === "object") {