From c474107617ca9b7e75b58402cd33be5c974cd6e2 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 18 Apr 2026 04:40:40 +0900 Subject: [PATCH 1/8] =?UTF-8?q?=E8=A7=A3=E5=86=B3=E5=B9=B6=E5=8F=91=20xhr?= =?UTF-8?q?=20=E7=9A=84=20session=20rule=20=E6=95=B0=E9=87=8F=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../declarativ_net_request.ts | 32 ++- .../gm_api/dnr_id_controller.test.ts | 196 ++++++++++++++++++ .../gm_api/dnr_id_controller.ts | 56 +++++ .../service/service_worker/gm_api/gm_api.ts | 5 +- tests/vitest.setup.ts | 2 + 5 files changed, 288 insertions(+), 3 deletions(-) create mode 100644 src/app/service/service_worker/gm_api/dnr_id_controller.test.ts create mode 100644 src/app/service/service_worker/gm_api/dnr_id_controller.ts diff --git a/packages/chrome-extension-mock/declarativ_net_request.ts b/packages/chrome-extension-mock/declarativ_net_request.ts index abbf15c3e..c2a9be819 100644 --- a/packages/chrome-extension-mock/declarativ_net_request.ts +++ b/packages/chrome-extension-mock/declarativ_net_request.ts @@ -1,4 +1,8 @@ export default class DeclarativeNetRequest { + MAX_NUMBER_OF_SESSION_RULES = 5000; + + private _sessionRules: chrome.declarativeNetRequest.Rule[] = []; + HeaderOperation = { APPEND: "append", SET: "set", @@ -30,9 +34,35 @@ export default class DeclarativeNetRequest { OTHER: "other", }; - updateSessionRules() { + updateSessionRules( + options: { + addRules?: chrome.declarativeNetRequest.Rule[]; + removeRuleIds?: number[]; + } = {} + ): Promise { return new Promise((resolve) => { + const { addRules = [], removeRuleIds = [] } = options; + + // Remove rules by ID + if (removeRuleIds.length > 0) { + this._sessionRules = this._sessionRules.filter((rule) => !removeRuleIds.includes(rule.id)); + } + + // Add or update rules (upsert by ID) + for (const newRule of addRules) { + const existingIndex = this._sessionRules.findIndex((rule) => rule.id === newRule.id); + if (existingIndex !== -1) { + this._sessionRules[existingIndex] = newRule; // update + } else { + this._sessionRules.push(newRule); // add + } + } + resolve(); }); } + + getSessionRules(): Promise { + return Promise.resolve([...this._sessionRules]); + } } diff --git a/src/app/service/service_worker/gm_api/dnr_id_controller.test.ts b/src/app/service/service_worker/gm_api/dnr_id_controller.test.ts new file mode 100644 index 000000000..81b7e0e68 --- /dev/null +++ b/src/app/service/service_worker/gm_api/dnr_id_controller.test.ts @@ -0,0 +1,196 @@ +import { sleep } from "@App/pkg/utils/utils"; +import { describe, expect, it, vi } from "vitest"; +import { + getSessionRuleIds, + LIMIT_SESSION_RULES, + nextSessionRuleId, + removeSessionRuleIdEntry, +} from "./dnr_id_controller"; + +describe("getSessionRuleIds", () => { + it("initializes from existing chrome session rules", async () => { + //@ts-ignore + vi.mocked(chrome.declarativeNetRequest.getSessionRules).mockResolvedValueOnce([ + { id: 10901, priority: 1, action: { type: "block" }, condition: {} }, + { id: 10902, priority: 1, action: { type: "block" }, condition: {} }, + ]); + + const ids = await getSessionRuleIds(); + expect(ids).toContain(10901); + expect(ids).toContain(10902); + }); +}); + +describe("nextSessionRuleId", () => { + it("returns unique incrementing IDs on each call", async () => { + const id1 = await nextSessionRuleId(); + const id2 = await nextSessionRuleId(); + const id3 = await nextSessionRuleId(); + + expect(id2).toBeGreaterThan(id1); + expect(id3).toBeGreaterThan(id2); + }); + + it("skips IDs that already exist in session rules", async () => { + const ids = await getSessionRuleIds(); + const next = await nextSessionRuleId(); + + ids.add(next + 1); + const skipped = await nextSessionRuleId(); + + expect(skipped).toBeGreaterThan(next + 1); + }); +}); + +describe("removeSessionRuleIdEntry", () => { + it("removes the given ID from the tracked set", async () => { + const ids = await getSessionRuleIds(); + const id = await nextSessionRuleId(); + + ids.add(id); + removeSessionRuleIdEntry(id); + + expect(ids.has(id)).toBe(false); + }); + + it("is a no-op when called before sessionRuleIds is initialized", () => { + expect(() => removeSessionRuleIdEntry(10404)).not.toThrow(); + }); + + it("throws when ruleId is <= 10000", () => { + expect(() => removeSessionRuleIdEntry(10000)).toThrow(); + expect(() => removeSessionRuleIdEntry(1)).toThrow(); + }); + + it("rewinds SESSION_RULE_ID_BEGIN so the removed id gets reused next", async () => { + const ids = await getSessionRuleIds(); + + const id1 = await nextSessionRuleId(); + const id2 = await nextSessionRuleId(); + const id3 = await nextSessionRuleId(); + + ids.add(id1); + ids.add(id2); + ids.add(id3); + + // Remove the latest — counter should rewind and reuse it + removeSessionRuleIdEntry(id3); + const reused = await nextSessionRuleId(); + expect(reused).toBe(id3); + }); + + it("rewinds SESSION_RULE_ID_BEGIN when removed id is behind the counter", async () => { + const ids = await getSessionRuleIds(); + + const id1 = await nextSessionRuleId(); + const id2 = await nextSessionRuleId(); + const id3 = await nextSessionRuleId(); + + ids.add(id1); + ids.add(id2); + ids.add(id3); + + // Remove an older id — counter rewinds to id1 - 1, so id1 gets reused + removeSessionRuleIdEntry(id1); + const reused = await nextSessionRuleId(); + expect(reused).toBe(id1); + }); + + it("does not rewind SESSION_RULE_ID_BEGIN when removed id was pre-existing (ahead of counter)", async () => { + //@ts-ignore + vi.mocked(chrome.declarativeNetRequest.getSessionRules).mockResolvedValueOnce([ + { id: 11122, priority: 1, action: { type: "block" }, condition: {} }, + ]); + + const ids = await getSessionRuleIds(); + const nextBefore = await nextSessionRuleId(); // e.g. 10001 + + ids.add(11122); // ensure it's tracked + removeSessionRuleIdEntry(11122); // 11122 > SESSION_RULE_ID_BEGIN + 1, no rewind + + const nextAfter = await nextSessionRuleId(); + expect(nextAfter).toBe(nextBefore + 1); // counter unchanged, just increments normally + }); +}); + +describe("nextSessionRuleId limit control", () => { + it("locks when session rules reach the limit and unlocks when an entry is removed", async () => { + const ids = await getSessionRuleIds(); + expect(ids.size).toBeLessThan(100); + + const added = []; + for (let w = ids.size; w < LIMIT_SESSION_RULES; w++) { + const j = await nextSessionRuleId(); + added.push(j); + ids.add(j); + } + expect(ids.size).toBeGreaterThan(1000); + + const lockedPromise = nextSessionRuleId(); + + const raceResult1 = await Promise.race([lockedPromise.then(() => "resolved"), sleep(5).then(() => "pending")]); + expect(raceResult1).toBe("pending"); + + const m1 = Math.floor(Math.random() * (added.length - 9)); + const p1 = added[m1]; + const p2 = added[m1 + 6]; + removeSessionRuleIdEntry(p1); + removeSessionRuleIdEntry(p2); + + const raceResult2 = await Promise.race([lockedPromise.then(() => "resolved"), sleep(5).then(() => "pending")]); + expect(raceResult2).toBe("resolved"); + + const id1 = await lockedPromise; + const id2 = await nextSessionRuleId(); + expect(id1).toBe(p1); + expect(id2).toBe(p2); + + for (const k of added) { + removeSessionRuleIdEntry(k); + } + + const res = await getSessionRuleIds(); + expect(res).toBe(ids); + expect(res.size).toBeLessThan(100); + }); + + it("only one lock is created even with concurrent nextSessionRuleId calls", async () => { + const ids = await getSessionRuleIds(); + expect(ids.size).toBeLessThan(100); + + const added = []; + for (let w = ids.size; w < LIMIT_SESSION_RULES; w++) { + const j = await nextSessionRuleId(); + added.push(j); + ids.add(j); + } + expect(ids.size).toBeGreaterThan(1000); + + // Fire multiple concurrent calls while locked + const p1 = nextSessionRuleId(); + const p2 = nextSessionRuleId(); + const p3 = nextSessionRuleId(); + + const raceResult = await Promise.race([ + Promise.all([p1, p2, p3]).then(() => "resolved"), + sleep(5).then(() => "pending"), + ]); + expect(raceResult).toBe("pending"); + + // Single removal should unlock all waiters sequentially + const toRemove = [...ids].find((id) => id > 10000)!; + removeSessionRuleIdEntry(toRemove); + + // All three should eventually resolve + const results = await Promise.race([Promise.all([p1, p2, p3]).then((ids) => ids), sleep(50).then(() => null)]); + expect(results).not.toBeNull(); + + for (const k of added) { + removeSessionRuleIdEntry(k); + } + + const res = await getSessionRuleIds(); + expect(res).toBe(ids); + expect(res.size).toBeLessThan(100); + }); +}); diff --git a/src/app/service/service_worker/gm_api/dnr_id_controller.ts b/src/app/service/service_worker/gm_api/dnr_id_controller.ts new file mode 100644 index 000000000..275a4f691 --- /dev/null +++ b/src/app/service/service_worker/gm_api/dnr_id_controller.ts @@ -0,0 +1,56 @@ +import type { Deferred } from "@App/pkg/utils/utils"; +import { deferred } from "@App/pkg/utils/utils"; + +let sessionRuleIdsPromise: Promise> | null = null; +let sessionRuleIds: Set | null = null; + +let SESSION_RULE_ID_BEGIN = 10000; +export const LIMIT_SESSION_RULES = + process.env.VI_TESTING === "true" ? 1234 : chrome.declarativeNetRequest.MAX_NUMBER_OF_SESSION_RULES - 300; +let lockSessionRuleCreation: Deferred | null = null; + +export const getSessionRuleIds = async (): Promise> => { + if (!sessionRuleIdsPromise) { + sessionRuleIdsPromise = chrome.declarativeNetRequest + .getSessionRules() + .then((rules) => { + sessionRuleIds = new Set(rules.map((rule) => rule.id).filter(Boolean)); + return sessionRuleIds; + }) + .catch((e) => { + console.warn(e); + sessionRuleIds = new Set([]); + return sessionRuleIds; + }); + } + const ruleIds = sessionRuleIds || (await sessionRuleIdsPromise); + return ruleIds; +}; + +export const removeSessionRuleIdEntry = (ruleId: number) => { + if (ruleId <= 10000) { + throw new Error("removeSessionRuleIdEntry cannot remove ids not recreated by nextSessionRuleId"); + } + if (sessionRuleIds) { + if (sessionRuleIds.delete(ruleId) === true) { + if (ruleId <= SESSION_RULE_ID_BEGIN + 1) { + SESSION_RULE_ID_BEGIN = ruleId - 1; + } + if (sessionRuleIds.size < LIMIT_SESSION_RULES) { + lockSessionRuleCreation?.resolve(); + lockSessionRuleCreation = null; + } + } + } +}; + +export const nextSessionRuleId = async () => { + const ruleIds = await getSessionRuleIds(); + if (!lockSessionRuleCreation && ruleIds.size + 1 > LIMIT_SESSION_RULES) lockSessionRuleCreation = deferred(); + if (lockSessionRuleCreation) await lockSessionRuleCreation.promise; + let id; + do { + id = ++SESSION_RULE_ID_BEGIN; + } while (ruleIds.has(id)); + return id; +}; 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 1ecc56f94..d087a58cb 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -53,6 +53,7 @@ import { headerModifierMap, headersReceivedMap } from "./gm_xhr"; import { BgGMXhr } from "@App/pkg/utils/xhr/bg_gm_xhr"; import { mightPrepareSetClipboard, setClipboard } from "../clipboard"; import { nativePageWindowOpen } from "../../offscreen/gm_api"; +import { nextSessionRuleId, removeSessionRuleIdEntry } from "./dnr_id_controller"; let generatedUniqueMarkerIDs = ""; let generatedUniqueMarkerIDWhen = ""; @@ -685,8 +686,7 @@ export default class GMApi { } const redirectNotManual = params.redirect !== "manual"; - // 使用 cacheInstance 避免SW重启造成重复 DNR Rule ID - const ruleId = 10000 + (await cacheInstance.incr("gmXhrRequestId", 1)); + const ruleId = await nextSessionRuleId(); const rule = { id: ruleId, action: { @@ -1609,6 +1609,7 @@ export default class GMApi { if (lastError) { console.error("chrome.declarativeNetRequest.updateSessionRules:", lastError); } + removeSessionRuleIdEntry(rule.id); } ); } diff --git a/tests/vitest.setup.ts b/tests/vitest.setup.ts index 8dcc95fc4..1d349d9a6 100644 --- a/tests/vitest.setup.ts +++ b/tests/vitest.setup.ts @@ -9,6 +9,8 @@ import { mockFetch } from "./mocks/fetch"; vi.stubGlobal("chrome", chromeMock); chromeMock.init(); +vi.spyOn(chromeMock.declarativeNetRequest, "getSessionRules"); +vi.spyOn(chromeMock.declarativeNetRequest, "updateSessionRules"); initTestEnv(); chromeMock.runtime.getURL = vi.fn().mockImplementation((path: string) => { From 0a2a7dc02be8125047025a1931b3895426bf68ed Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 18 Apr 2026 04:44:58 +0900 Subject: [PATCH 2/8] typo --- src/app/service/service_worker/gm_api/dnr_id_controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/service/service_worker/gm_api/dnr_id_controller.ts b/src/app/service/service_worker/gm_api/dnr_id_controller.ts index 275a4f691..6b618bc52 100644 --- a/src/app/service/service_worker/gm_api/dnr_id_controller.ts +++ b/src/app/service/service_worker/gm_api/dnr_id_controller.ts @@ -29,7 +29,7 @@ export const getSessionRuleIds = async (): Promise> => { export const removeSessionRuleIdEntry = (ruleId: number) => { if (ruleId <= 10000) { - throw new Error("removeSessionRuleIdEntry cannot remove ids not recreated by nextSessionRuleId"); + throw new Error("removeSessionRuleIdEntry cannot remove ids not created by nextSessionRuleId"); } if (sessionRuleIds) { if (sessionRuleIds.delete(ruleId) === true) { From 7522c7644ce54f8f5a227a2306d7793ab1f57806 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 18 Apr 2026 04:57:02 +0900 Subject: [PATCH 3/8] code fix --- .../gm_api/dnr_id_controller.test.ts | 42 ++++++++++--------- .../gm_api/dnr_id_controller.ts | 1 + tests/vitest.setup.ts | 4 +- 3 files changed, 26 insertions(+), 21 deletions(-) diff --git a/src/app/service/service_worker/gm_api/dnr_id_controller.test.ts b/src/app/service/service_worker/gm_api/dnr_id_controller.test.ts index 81b7e0e68..e2d3ddfbd 100644 --- a/src/app/service/service_worker/gm_api/dnr_id_controller.test.ts +++ b/src/app/service/service_worker/gm_api/dnr_id_controller.test.ts @@ -1,5 +1,5 @@ import { sleep } from "@App/pkg/utils/utils"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { getSessionRuleIds, LIMIT_SESSION_RULES, @@ -9,15 +9,12 @@ import { describe("getSessionRuleIds", () => { it("initializes from existing chrome session rules", async () => { - //@ts-ignore - vi.mocked(chrome.declarativeNetRequest.getSessionRules).mockResolvedValueOnce([ - { id: 10901, priority: 1, action: { type: "block" }, condition: {} }, - { id: 10902, priority: 1, action: { type: "block" }, condition: {} }, - ]); - const ids = await getSessionRuleIds(); - expect(ids).toContain(10901); - expect(ids).toContain(10902); + expect(ids.size).lessThan(100); + await nextSessionRuleId(); + expect(ids.size).greaterThanOrEqual(1); + await nextSessionRuleId(); + expect(ids.size).greaterThanOrEqual(2); }); }); @@ -97,19 +94,26 @@ describe("removeSessionRuleIdEntry", () => { }); it("does not rewind SESSION_RULE_ID_BEGIN when removed id was pre-existing (ahead of counter)", async () => { - //@ts-ignore - vi.mocked(chrome.declarativeNetRequest.getSessionRules).mockResolvedValueOnce([ - { id: 11122, priority: 1, action: { type: "block" }, condition: {} }, - ]); + const id1 = await nextSessionRuleId(); + const id2 = await nextSessionRuleId(); + const id3 = await nextSessionRuleId(); + const id4 = await nextSessionRuleId(); + const id5 = await nextSessionRuleId(); + const id6 = await nextSessionRuleId(); - const ids = await getSessionRuleIds(); + removeSessionRuleIdEntry(id1); + removeSessionRuleIdEntry(id5); + removeSessionRuleIdEntry(id3); + removeSessionRuleIdEntry(id4); + removeSessionRuleIdEntry(id2); const nextBefore = await nextSessionRuleId(); // e.g. 10001 + removeSessionRuleIdEntry(id6); - ids.add(11122); // ensure it's tracked - removeSessionRuleIdEntry(11122); // 11122 > SESSION_RULE_ID_BEGIN + 1, no rewind - - const nextAfter = await nextSessionRuleId(); - expect(nextAfter).toBe(nextBefore + 1); // counter unchanged, just increments normally + expect(await nextSessionRuleId()).toBe(nextBefore + 1); // counter unchanged, just increments normally + expect(await nextSessionRuleId()).toBe(nextBefore + 2); // counter unchanged, just increments normally + expect(await nextSessionRuleId()).toBe(nextBefore + 3); // counter unchanged, just increments normally + expect(await nextSessionRuleId()).toBe(nextBefore + 4); // counter unchanged, just increments normally + expect(await nextSessionRuleId()).toBe(nextBefore + 5); // counter unchanged, just increments normally }); }); diff --git a/src/app/service/service_worker/gm_api/dnr_id_controller.ts b/src/app/service/service_worker/gm_api/dnr_id_controller.ts index 6b618bc52..83db1ce6c 100644 --- a/src/app/service/service_worker/gm_api/dnr_id_controller.ts +++ b/src/app/service/service_worker/gm_api/dnr_id_controller.ts @@ -52,5 +52,6 @@ export const nextSessionRuleId = async () => { do { id = ++SESSION_RULE_ID_BEGIN; } while (ruleIds.has(id)); + ruleIds.add(id); return id; }; diff --git a/tests/vitest.setup.ts b/tests/vitest.setup.ts index 1d349d9a6..178d5a5e7 100644 --- a/tests/vitest.setup.ts +++ b/tests/vitest.setup.ts @@ -9,8 +9,8 @@ import { mockFetch } from "./mocks/fetch"; vi.stubGlobal("chrome", chromeMock); chromeMock.init(); -vi.spyOn(chromeMock.declarativeNetRequest, "getSessionRules"); -vi.spyOn(chromeMock.declarativeNetRequest, "updateSessionRules"); +// vi.spyOn(chromeMock.declarativeNetRequest, "getSessionRules"); +// vi.spyOn(chromeMock.declarativeNetRequest, "updateSessionRules"); initTestEnv(); chromeMock.runtime.getURL = vi.fn().mockImplementation((path: string) => { From 3f3e32a3d090b538534bbf0123c859b923c68bab Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 18 Apr 2026 05:10:00 +0900 Subject: [PATCH 4/8] fix mock --- .../declarativ_net_request.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/chrome-extension-mock/declarativ_net_request.ts b/packages/chrome-extension-mock/declarativ_net_request.ts index c2a9be819..af50dae54 100644 --- a/packages/chrome-extension-mock/declarativ_net_request.ts +++ b/packages/chrome-extension-mock/declarativ_net_request.ts @@ -34,12 +34,20 @@ export default class DeclarativeNetRequest { OTHER: "other", }; - updateSessionRules( - options: { + updateSessionRules(arg1: any, arg2: any): Promise { + let options: { addRules?: chrome.declarativeNetRequest.Rule[]; removeRuleIds?: number[]; - } = {} - ): Promise { + } = {}; + let callback: undefined | ((...args: any) => any) = undefined; + + if (typeof arg1 === "function") { + callback = arg1; + } else if (typeof arg2 === "function") { + callback = arg2; + } + if (typeof arg1 === "object" && arg1) options = arg1; + return new Promise((resolve) => { const { addRules = [], removeRuleIds = [] } = options; @@ -59,6 +67,7 @@ export default class DeclarativeNetRequest { } resolve(); + callback?.(); }); } From da33bd2db201e984e7cb7f142421ab1ba41cac49 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 18 Apr 2026 09:23:48 +0900 Subject: [PATCH 5/8] update unit test --- .../service/service_worker/gm_api/dnr_id_controller.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/service/service_worker/gm_api/dnr_id_controller.test.ts b/src/app/service/service_worker/gm_api/dnr_id_controller.test.ts index e2d3ddfbd..1b5f218ca 100644 --- a/src/app/service/service_worker/gm_api/dnr_id_controller.test.ts +++ b/src/app/service/service_worker/gm_api/dnr_id_controller.test.ts @@ -97,21 +97,21 @@ describe("removeSessionRuleIdEntry", () => { const id1 = await nextSessionRuleId(); const id2 = await nextSessionRuleId(); const id3 = await nextSessionRuleId(); - const id4 = await nextSessionRuleId(); + const _id4 = await nextSessionRuleId(); const id5 = await nextSessionRuleId(); const id6 = await nextSessionRuleId(); removeSessionRuleIdEntry(id1); removeSessionRuleIdEntry(id5); removeSessionRuleIdEntry(id3); - removeSessionRuleIdEntry(id4); + // removeSessionRuleIdEntry(id4); removeSessionRuleIdEntry(id2); const nextBefore = await nextSessionRuleId(); // e.g. 10001 removeSessionRuleIdEntry(id6); expect(await nextSessionRuleId()).toBe(nextBefore + 1); // counter unchanged, just increments normally expect(await nextSessionRuleId()).toBe(nextBefore + 2); // counter unchanged, just increments normally - expect(await nextSessionRuleId()).toBe(nextBefore + 3); // counter unchanged, just increments normally + // expect(await nextSessionRuleId()).toBe(nextBefore + 3); // counter unchanged, just increments normally expect(await nextSessionRuleId()).toBe(nextBefore + 4); // counter unchanged, just increments normally expect(await nextSessionRuleId()).toBe(nextBefore + 5); // counter unchanged, just increments normally }); From d80bc08da06aca1e2164df0d33045b5693e2db52 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:34:05 +0900 Subject: [PATCH 6/8] =?UTF-8?q?=E6=94=B9=E5=96=84=20session=20rule=20?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E5=A4=84=E7=90=86=20=E5=8F=8A=20scXhrRequest?= =?UTF-8?q?s=20=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/service_worker/gm_api/gm_api.ts | 109 ++++++++++++++---- 1 file changed, 86 insertions(+), 23 deletions(-) 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 d087a58cb..4ed8529db 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -75,7 +75,42 @@ const generateUniqueMarkerID = () => { }; type OnBeforeSendHeadersOptions = `${chrome.webRequest.OnBeforeSendHeadersOptions}`; -type OnHeadersReceivedOptions = `${chrome.webRequest.OnHeadersReceivedOptions}`; +type ReceiveHeaderOptions = `${chrome.webRequest.OnHeadersReceivedOptions}` & + `${chrome.webRequest.OnResponseStartedOptions}`; + +// 删除关联与DNR: 不再处理 headerModifer 时清空 Map 关联 及 浏览器 Session Rule +const headersSettled = (markerID: string) => { + const dnrRule = headerModifierMap.get(markerID); + const ruleID = dnrRule?.rule.id; + if (markerID) { + headerModifierMap.delete(markerID); + } + if (ruleID) { + chrome.declarativeNetRequest.updateSessionRules( + { + removeRuleIds: [ruleID], + }, + () => { + const lastError = chrome.runtime.lastError; + if (lastError) { + console.error("chrome.declarativeNetRequest.updateSessionRules:", lastError); + } + removeSessionRuleIdEntry(ruleID); + } + ); + } +}; + +const cleanupOnAPIError = (requestId: string) => { + const markerID = scXhrRequests.get(requestId) as string | undefined; + scXhrRequests.delete(requestId); + if (!markerID) return; + redirectedUrls.delete(markerID); + nwErrorResults.delete(markerID); + scXhrRequests.delete(markerID); + headersReceivedMap.delete(markerID); + headersSettled(markerID); // 处理完毕 +}; // GMExternalDependencies接口定义 // 为了支持外部依赖注入,方便测试和扩展 @@ -851,7 +886,7 @@ export default class GMApi { if (reqId) scXhrRequests.delete(reqId); scXhrRequests.delete(markerID); headersReceivedMap.delete(markerID); - headerModifierMap.delete(markerID); + headersSettled(markerID); // 处理完毕 }; let strategy: GMXhrStrategy | undefined = undefined; if (useFetch) { @@ -1410,6 +1445,7 @@ export default class GMApi { if (lastError) { console.error("chrome.runtime.lastError in chrome.webRequest.onBeforeRedirect:", lastError); // webRequest API 出错不进行后续处理 + cleanupOnAPIError(details?.requestId); return undefined; } if (details.tabId === -1) { @@ -1426,7 +1462,7 @@ export default class GMApi { } ); const reqOpt: OnBeforeSendHeadersOptions[] = ["requestHeaders"]; - const respOpt: OnHeadersReceivedOptions[] = ["responseHeaders"]; + const respOpt: ReceiveHeaderOptions[] = ["responseHeaders"]; if (!isFirefox()) { reqOpt.push("extraHeaders"); respOpt.push("extraHeaders"); @@ -1438,14 +1474,16 @@ export default class GMApi { if (lastError) { console.error("chrome.runtime.lastError in chrome.webRequest.onErrorOccurred:", lastError); // webRequest API 出错不进行后续处理 + cleanupOnAPIError(details?.requestId); return undefined; } if (details.tabId === -1) { const markerID = scXhrRequests.get(details.requestId); - if (markerID) { - nwErrorResults.set(markerID, details.error); - nwErrorResultPromises.get(markerID)?.(); - } + if (!markerID) return; + nwErrorResults.set(markerID, details.error); + nwErrorResultPromises.get(markerID)?.(); + + headersSettled(markerID); // 错误发生,不处理 header modifiers } }, { @@ -1502,6 +1540,7 @@ export default class GMApi { if (lastError) { console.error("chrome.runtime.lastError in chrome.webRequest.onBeforeSendHeaders:", lastError); // webRequest API 出错不进行后续处理 + cleanupOnAPIError(details?.requestId); return undefined; } if (details.tabId === -1) { @@ -1510,7 +1549,11 @@ export default class GMApi { if (requestHeaders) { // 如 onBeforeSendHeaders 是在 modifyHeaders 前执行,可以更新一下 reqId 和 markerID 的关联 const markerID = requestHeaders?.find((h) => h.name.toLowerCase() === "x-sc-request-marker")?.value; - if (markerID) scXhrRequests.set(reqId, markerID); + if (markerID) { + // 双向关联 + scXhrRequests.set(reqId, markerID); + scXhrRequests.set(markerID, reqId); // 用于清理 + } } const markerID = scXhrRequests.get(reqId); if (!markerID) return undefined; @@ -1525,12 +1568,14 @@ export default class GMApi { }, reqOpt ); + chrome.webRequest.onHeadersReceived.addListener( (details) => { const lastError = chrome.runtime.lastError; if (lastError) { console.error("chrome.runtime.lastError in chrome.webRequest.onBeforeSendHeaders:", lastError); // webRequest API 出错不进行后续处理 + cleanupOnAPIError(details?.requestId); return undefined; } if (details.tabId === -1) { @@ -1597,21 +1642,7 @@ export default class GMApi { } } ); - } else { - // 删除关联与DNR - headerModifierMap.delete(markerID); - chrome.declarativeNetRequest.updateSessionRules( - { - removeRuleIds: [rule.id], - }, - () => { - const lastError = chrome.runtime.lastError; - if (lastError) { - console.error("chrome.declarativeNetRequest.updateSessionRules:", lastError); - } - removeSessionRuleIdEntry(rule.id); - } - ); + return; } } } @@ -1624,6 +1655,38 @@ export default class GMApi { respOpt ); + chrome.webRequest.onResponseStarted.addListener( + (details) => { + // onResponseStarted 触发后,headers 不会再有改动 + const lastError = chrome.runtime.lastError; + if (lastError) { + console.error("chrome.runtime.lastError in chrome.webRequest.onResponseStarted:", lastError); + // webRequest API 出错不进行后续处理 + cleanupOnAPIError(details?.requestId); + return undefined; + } + if (details.tabId === -1) { + const reqId = details.requestId; + + const markerID = scXhrRequests.get(reqId); + if (!markerID) return; + if (details.responseHeaders && details.statusCode) { + headersReceivedMap.set(markerID, { + responseHeaders: details.responseHeaders, + statusCode: details.statusCode, + }); + } + + headersSettled(markerID); // onResponseStarted 已触发,headers 已固定 + } + }, + { + urls: [""], + types: ["xmlhttprequest"], + }, + respOpt + ); + const ruleId = 999; const rule = { id: ruleId, From 98cc9bbcd5542ea054309081b9acd3a63a3d86df Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:35:22 +0900 Subject: [PATCH 7/8] fix mock --- packages/chrome-extension-mock/web_reqeuest.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/chrome-extension-mock/web_reqeuest.ts b/packages/chrome-extension-mock/web_reqeuest.ts index 2df2e3f92..cbad97992 100644 --- a/packages/chrome-extension-mock/web_reqeuest.ts +++ b/packages/chrome-extension-mock/web_reqeuest.ts @@ -15,6 +15,12 @@ export default class WebRequest { }, }; + onResponseStarted = { + addListener: () => { + // TODO + }, + }; + onCompleted = { addListener: () => { // TODO From 276a6407e6eae039de612a460ca591852096c703 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Thu, 23 Apr 2026 01:07:46 +0800 Subject: [PATCH 8/8] =?UTF-8?q?=E5=A4=84=E7=90=86=20code=20review=20?= =?UTF-8?q?=E5=8F=8D=E9=A6=88:=20=E4=BF=AE=E5=A4=8D=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E9=94=81/=E9=94=99=E8=AF=AF=E5=9B=9E=E6=BB=9A=E5=B9=B6?= =?UTF-8?q?=E6=94=B9=E5=96=84=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dnr_id_controller: nextSessionRuleId 改为 while 循环反复判定上限, removeSessionRuleIdEntry 释放一次只放行一个 waiter,避免撑爆 LIMIT - dnr_id_controller: 初始化时用历史最大 rule id 更新 SESSION_RULE_ID_BEGIN - gm_api: headersSettled 在 lastError 时不释放 ruleID 避免本地/浏览器状态不一致 - gm_api: updateSessionRules addRules 失败时回滚 headerModifierMap 与 ruleId - gm_api: 修正注释 headerModifer -> headerModifier - chrome-extension-mock: onResponseStarted.addListener 暴露 callback 供测试触发 - dnr_id_controller.test: describe/it 标题改为中文,去除 Math.random - dnr_id_controller.test: 并发用例改为 "1 次释放 = 1 个 waiter 放行" 语义 - vitest.setup: 移除注释掉的 spyOn 调试代码 --- .../chrome-extension-mock/web_reqeuest.ts | 5 +- .../gm_api/dnr_id_controller.test.ts | 73 ++++++++++++------- .../gm_api/dnr_id_controller.ts | 23 ++++-- .../service/service_worker/gm_api/gm_api.ts | 19 +++-- tests/vitest.setup.ts | 2 - 5 files changed, 78 insertions(+), 44 deletions(-) diff --git a/packages/chrome-extension-mock/web_reqeuest.ts b/packages/chrome-extension-mock/web_reqeuest.ts index cbad97992..5427cf21e 100644 --- a/packages/chrome-extension-mock/web_reqeuest.ts +++ b/packages/chrome-extension-mock/web_reqeuest.ts @@ -2,6 +2,7 @@ import EventEmitter from "eventemitter3"; export default class WebRequest { sendHeader?: (details: chrome.webRequest.OnSendHeadersDetails) => chrome.webRequest.BlockingResponse | void; + responseStarted?: (details: chrome.webRequest.OnResponseStartedDetails) => void; onBeforeSendHeaders = { addListener: (callback: any) => { @@ -16,8 +17,8 @@ export default class WebRequest { }; onResponseStarted = { - addListener: () => { - // TODO + addListener: (callback: any) => { + this.responseStarted = callback; }, }; diff --git a/src/app/service/service_worker/gm_api/dnr_id_controller.test.ts b/src/app/service/service_worker/gm_api/dnr_id_controller.test.ts index 1b5f218ca..57cef5bcf 100644 --- a/src/app/service/service_worker/gm_api/dnr_id_controller.test.ts +++ b/src/app/service/service_worker/gm_api/dnr_id_controller.test.ts @@ -8,7 +8,7 @@ import { } from "./dnr_id_controller"; describe("getSessionRuleIds", () => { - it("initializes from existing chrome session rules", async () => { + it("从现有 chrome session rules 初始化", async () => { const ids = await getSessionRuleIds(); expect(ids.size).lessThan(100); await nextSessionRuleId(); @@ -19,7 +19,7 @@ describe("getSessionRuleIds", () => { }); describe("nextSessionRuleId", () => { - it("returns unique incrementing IDs on each call", async () => { + it("每次调用返回唯一递增的 id", async () => { const id1 = await nextSessionRuleId(); const id2 = await nextSessionRuleId(); const id3 = await nextSessionRuleId(); @@ -28,7 +28,7 @@ describe("nextSessionRuleId", () => { expect(id3).toBeGreaterThan(id2); }); - it("skips IDs that already exist in session rules", async () => { + it("跳过已存在于 session rules 中的 id", async () => { const ids = await getSessionRuleIds(); const next = await nextSessionRuleId(); @@ -40,7 +40,7 @@ describe("nextSessionRuleId", () => { }); describe("removeSessionRuleIdEntry", () => { - it("removes the given ID from the tracked set", async () => { + it("从追踪集合中移除指定 id", async () => { const ids = await getSessionRuleIds(); const id = await nextSessionRuleId(); @@ -50,16 +50,16 @@ describe("removeSessionRuleIdEntry", () => { expect(ids.has(id)).toBe(false); }); - it("is a no-op when called before sessionRuleIds is initialized", () => { + it("sessionRuleIds 未初始化时为 no-op", () => { expect(() => removeSessionRuleIdEntry(10404)).not.toThrow(); }); - it("throws when ruleId is <= 10000", () => { + it("ruleId <= 10000 时抛错", () => { expect(() => removeSessionRuleIdEntry(10000)).toThrow(); expect(() => removeSessionRuleIdEntry(1)).toThrow(); }); - it("rewinds SESSION_RULE_ID_BEGIN so the removed id gets reused next", async () => { + it("回退 SESSION_RULE_ID_BEGIN 使被移除的 id 下次被复用", async () => { const ids = await getSessionRuleIds(); const id1 = await nextSessionRuleId(); @@ -70,13 +70,13 @@ describe("removeSessionRuleIdEntry", () => { ids.add(id2); ids.add(id3); - // Remove the latest — counter should rewind and reuse it + // 移除最新的 id,counter 回退并复用该 id removeSessionRuleIdEntry(id3); const reused = await nextSessionRuleId(); expect(reused).toBe(id3); }); - it("rewinds SESSION_RULE_ID_BEGIN when removed id is behind the counter", async () => { + it("移除的 id 在 counter 之前时仍会回退", async () => { const ids = await getSessionRuleIds(); const id1 = await nextSessionRuleId(); @@ -87,13 +87,13 @@ describe("removeSessionRuleIdEntry", () => { ids.add(id2); ids.add(id3); - // Remove an older id — counter rewinds to id1 - 1, so id1 gets reused + // 移除较早的 id,counter 回退到 id1 - 1,下次分配会得到 id1 removeSessionRuleIdEntry(id1); const reused = await nextSessionRuleId(); expect(reused).toBe(id1); }); - it("does not rewind SESSION_RULE_ID_BEGIN when removed id was pre-existing (ahead of counter)", async () => { + it("移除 counter 之后的 id 不回退 counter", async () => { const id1 = await nextSessionRuleId(); const id2 = await nextSessionRuleId(); const id3 = await nextSessionRuleId(); @@ -109,16 +109,16 @@ describe("removeSessionRuleIdEntry", () => { const nextBefore = await nextSessionRuleId(); // e.g. 10001 removeSessionRuleIdEntry(id6); - expect(await nextSessionRuleId()).toBe(nextBefore + 1); // counter unchanged, just increments normally - expect(await nextSessionRuleId()).toBe(nextBefore + 2); // counter unchanged, just increments normally - // expect(await nextSessionRuleId()).toBe(nextBefore + 3); // counter unchanged, just increments normally - expect(await nextSessionRuleId()).toBe(nextBefore + 4); // counter unchanged, just increments normally - expect(await nextSessionRuleId()).toBe(nextBefore + 5); // counter unchanged, just increments normally + expect(await nextSessionRuleId()).toBe(nextBefore + 1); // counter 未变,正常递增 + expect(await nextSessionRuleId()).toBe(nextBefore + 2); + // expect(await nextSessionRuleId()).toBe(nextBefore + 3); + expect(await nextSessionRuleId()).toBe(nextBefore + 4); + expect(await nextSessionRuleId()).toBe(nextBefore + 5); }); }); describe("nextSessionRuleId limit control", () => { - it("locks when session rules reach the limit and unlocks when an entry is removed", async () => { + it("达到上限时锁定,移除条目后解锁", async () => { const ids = await getSessionRuleIds(); expect(ids.size).toBeLessThan(100); @@ -135,9 +135,9 @@ describe("nextSessionRuleId limit control", () => { const raceResult1 = await Promise.race([lockedPromise.then(() => "resolved"), sleep(5).then(() => "pending")]); expect(raceResult1).toBe("pending"); - const m1 = Math.floor(Math.random() * (added.length - 9)); - const p1 = added[m1]; - const p2 = added[m1 + 6]; + // 使用固定索引而非随机,保证测试可重复 + const p1 = added[0]; + const p2 = added[6]; removeSessionRuleIdEntry(p1); removeSessionRuleIdEntry(p2); @@ -158,7 +158,7 @@ describe("nextSessionRuleId limit control", () => { expect(res.size).toBeLessThan(100); }); - it("only one lock is created even with concurrent nextSessionRuleId calls", async () => { + it("单次移除仅放行 1 个 waiter,其余继续等待", async () => { const ids = await getSessionRuleIds(); expect(ids.size).toBeLessThan(100); @@ -170,7 +170,7 @@ describe("nextSessionRuleId limit control", () => { } expect(ids.size).toBeGreaterThan(1000); - // Fire multiple concurrent calls while locked + // 在已达上限时发起多个并发调用 const p1 = nextSessionRuleId(); const p2 = nextSessionRuleId(); const p3 = nextSessionRuleId(); @@ -181,16 +181,33 @@ describe("nextSessionRuleId limit control", () => { ]); expect(raceResult).toBe("pending"); - // Single removal should unlock all waiters sequentially - const toRemove = [...ids].find((id) => id > 10000)!; - removeSessionRuleIdEntry(toRemove); + // 单次释放 1 个 slot: 只应放行 1 个 waiter,剩余仍挂起 + removeSessionRuleIdEntry(added[0]); + const firstResolved = await Promise.race([p1.then(() => "resolved"), sleep(50).then(() => "pending")]); + expect(firstResolved).toBe("resolved"); - // All three should eventually resolve - const results = await Promise.race([Promise.all([p1, p2, p3]).then((ids) => ids), sleep(50).then(() => null)]); + const remainingStillPending = await Promise.race([ + Promise.all([p2, p3]).then(() => "resolved"), + sleep(5).then(() => "pending"), + ]); + expect(remainingStillPending).toBe("pending"); + + // 继续释放 2 个 slot,剩余 waiter 才能继续完成 + removeSessionRuleIdEntry(added[1]); + removeSessionRuleIdEntry(added[2]); + const results = await Promise.race([Promise.all([p2, p3]).then((vals) => vals), sleep(50).then(() => null)]); expect(results).not.toBeNull(); + // 任何时候 size 都不应超过 LIMIT_SESSION_RULES + expect(ids.size).toBeLessThanOrEqual(LIMIT_SESSION_RULES); + for (const k of added) { - removeSessionRuleIdEntry(k); + if (ids.has(k)) removeSessionRuleIdEntry(k); + } + // p1/p2/p3 分配的 id 也要清理 + const allocated = [await p1, ...(results as number[])]; + for (const k of allocated) { + if (ids.has(k)) removeSessionRuleIdEntry(k); } const res = await getSessionRuleIds(); diff --git a/src/app/service/service_worker/gm_api/dnr_id_controller.ts b/src/app/service/service_worker/gm_api/dnr_id_controller.ts index 83db1ce6c..2faf581fb 100644 --- a/src/app/service/service_worker/gm_api/dnr_id_controller.ts +++ b/src/app/service/service_worker/gm_api/dnr_id_controller.ts @@ -14,7 +14,13 @@ export const getSessionRuleIds = async (): Promise> => { sessionRuleIdsPromise = chrome.declarativeNetRequest .getSessionRules() .then((rules) => { - sessionRuleIds = new Set(rules.map((rule) => rule.id).filter(Boolean)); + const existingRuleIds = rules.map((rule) => rule.id).filter(Boolean); + // 根据历史 session rule 的最大 id 更新 SESSION_RULE_ID_BEGIN + // 避免 SW 重启后从 10001 起做大量 do/while 递增扫描 + if (existingRuleIds.length > 0) { + SESSION_RULE_ID_BEGIN = Math.max(SESSION_RULE_ID_BEGIN, ...existingRuleIds); + } + sessionRuleIds = new Set(existingRuleIds); return sessionRuleIds; }) .catch((e) => { @@ -36,18 +42,21 @@ export const removeSessionRuleIdEntry = (ruleId: number) => { if (ruleId <= SESSION_RULE_ID_BEGIN + 1) { SESSION_RULE_ID_BEGIN = ruleId - 1; } - if (sessionRuleIds.size < LIMIT_SESSION_RULES) { - lockSessionRuleCreation?.resolve(); - lockSessionRuleCreation = null; - } + // 唤醒所有等待者: 每个等待者会在 while 循环内重新判定 size 并决定是继续分配还是再次等待 + // 这样 "1 次释放 => 1 个等待者通过",避免 N 个等待者同时放行再次撑爆上限 + lockSessionRuleCreation?.resolve(); + lockSessionRuleCreation = null; } } }; export const nextSessionRuleId = async () => { const ruleIds = await getSessionRuleIds(); - if (!lockSessionRuleCreation && ruleIds.size + 1 > LIMIT_SESSION_RULES) lockSessionRuleCreation = deferred(); - if (lockSessionRuleCreation) await lockSessionRuleCreation.promise; + // 用 while 循环反复判定上限: 等待者被唤醒后如果 slot 已被其他等待者抢占,会再次进入等待 + while (ruleIds.size + 1 > LIMIT_SESSION_RULES) { + if (!lockSessionRuleCreation) lockSessionRuleCreation = deferred(); + await lockSessionRuleCreation.promise; + } let id; do { id = ++SESSION_RULE_ID_BEGIN; 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 4ed8529db..d9e761534 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -78,7 +78,7 @@ type OnBeforeSendHeadersOptions = `${chrome.webRequest.OnBeforeSendHeadersOption type ReceiveHeaderOptions = `${chrome.webRequest.OnHeadersReceivedOptions}` & `${chrome.webRequest.OnResponseStartedOptions}`; -// 删除关联与DNR: 不再处理 headerModifer 时清空 Map 关联 及 浏览器 Session Rule +// 删除关联与DNR: 不再处理 headerModifier 时清空 Map 关联 及 浏览器 Session Rule const headersSettled = (markerID: string) => { const dnrRule = headerModifierMap.get(markerID); const ruleID = dnrRule?.rule.id; @@ -93,7 +93,9 @@ const headersSettled = (markerID: string) => { () => { const lastError = chrome.runtime.lastError; if (lastError) { + // removeRuleIds 失败: 浏览器里仍保留该规则,本地不释放 ruleID 避免复用 console.error("chrome.declarativeNetRequest.updateSessionRules:", lastError); + return; } removeSessionRuleIdEntry(ruleID); } @@ -738,10 +740,17 @@ export default class GMApi { }, } as chrome.declarativeNetRequest.Rule; headerModifierMap.set(markerID, { rule, redirectNotManual }); - await chrome.declarativeNetRequest.updateSessionRules({ - removeRuleIds: [ruleId], - addRules: [rule], - }); + try { + await chrome.declarativeNetRequest.updateSessionRules({ + removeRuleIds: [ruleId], + addRules: [rule], + }); + } catch (e) { + // addRules 失败: 回滚本地 headerModifierMap 关联并释放 ruleId,避免永久占位导致限额锁死 + headerModifierMap.delete(markerID); + removeSessionRuleIdEntry(ruleId); + throw e; + } } return true; } diff --git a/tests/vitest.setup.ts b/tests/vitest.setup.ts index 178d5a5e7..8dcc95fc4 100644 --- a/tests/vitest.setup.ts +++ b/tests/vitest.setup.ts @@ -9,8 +9,6 @@ import { mockFetch } from "./mocks/fetch"; vi.stubGlobal("chrome", chromeMock); chromeMock.init(); -// vi.spyOn(chromeMock.declarativeNetRequest, "getSessionRules"); -// vi.spyOn(chromeMock.declarativeNetRequest, "updateSessionRules"); initTestEnv(); chromeMock.runtime.getURL = vi.fn().mockImplementation((path: string) => {