diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 2ea570d90..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; @@ -991,14 +992,101 @@ export default class GMApi extends GM_Base { } return retParam as K; }; + // 保证「主回调」(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 { + try { + primary(); + } finally { + details.onloadend?.(makeCallbackParam(loadendData)); + } + } catch (err) { + if (onError) { + onError(err); + } else { + throw err; + } + } finally { + settle(); + } + }; + // 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 }, + () => { + retPromiseResolve?.(data.data); + releaseResources?.(); + } + ); + break; + case "onprogress": + details.onprogress?.(makeCallbackParam({ ...data.data, mode: "browser" })); + break; + case "ontimeout": + terminal = true; + withLoadEnd( + () => details.ontimeout?.(makeCallbackParam({})), + {}, + () => { + retPromiseReject?.(new Error("Timeout ERROR")); + releaseResources?.(); + } + ); + break; + case "onerror": + terminal = true; + withLoadEnd( + () => details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError), + { error: "unknown" }, + () => { + retPromiseReject?.(new Error("Unknown ERROR")); + releaseResources?.(); + } + ); + break; + default: + terminal = true; + LoggerCore.logger().warn("GM_download resp is error", { data }); + withLoadEnd( + () => {}, + {}, + () => { + retPromiseReject?.(new Error("Unexpected Internal ERROR")); + releaseResources?.(); + } + ); + break; + } + }; const handle = async () => { const url = await urlPromiseLike; if (!url) { // 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; } @@ -1016,62 +1104,67 @@ 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, - ]); - if (aborted) return; + 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) { + terminal = true; + 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) { + try { + con.disconnect(true); + } catch { + // ignored + } + return; + } connect = con; - connect.onMessage((data) => { - switch (data.action) { - case "onload": - details.onload?.(makeCallbackParam({ ...data.data })); - retPromiseResolve?.(data.data); - break; - case "save_cancelled": // saveAs cancelled by user,TM 视为下载成功 - details.onload?.(makeCallbackParam({ ...data.data })); - retPromiseResolve?.(data.data); - break; - case "onprogress": - details.onprogress?.(makeCallbackParam({ ...data.data, mode: "browser" })); - retPromiseReject?.(new Error("Timeout ERROR")); - break; - case "ontimeout": - details.ontimeout?.(makeCallbackParam({})); - retPromiseReject?.(new Error("Timeout ERROR")); - break; - case "onerror": - details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError); - retPromiseReject?.(new Error("Unknown ERROR")); - break; - default: - LoggerCore.logger().warn("GM_download resp is error", { - data, - }); - retPromiseReject?.(new Error("Unexpected Internal ERROR")); - break; + connect.onMessage((data) => handleDownloadMessage(data)); + connect.onDisconnect(() => { + if (!aborted) { + handleDownloadMessage({ action: "onerror" }); } }); } 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; + if (!(response instanceof Blob)) { + handleDownloadMessage({ action: "onerror" }); + return; + } // 1. 先创建 blob URL,并立即就地准备好释放函数 + 标志位。 // 这样后续任何抛错/aborted/disconnect 路径都能复用同一处释放逻辑, @@ -1108,8 +1201,12 @@ export default class GMApi extends GM_Base { // 行为与 “onMessage 收到 onerror” 一致,保持外层 contract 不变。 releaseResources(); if (!aborted) { - details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError); - retPromiseReject?.(e instanceof Error ? e : new Error("GM_download connect ERROR")); + terminal = true; + withLoadEnd( + () => details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError), + { error: "unknown" }, + () => retPromiseReject?.(e instanceof Error ? e : new Error("GM_download connect ERROR")) + ); } return; } @@ -1126,42 +1223,16 @@ export default class GMApi extends GM_Base { } connect = con; - connect.onMessage((data) => { - switch (data.action) { - case "onload": - details.onload?.(makeCallbackParam({ ...data.data })); - retPromiseResolve?.(data.data); - releaseResources(); - break; - case "save_cancelled": // saveAs cancelled by user,TM 视为下载成功 - details.onload?.(makeCallbackParam({ ...data.data })); - retPromiseResolve?.(data.data); - releaseResources(); - break; - case "ontimeout": - details.ontimeout?.(makeCallbackParam({})); - retPromiseReject?.(new Error("Timeout ERROR")); - releaseResources(); - break; - case "onerror": - details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError); - retPromiseReject?.(new Error("Unknown ERROR")); - releaseResources(); - break; - default: - LoggerCore.logger().warn("GM_download resp is error", { - data, - }); - retPromiseReject?.(new Error("Unexpected Internal ERROR")); - releaseResources(); - break; - } - }); + connect.onMessage((data) => handleDownloadMessage(data, releaseResources)); // 后台主动断连(例如 SW 重启、扩展更新)也释放 URL,避免长尾泄漏。 // releaseResources 通过 released 标志位幂等,与 onMessage 内部的释放调用顺序无关。 connect.onDisconnect(() => { - releaseResources(); + if (aborted) { + releaseResources(); + return; + } + handleDownloadMessage({ action: "onerror" }, releaseResources); }); }, onload: () => { @@ -1171,10 +1242,41 @@ export default class GMApi extends GM_Base { details.onprogress?.(makeCallbackParam({ ...e, mode: "native" })); }, ontimeout: () => { - details.ontimeout?.(makeCallbackParam({})); + xhrFailed = true; + terminal = true; + // XHR 阶段已终结:清空 nativeAbort,避免使用者在 onloadend 内呼叫 + // download.abort() 时经由 nativeAbort() 强制断开内部 XHR 的消息连线 + // (其 abort() 会无条件 disconnect),导致内部 XHR 收不到自己真正的 + // onloadend 消息、其 retPromise 永久 pending、refCleanup 也无法执行。 + // 清空后 abort() 中的 nativeAbort?.() 变为空操作,内部 XHR 可自然收尾。 + nativeAbort = null; + // 必须在此直接 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: () => { - details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError); + xhrFailed = true; + terminal = true; + nativeAbort = null; + 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") { @@ -1198,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 new file mode 100644 index 000000000..567e395fc --- /dev/null +++ b/src/app/service/content/gm_api/gm_download.test.ts @@ -0,0 +1,626 @@ +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>(); + 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; + let disconnectHandler: (() => void) | undefined; + const conn = { + onMessage(cb: (data: any) => void) { + messageHandler = cb; + }, + sendMessage: vi.fn(), + disconnect: vi.fn(), + onDisconnect(cb: () => void) { + disconnectHandler = cb; + }, + } as unknown as MessageConnect; + return { + conn, + emit(data: any) { + messageHandler?.(data); + }, + emitDisconnect() { + disconnectHandler?.(); + }, + }; +} + +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(); + }); +}); + +// 非 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(); + }); + + 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,且不应中断内部 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: innerAbort }; + }); + 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(); + // 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(); + }); + + 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); + }); +}); 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"; 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 { 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 {