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/7] =?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/7] =?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/7] =?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/7] =?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/7] 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") { From a192c0b4721a52a6b1ebd956e3bbb46921b55ac3 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:05:19 +0900 Subject: [PATCH 6/7] =?UTF-8?q?=F0=9F=90=9B=20=E5=AE=8C=E5=96=84=20GM.down?= =?UTF-8?q?load=20=E7=BB=88=E6=80=81=E4=B8=8E=E6=96=AD=E8=BF=9E=E6=94=B6?= =?UTF-8?q?=E5=B0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/gm_api.ts | 45 +++- .../content/gm_api/gm_download.test.ts | 208 +++++++++++++++++- 2 files changed, 246 insertions(+), 7 deletions(-) diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 88606c747..059d0f33a 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -976,6 +976,7 @@ export default class GMApi extends GM_Base { : null; const urlPromiseLike = typeof details.url === "object" ? convObjectToURL(details.url) : details.url; let aborted = false; + let terminal = false; let connect: MessageConnect; let nativeAbort: (() => any) | null = null; const contentContext = details.context; @@ -1021,9 +1022,11 @@ export default class GMApi extends GM_Base { // browser 与 native 两条路径最终都经由后台 GM_download 回传同一组消息(onload/save_cancelled/ // onerror),此处共用同一份处理逻辑;releaseResources 仅 native 路径需要,browser 路径不传。 const handleDownloadMessage = (data: { action?: string; data?: any }, releaseResources?: () => void) => { + if (terminal) return; switch (data.action) { case "onload": case "save_cancelled": // saveAs cancelled by user,TM 视为下载成功 + terminal = true; withLoadEnd( () => details.onload?.(makeCallbackParam({ ...data.data })), { ...data.data }, @@ -1035,9 +1038,9 @@ export default class GMApi extends GM_Base { break; case "onprogress": details.onprogress?.(makeCallbackParam({ ...data.data, mode: "browser" })); - retPromiseReject?.(new Error("Timeout ERROR")); break; case "ontimeout": + terminal = true; withLoadEnd( () => details.ontimeout?.(makeCallbackParam({})), {}, @@ -1048,6 +1051,7 @@ export default class GMApi extends GM_Base { ); break; case "onerror": + terminal = true; withLoadEnd( () => details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError), { error: "unknown" }, @@ -1058,6 +1062,7 @@ export default class GMApi extends GM_Base { ); break; default: + terminal = true; LoggerCore.logger().warn("GM_download resp is error", { data }); withLoadEnd( () => {}, @@ -1076,8 +1081,12 @@ export default class GMApi extends GM_Base { // TM 对空 url 会同步报错/触发 onerror,而非发起请求; // new URL("", base) 不会抛错而是解析为当前页面地址,因此需在此显式拦截,避免误下载当前页面。 if (!aborted) { - details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError); - retPromiseReject?.(new Error("GM_download: url is empty")); + terminal = true; + withLoadEnd( + () => details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError), + { error: "unknown" }, + () => retPromiseReject?.(new Error("GM_download: url is empty")) + ); } return; } @@ -1115,6 +1124,7 @@ export default class GMApi extends GM_Base { // 后台连接失败:通过 onerror / reject 通知调用方,行为与 “onMessage 收到 onerror” 一致, // 否则 GM.download 的 promise 会永远 pending(issue: 无 native XHR 后备路径可兜底)。 if (!aborted) { + terminal = true; withLoadEnd( () => details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError), { error: "unknown" }, @@ -1123,9 +1133,21 @@ export default class GMApi extends GM_Base { } return; } - if (aborted) return; + if (aborted) { + try { + con.disconnect(true); + } catch { + // ignored + } + return; + } connect = con; connect.onMessage((data) => handleDownloadMessage(data)); + connect.onDisconnect(() => { + if (!aborted) { + handleDownloadMessage({ action: "onerror" }); + } + }); } else { // native // xhr 已因 ontimeout/onerror 失败:即使失败前已收到部分数据,底层 XHR 实现仍会在其 @@ -1139,7 +1161,10 @@ export default class GMApi extends GM_Base { onloadend: async (res) => { if (aborted || xhrFailed) return; const response = res.response; - if (!(response instanceof Blob)) return; + if (!(response instanceof Blob)) { + handleDownloadMessage({ action: "onerror" }); + return; + } // 1. 先创建 blob URL,并立即就地准备好释放函数 + 标志位。 // 这样后续任何抛错/aborted/disconnect 路径都能复用同一处释放逻辑, @@ -1176,6 +1201,7 @@ export default class GMApi extends GM_Base { // 行为与 “onMessage 收到 onerror” 一致,保持外层 contract 不变。 releaseResources(); if (!aborted) { + terminal = true; withLoadEnd( () => details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError), { error: "unknown" }, @@ -1202,7 +1228,11 @@ export default class GMApi extends GM_Base { // 后台主动断连(例如 SW 重启、扩展更新)也释放 URL,避免长尾泄漏。 // releaseResources 通过 released 标志位幂等,与 onMessage 内部的释放调用顺序无关。 connect.onDisconnect(() => { - releaseResources(); + if (aborted) { + releaseResources(); + return; + } + handleDownloadMessage({ action: "onerror" }, releaseResources); }); }, onload: () => { @@ -1213,6 +1243,7 @@ export default class GMApi extends GM_Base { }, ontimeout: () => { xhrFailed = true; + terminal = true; // XHR 阶段已终结:清空 nativeAbort,避免使用者在 onloadend 内呼叫 // download.abort() 时经由 nativeAbort() 强制断开内部 XHR 的消息连线 // (其 abort() 会无条件 disconnect),导致内部 XHR 收不到自己真正的 @@ -1235,6 +1266,7 @@ export default class GMApi extends GM_Base { }, onerror: () => { xhrFailed = true; + terminal = true; nativeAbort = null; withLoadEnd( () => details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError), @@ -1268,6 +1300,7 @@ export default class GMApi extends GM_Base { xhrParams.password = details.password || ""; } // -- 其他参数 -- + if (aborted) return; const { retPromise, abort } = GM_xmlhttpRequest(a, xhrParams, true, true); retPromise?.catch(() => { if (aborted) return; 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 78779be0e..567e395fc 100644 --- a/src/app/service/content/gm_api/gm_download.test.ts +++ b/src/app/service/content/gm_api/gm_download.test.ts @@ -24,19 +24,25 @@ async function flushMicrotasks(times = 10) { function createFakeConnect() { let messageHandler: ((data: any) => void) | undefined; + let disconnectHandler: (() => void) | undefined; const conn = { onMessage(cb: (data: any) => void) { messageHandler = cb; }, sendMessage: vi.fn(), disconnect: vi.fn(), - onDisconnect: vi.fn(), + onDisconnect(cb: () => void) { + disconnectHandler = cb; + }, } as unknown as MessageConnect; return { conn, emit(data: any) { messageHandler?.(data); }, + emitDisconnect() { + disconnectHandler?.(); + }, }; } @@ -417,4 +423,204 @@ describe("GM_download 补充回归测试(native 部分下载 / browser connect queueMicrotaskSpy.mockRestore(); }); + + it("downloadMode=browser:进度事件不应提前 reject,后续成功仍应 resolve", async () => { + const { conn, emit } = createFakeConnect(); + const fakeA = createFakeA(conn); + const onprogress = vi.fn(); + const onload = vi.fn(); + const details: GMTypes.DownloadDetails = { + url: "https://example.com/a.zip", + name: "a.zip", + downloadMode: "browser", + onprogress, + onload, + }; + const { retPromise } = GMApi._GM_download(fakeA as any, details, true); + await flushMicrotasks(); + + emit({ action: "onprogress", data: { loaded: 1, total: 10 } }); + let settled = false; + void retPromise!.then( + () => { + settled = true; + }, + () => { + settled = true; + } + ); + await flushMicrotasks(); + expect(onprogress).toHaveBeenCalledTimes(1); + expect(settled).toBe(false); + + const payload = { loaded: 10, total: 10 }; + emit({ action: "onload", data: payload }); + await expect(retPromise).resolves.toEqual(payload); + expect(onload).toHaveBeenCalledTimes(1); + }); + + it("downloadMode=browser:后台连接断开应以 onerror/onloadend 结束请求", async () => { + const { conn, emitDisconnect } = 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, + }; + const { retPromise } = GMApi._GM_download(fakeA as any, details, true); + await flushMicrotasks(); + + emitDisconnect(); + + expect(onerror).toHaveBeenCalledTimes(1); + expect(onloadend).toHaveBeenCalledTimes(1); + await expect(retPromise).rejects.toThrow(); + }); + + it("downloadMode=native:后台连接断开应以 onerror/onloadend 结束请求", async () => { + const { conn, emitDisconnect } = 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 onerror = vi.fn(); + const onloadend = vi.fn(); + const details: GMTypes.DownloadDetails = { + url: "https://example.com/a.zip", + name: "a.zip", + downloadMode: "native", + onerror, + onloadend, + }; + const { retPromise } = GMApi._GM_download(fakeA as any, details, true); + await flushMicrotasks(); + + emitDisconnect(); + + expect(onerror).toHaveBeenCalledTimes(1); + expect(onloadend).toHaveBeenCalledTimes(1); + await expect(retPromise).rejects.toThrow(); + }); + + it("downloadMode=native:非 Blob 响应应以 onerror/onloadend 结束请求", async () => { + const fakeA = { + isInvalidContext: () => false, + connect: vi.fn(), + }; + vi.mocked(GM_xmlhttpRequest).mockImplementationOnce((_a: unknown, xhrParams: any) => { + queueMicrotask(() => { + xhrParams.onloadend?.({ response: null }); + }); + return { retPromise: Promise.resolve(), abort: 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, + }; + const { retPromise } = GMApi._GM_download(fakeA as any, details, true); + await flushMicrotasks(); + + expect(onerror).toHaveBeenCalledTimes(1); + expect(onloadend).toHaveBeenCalledTimes(1); + await expect(retPromise).rejects.toThrow(); + expect(fakeA.connect).not.toHaveBeenCalled(); + }); + + it("downloadMode=browser:重复终态消息只应触发一次回调并 settle 一次", 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, + }; + const { retPromise } = GMApi._GM_download(fakeA as any, details, true); + await flushMicrotasks(); + const payload = { loaded: 10, total: 10 }; + + emit({ action: "onload", data: payload }); + emit({ action: "onload", data: payload }); + + await expect(retPromise).resolves.toEqual(payload); + expect(onload).toHaveBeenCalledTimes(1); + expect(onloadend).toHaveBeenCalledTimes(1); + }); + + it("downloadMode=browser:abort 后才建立的连接应立即断开", async () => { + let resolveConnect: (conn: MessageConnect) => void = () => {}; + const { conn } = createFakeConnect(); + const fakeA = { + isInvalidContext: () => false, + connect: vi.fn( + () => + new Promise((resolve) => { + resolveConnect = resolve; + }) + ), + }; + const details: GMTypes.DownloadDetails = { + url: "https://example.com/a.zip", + name: "a.zip", + downloadMode: "browser", + }; + const { abort } = GMApi._GM_download(fakeA as any, details, false); + await flushMicrotasks(); + expect(fakeA.connect).toHaveBeenCalledTimes(1); + abort(); + resolveConnect(conn); + await flushMicrotasks(); + + expect(conn.disconnect).toHaveBeenCalledWith(true); + }); + + it("空 URL:onerror 抛错时仍应调用 onloadend 并 reject GM.download", async () => { + const onloadend = vi.fn(); + const details: GMTypes.DownloadDetails = { + url: "", + name: "a.zip", + onerror: () => { + throw new Error("boom"); + }, + onloadend, + }; + const { retPromise } = GMApi._GM_download({ isInvalidContext: () => false } as any, details, true); + await flushMicrotasks(); + + expect(onloadend).toHaveBeenCalledTimes(1); + await expect(retPromise).rejects.toThrow("url is empty"); + }); + + it("native 模式:abort 在异步启动前调用时不应继续创建 XHR", async () => { + let started = false; + vi.mocked(GM_xmlhttpRequest).mockImplementationOnce(() => { + started = true; + return { retPromise: Promise.resolve(), abort: vi.fn() }; + }); + const details: GMTypes.DownloadDetails = { + url: "https://example.com/a.zip", + name: "a.zip", + downloadMode: "native", + }; + const { abort } = GMApi._GM_download({ isInvalidContext: () => false } as any, details, false); + abort(); + await flushMicrotasks(); + + expect(started).toBe(false); + }); }); From 4ddacca042a0f16a9068aa8cd4fb425aafa8d089 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:05:33 +0900 Subject: [PATCH 7/7] =?UTF-8?q?=F0=9F=90=9B=20=E5=8F=96=E6=B6=88=E6=96=AD?= =?UTF-8?q?=E8=BF=9E=E5=90=8E=E7=9A=84=E8=BF=9F=E5=88=B0=E6=B5=8F=E8=A7=88?= =?UTF-8?q?=E5=99=A8=E4=B8=8B=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service_worker/gm_api/gm_api.test.ts | 42 +++++++++++++++++++ .../service/service_worker/gm_api/gm_api.ts | 27 +++++++----- 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/src/app/service/service_worker/gm_api/gm_api.test.ts b/src/app/service/service_worker/gm_api/gm_api.test.ts index 63b937997..962c4054d 100644 --- a/src/app/service/service_worker/gm_api/gm_api.test.ts +++ b/src/app/service/service_worker/gm_api/gm_api.test.ts @@ -9,9 +9,19 @@ import GMApi, { } from "./gm_api"; import { PermissionVerifyApiGet, type ConfirmParam } from "../permission_verify"; import type { GMApiRequest } from "../types"; +import { detachDownloadCallback, startDownload } from "../download"; // 触发所有 GM API 装饰器注册(与 gm_api.ts 中的 import 保持同步) import "./gm_api"; +vi.mock("../download", async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + detachDownloadCallback: vi.fn(), + startDownload: vi.fn(), + }; +}); + // 小工具:建立假的 IGetSender const makeSender = (url?: string): IGetSender => ({ getSender: () => (url ? { url } : {}), @@ -338,3 +348,35 @@ describe.concurrent("native GM_download 的 @connect 校验(verifyXhrConnect await expect(xhrConfirm(req, makeConnSender(), makeGmApi())).rejects.toThrow(/not a part of the @connect list/); }); }); + +describe("browser GM_download 的连接竞态", () => { + it("后台连接在 startDownload 返回 ID 前断开时,应取消迟到的浏览器下载", async () => { + let disconnectHandler: (() => void) | undefined; + const conn = { + onDisconnect(handler: () => void) { + disconnectHandler = handler; + }, + sendMessage: vi.fn(), + disconnect: vi.fn(), + }; + const sender = { + getSender: () => ({}), + getType: () => 0, + isType: () => true, + getExtMessageSender: () => null, + getConnect: () => conn, + } as unknown as IGetSender; + const request = makeReq({ url: "blob:https://scriptcat.test/late", downloadMode: "browser" }); + const cancel = vi.spyOn(chrome.downloads, "cancel"); + vi.mocked(startDownload).mockImplementationOnce(async () => { + disconnectHandler?.(); + return 42; + }); + + await GMApi.prototype.GM_download(request, sender); + + expect(cancel).toHaveBeenCalledWith(42, expect.any(Function)); + expect(detachDownloadCallback).toHaveBeenCalledWith(42); + cancel.mockRestore(); + }); +}); diff --git a/src/app/service/service_worker/gm_api/gm_api.ts b/src/app/service/service_worker/gm_api/gm_api.ts index 99a5e2b68..3f9eaf685 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -1420,6 +1420,17 @@ export default class GMApi { const fileName = cleanFileName(params.name); // blob本地文件或显示指定downloadMode为"browser"则直接下载 const blobURL = params.url; + const cancelDisconnectedDownload = () => { + if (typeof cDownloadId !== "number" || cDownloadId <= 0 || reqCompleteWith) return; + reqCompleteWith = "disconnected"; + chrome.downloads.cancel(cDownloadId, () => { + const lastError = chrome.runtime.lastError; + if (lastError) { + console.error("chrome.runtime.lastError in chrome.downloads.cancel:", lastError); + } + }); + detachDownloadCallback(cDownloadId); + }; const downloadCallback = (o: DownloadCallback) => { if (o.state === "complete") { if (!isConnDisconnected && !reqCompleteWith) { @@ -1451,16 +1462,7 @@ export default class GMApi { msgConn.onDisconnect(() => { if (isConnDisconnected) return; isConnDisconnected = true; - if (typeof cDownloadId === "number" && cDownloadId > 0 && !reqCompleteWith) { - reqCompleteWith = "disconnected"; - chrome.downloads.cancel(cDownloadId, () => { - const lastError = chrome.runtime.lastError; - if (lastError) { - console.error("chrome.runtime.lastError in chrome.downloads.cancel:", lastError); - } - }); - detachDownloadCallback(cDownloadId); - } + cancelDisconnectedDownload(); }); if (!blobURL) { if (!isConnDisconnected && !reqCompleteWith) { @@ -1486,6 +1488,11 @@ export default class GMApi { downloadAPIOptions.conflictAction = params.conflictAction; } cDownloadId = await startDownload(downloadAPIOptions, downloadCallback); + // 连接可能在 startDownload 尚未返回 ID 时断开;此时 onDisconnect 无法立即取消, + // 拿到迟到的 ID 后必须补做取消,否则会留下无法回报结果的浏览器下载。 + if (isConnDisconnected) { + cancelDisconnectedDownload(); + } if (cDownloadId === undefined) { if (!isConnDisconnected && !reqCompleteWith) { reqCompleteWith = "error:download_api_error";