From 652e9a03789cccc0abb8def11efd4937bc11cab4 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 26 Apr 2026 21:01:29 +0900 Subject: [PATCH 1/4] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20`synchronize.ts`=20?= =?UTF-8?q?=E9=81=BF=E5=85=8D=20=E8=AF=AF=E5=88=A4=E5=92=8C=E8=AF=AF?= =?UTF-8?q?=E5=88=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service_worker/synchronize.test.ts | 196 ++++++++++++++++++ src/app/service/service_worker/synchronize.ts | 87 +++++--- 2 files changed, 250 insertions(+), 33 deletions(-) create mode 100644 src/app/service/service_worker/synchronize.test.ts diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts new file mode 100644 index 000000000..4b238653f --- /dev/null +++ b/src/app/service/service_worker/synchronize.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { SynchronizeService } from "./synchronize"; +import { initTestEnv } from "@Tests/utils"; +import type FileSystem from "@Packages/filesystem/filesystem"; +import type { CloudSyncConfig } from "@App/pkg/config/config"; + +initTestEnv(); + +const syncConfig: CloudSyncConfig = { + enable: true, + syncDelete: true, + syncStatus: true, + filesystem: "webdav", + params: {}, +}; + +const createFs = (overrides: Partial = {}): FileSystem => + ({ + verify: vi.fn().mockResolvedValue(undefined), + list: vi.fn().mockResolvedValue([]), + open: vi.fn(), + openDir: vi.fn(), + create: vi.fn().mockResolvedValue({ + write: vi.fn().mockResolvedValue(undefined), + }), + createDir: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + getDirUrl: vi.fn().mockResolvedValue(""), + ...overrides, + }) as unknown as FileSystem; + +describe("SynchronizeService", () => { + beforeEach(() => { + vi.clearAllMocks(); + chrome.storage.local.clear(); + }); + + it("serializes concurrent syncOnce calls", async () => { + let releaseFirst!: () => void; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + const order: string[] = []; + const fs1 = createFs({ + list: vi + .fn() + .mockImplementationOnce(async () => { + order.push("first:start"); + await firstGate; + order.push("first:end"); + return []; + }) + .mockResolvedValue([]), + }); + const fs2 = createFs({ + list: vi + .fn() + .mockImplementationOnce(async () => { + order.push("second:start"); + return []; + }) + .mockResolvedValue([]), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([]), + } as any + ); + + const first = service.syncOnce(syncConfig, fs1); + await Promise.resolve(); + const second = service.syncOnce(syncConfig, fs2); + await Promise.resolve(); + + expect(order).toEqual(["first:start"]); + expect((fs2.list as any).mock.calls.length).toBe(0); + + releaseFirst(); + await Promise.all([first, second]); + + expect(order).toEqual(["first:start", "first:end", "second:start"]); + }); + + it("does not delete orphan cloud script without meta", async () => { + const fs = createFs({ + list: vi.fn().mockResolvedValue([ + { + name: "orphan.user.js", + path: "orphan.user.js", + size: 1, + digest: "d1", + createtime: 1, + updatetime: 1, + }, + ]), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([]), + } as any + ); + + await service.syncOnce(syncConfig, fs); + + expect(fs.delete).not.toHaveBeenCalled(); + }); + + it("waits for installScript during pullScript", async () => { + let releaseInstall!: () => void; + const installGate = new Promise((resolve) => { + releaseInstall = resolve; + }); + const installScript = vi.fn().mockImplementation(() => installGate); + const fs = createFs({ + open: vi.fn().mockImplementation(async (file) => ({ + read: vi.fn().mockResolvedValue( + file.name.endsWith(".user.js") + ? `// ==UserScript== +// @name Pull Test +// @namespace sync-test +// @match https://example.com/* +// ==/UserScript== +console.log("ok");` + : JSON.stringify({ uuid: "pull-uuid" }) + ), + })), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + { + installScript, + } as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: {}, + } as any + ); + + let settled = false; + const pullPromise = service + .pullScript( + fs, + { + script: { + name: "pull-uuid.user.js", + path: "pull-uuid.user.js", + size: 1, + digest: "d1", + createtime: 1, + updatetime: 1, + }, + meta: { + name: "pull-uuid.meta.json", + path: "pull-uuid.meta.json", + size: 1, + digest: "d2", + createtime: 1, + updatetime: 1, + }, + }, + undefined + ) + .then(() => { + settled = true; + }); + + await Promise.resolve(); + expect(settled).toBe(false); + + releaseInstall(); + await pullPromise; + + expect(installScript).toHaveBeenCalledTimes(1); + expect(settled).toBe(true); + }); +}); diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index 9d2c00a7f..7ab83377d 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -33,6 +33,7 @@ import { ExtVersion } from "@App/app/const"; import { dayFormat } from "@App/pkg/utils/day_format"; import i18n, { i18nName } from "@App/locales/locales"; import { InfoNotification } from "./utils"; +import { stackAsyncTask } from "@App/pkg/utils/async_queue"; // type SynchronizeTarget = "local"; @@ -66,6 +67,8 @@ type ScriptcatSyncStatus = { type PushScriptParam = TInstallScriptParams; +const SYNC_SERVICE_TASK_KEY = "cloud_sync"; + export class SynchronizeService { logger: Logger; @@ -329,6 +332,10 @@ export class SynchronizeService { // 同步一次 async syncOnce(syncConfig: CloudSyncConfig, fs: FileSystem) { + return stackAsyncTask(SYNC_SERVICE_TASK_KEY, () => this.syncOnceInternal(syncConfig, fs)); + } + + private async syncOnceInternal(syncConfig: CloudSyncConfig, fs: FileSystem) { this.logger.info("start sync once"); // 获取文件列表 const list = await fs.list(); @@ -402,7 +409,7 @@ export class SynchronizeService { const metaObj = JSON.parse(metaJson) as SyncMeta; if (metaObj.isDeleted) { // 删除脚本 - this.script.deleteScript(script.uuid, "sync"); + await this.script.deleteScript(script.uuid, "sync"); InfoNotification( i18n.t("notification.script_sync_delete"), i18n.t("notification.script_sync_delete_desc", { scriptName: i18nName(script) }) @@ -410,7 +417,7 @@ export class SynchronizeService { } else { // 否则认为是一个无效的.meta文件,进行删除,并进行同步 await fs.delete(file.meta!.name); - result.push(this.pushScript(fs, script)); + await this.pushScript(fs, script); } })() ); @@ -436,8 +443,11 @@ export class SynchronizeService { // 如果脚本不存在,但文件存在,则安装脚本 if (file.script) { if (!file.meta) { - // 如果.meta文件不存在,则删除脚本文件,并跳过 - result.push(fs.delete(file.script.name)); + // .meta 文件可能尚未上传完成,跳过本次以避免误删云端脚本 + this.logger.warn("skip orphan cloud script without meta", { + uuid, + file: file.script.name, + }); return; } updateScript.set(uuid, true); @@ -616,7 +626,7 @@ export class SynchronizeService { script.status = status.enable ? SCRIPT_STATUS_ENABLE : SCRIPT_STATUS_DISABLE; } } - this.script.installScript({ + await this.script.installScript({ script, code, upsertBy: "sync", @@ -630,33 +640,37 @@ export class SynchronizeService { cloudSyncConfigChange(value: CloudSyncConfig) { if (value.enable) { // 开启云同步同步 - this.buildFileSystem(value).then(async (fs) => { - await this.syncOnce(value, fs); - // 开启定时器, 一小时一次 - chrome.alarms.get("cloudSync", (alarm) => { - const lastError = chrome.runtime.lastError; - if (lastError) { - console.error("chrome.runtime.lastError in chrome.alarms.get:", lastError); - // 非预期的异常API错误,停止处理 - } - if (!alarm) { - chrome.alarms.create( - "cloudSync", - { - periodInMinutes: 60, - }, - () => { - const lastError = chrome.runtime.lastError; - if (lastError) { - console.error("chrome.runtime.lastError in chrome.alarms.create:", lastError); - // Starting in Chrome 117, the number of active alarms is limited to 500. Once this limit is reached, chrome.alarms.create() will fail. - console.error("Chrome alarm is unable to create. Please check whether limit is reached."); + this.buildFileSystem(value) + .then(async (fs) => { + await this.syncOnce(value, fs); + // 开启定时器, 一小时一次 + chrome.alarms.get("cloudSync", (alarm) => { + const lastError = chrome.runtime.lastError; + if (lastError) { + console.error("chrome.runtime.lastError in chrome.alarms.get:", lastError); + // 非预期的异常API错误,停止处理 + } + if (!alarm) { + chrome.alarms.create( + "cloudSync", + { + periodInMinutes: 60, + }, + () => { + const lastError = chrome.runtime.lastError; + if (lastError) { + console.error("chrome.runtime.lastError in chrome.alarms.create:", lastError); + // Starting in Chrome 117, the number of active alarms is limited to 500. Once this limit is reached, chrome.alarms.create() will fail. + console.error("Chrome alarm is unable to create. Please check whether limit is reached."); + } } - } - ); - } + ); + } + }); + }) + .catch((e) => { + this.logger.error("cloud sync config change error", Logger.E(e)); }); - }); } else { // 停止计时器 chrome.alarms.clear("cloudSync"); @@ -670,9 +684,12 @@ export class SynchronizeService { // 判断是否开启了同步 const config = await this.systemConfig.getCloudSync(); if (config.enable) { - this.buildFileSystem(config).then(async (fs) => { + stackAsyncTask(SYNC_SERVICE_TASK_KEY, async () => { + const fs = await this.buildFileSystem(config); await this.pushScript(fs, params.script); - this.updateFileDigest(fs); + await this.updateFileDigest(fs); + }).catch((e) => { + this.logger.error("push script on install error", Logger.E(e)); }); } } @@ -681,13 +698,17 @@ export class SynchronizeService { // 判断是否开启了同步 const config = await this.systemConfig.getCloudSync(); if (config.enable) { - this.buildFileSystem(config).then(async (fs) => { + stackAsyncTask(SYNC_SERVICE_TASK_KEY, async () => { + const fs = await this.buildFileSystem(config); for (const { uuid, deleteBy } of data) { if (deleteBy === "sync") { continue; } await this.deleteCloudScript(fs, uuid, config.syncDelete); } + await this.updateFileDigest(fs); + }).catch((e) => { + this.logger.error("delete cloud script error", Logger.E(e)); }); } } From 462a25e941cabd867f5512f29d5af337598ae636 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 27 Apr 2026 12:00:48 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E2=9C=85=20=E8=A1=A5=E5=85=85=20synchroniz?= =?UTF-8?q?e=20=E6=B5=8B=E8=AF=95=E5=B9=B6=E4=BF=9D=E7=95=99=20orphan=20?= =?UTF-8?q?=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 deleteScript/pushScript 等待 digest 更新的回归测试 - 新增 orphan uuid cloudStatus 保留的回归测试 - syncOnce 加 try/catch 避免错误冒泡破坏队列后续任务 - 跳过 orphan 时保留其云端 status,避免覆盖另一台设备的半上传状态 --- .../service_worker/synchronize.test.ts | 246 ++++++++++++++++-- src/app/service/service_worker/synchronize.ts | 21 +- 2 files changed, 243 insertions(+), 24 deletions(-) diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index 4b238653f..326fb46f8 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -29,6 +29,13 @@ const createFs = (overrides: Partial = {}): FileSystem => ...overrides, }) as unknown as FileSystem; +// 等待若干轮微任务,确保所有已就绪的 await 都被推进 +const flushMicrotasks = async (rounds = 10) => { + for (let i = 0; i < rounds; i++) { + await Promise.resolve(); + } +}; + describe("SynchronizeService", () => { beforeEach(() => { vi.clearAllMocks(); @@ -41,25 +48,26 @@ describe("SynchronizeService", () => { releaseFirst = resolve; }); const order: string[] = []; - const fs1 = createFs({ - list: vi - .fn() - .mockImplementationOnce(async () => { - order.push("first:start"); - await firstGate; - order.push("first:end"); - return []; - }) - .mockResolvedValue([]), - }); + // gate 放在第一轮的最后一步(updateFileDigest 内部的 fs.list) + // 这样如果未来锁粒度被改小,第二轮提前进入也会被这个测试捕获 + const fs1List = vi + .fn() + .mockImplementationOnce(async () => { + order.push("first:list"); + return []; + }) + .mockImplementationOnce(async () => { + order.push("first:digest"); + await firstGate; + order.push("first:end"); + return []; + }); + const fs1 = createFs({ list: fs1List }); const fs2 = createFs({ - list: vi - .fn() - .mockImplementationOnce(async () => { - order.push("second:start"); - return []; - }) - .mockResolvedValue([]), + list: vi.fn().mockImplementation(async () => { + order.push("second:list"); + return []; + }), }); const service = new SynchronizeService( {} as any, @@ -76,17 +84,18 @@ describe("SynchronizeService", () => { ); const first = service.syncOnce(syncConfig, fs1); - await Promise.resolve(); const second = service.syncOnce(syncConfig, fs2); - await Promise.resolve(); + await flushMicrotasks(); - expect(order).toEqual(["first:start"]); + // 第一轮已经跑到末尾的 updateFileDigest,第二轮一步都没开始 + expect(order).toEqual(["first:list", "first:digest"]); expect((fs2.list as any).mock.calls.length).toBe(0); releaseFirst(); await Promise.all([first, second]); - expect(order).toEqual(["first:start", "first:end", "second:start"]); + // 第一轮整体结束(first:end)后第二轮才能开始(second:list) + expect(order.slice(0, 4)).toEqual(["first:list", "first:digest", "first:end", "second:list"]); }); it("does not delete orphan cloud script without meta", async () => { @@ -121,6 +130,61 @@ describe("SynchronizeService", () => { expect(fs.delete).not.toHaveBeenCalled(); }); + it("preserves cloudStatus for skipped orphan uuid when writing scriptcat-sync.json", async () => { + const orphanStatus = { enable: false, sort: 7, updatetime: 100 }; + const writeMock = vi.fn().mockResolvedValue(undefined); + const fs = createFs({ + list: vi.fn().mockResolvedValue([ + { + name: "orphan.user.js", + path: "orphan.user.js", + size: 1, + digest: "d1", + createtime: 1, + updatetime: 1, + }, + { + name: "scriptcat-sync.json", + path: "scriptcat-sync.json", + size: 1, + digest: "d2", + createtime: 1, + updatetime: 1, + }, + ]), + open: vi.fn().mockResolvedValue({ + read: vi.fn().mockResolvedValue( + JSON.stringify({ + version: "1.0.0", + status: { scripts: { orphan: orphanStatus } }, + }) + ), + }), + create: vi.fn().mockResolvedValue({ write: writeMock }), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([]), + } as any + ); + + await service.syncOnce(syncConfig, fs); + + // 第一次 write 是 scriptcat-sync.json 的内容 + expect(writeMock).toHaveBeenCalled(); + const writtenContent = writeMock.mock.calls[0][0] as string; + const written = JSON.parse(writtenContent); + expect(written.status.scripts.orphan).toEqual(orphanStatus); + }); + it("waits for installScript during pullScript", async () => { let releaseInstall!: () => void; const installGate = new Promise((resolve) => { @@ -193,4 +257,142 @@ console.log("ok");` expect(installScript).toHaveBeenCalledTimes(1); expect(settled).toBe(true); }); + + it("waits for deleteScript before updating file digest", async () => { + let releaseDelete!: () => void; + const deleteGate = new Promise((resolve) => { + releaseDelete = resolve; + }); + const order: string[] = []; + const deleteScript = vi.fn().mockImplementation(async () => { + order.push("delete:start"); + await deleteGate; + order.push("delete:end"); + }); + // fs.list 第二次调用对应 updateFileDigest,这是 syncOnce 的最后一步 + const fsList = vi + .fn() + .mockImplementationOnce(async () => [ + { + name: "del-uuid.meta.json", + path: "del-uuid.meta.json", + size: 1, + digest: "d1", + createtime: 1, + updatetime: 1, + }, + ]) + .mockImplementationOnce(async () => { + order.push("digest:list"); + return []; + }); + const fs = createFs({ + list: fsList, + open: vi.fn().mockResolvedValue({ + read: vi.fn().mockResolvedValue(JSON.stringify({ uuid: "del-uuid", isDeleted: true })), + }), + }); + const service = new SynchronizeService( + {} as any, + {} as any, + { deleteScript } as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([ + { + uuid: "del-uuid", + name: "del", + updatetime: 1, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }, + ]), + } as any + ); + + const promise = service.syncOnce(syncConfig, fs); + await flushMicrotasks(); + + // delete 已经开始但没结束,updateFileDigest 还没被调用 + expect(order).toEqual(["delete:start"]); + + releaseDelete(); + await promise; + + // delete 必须在 updateFileDigest 之前完成 + expect(order).toEqual(["delete:start", "delete:end", "digest:list"]); + }); + + it("waits for pushScript before updating file digest", async () => { + let releasePush!: () => void; + const pushGate = new Promise((resolve) => { + releasePush = resolve; + }); + const order: string[] = []; + // pushScript 内部第一步是 fs.create(uuid.user.js),gate 在这里就能拦住整个 push + const fsCreate = vi.fn().mockImplementation(async (filename: string) => { + if (filename === "push-uuid.user.js") { + order.push("push:start"); + await pushGate; + order.push("push:end"); + } + return { write: vi.fn().mockResolvedValue(undefined) }; + }); + const fsList = vi + .fn() + .mockImplementationOnce(async () => []) + .mockImplementationOnce(async () => { + order.push("digest:list"); + return []; + }); + const fs = createFs({ + list: fsList, + create: fsCreate, + }); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: { + get: vi.fn().mockResolvedValue({ code: "// code" }), + }, + all: vi.fn().mockResolvedValue([ + { + uuid: "push-uuid", + name: "push", + updatetime: 1, + createtime: 1, + status: 1, + sort: 0, + metadata: {}, + }, + ]), + } as any + ); + + const promise = service.syncOnce(syncConfig, fs); + await flushMicrotasks(); + + // push 已经开始但没结束,updateFileDigest 还没被调用 + expect(order).toEqual(["push:start"]); + + releasePush(); + await promise; + + // push 必须在 updateFileDigest 之前完成 + expect(order).toContain("push:end"); + expect(order).toContain("digest:list"); + expect(order.indexOf("push:end")).toBeLessThan(order.indexOf("digest:list")); + }); }); diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index 7ab83377d..2d9ab368d 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -67,7 +67,7 @@ type ScriptcatSyncStatus = { type PushScriptParam = TInstallScriptParams; -const SYNC_SERVICE_TASK_KEY = "cloud_sync"; +const SYNC_SERVICE_TASK_KEY = "cloud_sync_queue"; export class SynchronizeService { logger: Logger; @@ -332,7 +332,13 @@ export class SynchronizeService { // 同步一次 async syncOnce(syncConfig: CloudSyncConfig, fs: FileSystem) { - return stackAsyncTask(SYNC_SERVICE_TASK_KEY, () => this.syncOnceInternal(syncConfig, fs)); + return stackAsyncTask(SYNC_SERVICE_TASK_KEY, async () => { + try { + await this.syncOnceInternal(syncConfig, fs); + } catch (e) { + this.logger.error("sync once error", Logger.E(e)); + } + }); } private async syncOnceInternal(syncConfig: CloudSyncConfig, fs: FileSystem) { @@ -393,6 +399,9 @@ export class SynchronizeService { // 对比脚本列表和文件列表,进行同步 const result: Promise[] = []; const updateScript: Map = new Map(); + // 记录被跳过的孤儿云端脚本(仅 .user.js 无 .meta.json) + // 避免本机回写 scriptcat-sync.json 时丢失对应 uuid 的云端 status + const skippedOrphanUuids = new Set(); // 需要是同步操作,后续上传剩下的脚本 // 最后使用 Promise.allSettled 进行等待 uuidMap.forEach((file, uuid) => { @@ -448,6 +457,7 @@ export class SynchronizeService { uuid, file: file.script.name, }); + skippedOrphanUuids.add(uuid); return; } updateScript.set(uuid, true); @@ -509,6 +519,13 @@ export class SynchronizeService { } }) ); + // 保留被跳过的 orphan uuid 的云端 status,避免覆盖另一台设备半上传的状态 + skippedOrphanUuids.forEach((uuid) => { + const status = cloudStatus[uuid]; + if (status) { + scriptcatSync.status.scripts[uuid] = status; + } + }); // 保存脚本猫同步状态 const syncFile = await fs.create("scriptcat-sync.json"); await syncFile.write(JSON.stringify(scriptcatSync, null, 2)); From f3ab913c51d31289f97b8952760173e65ccc0751 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 27 Apr 2026 12:05:35 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E2=9C=85=20=E8=A1=A5=E9=BD=90=20synchroniz?= =?UTF-8?q?e=20=E9=98=9F=E5=88=97=E4=B8=8E=E9=94=99=E8=AF=AF=E5=85=9C?= =?UTF-8?q?=E5=BA=95=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scriptInstall 走 cloud_sync 队列,且 push 后才更新 digest - scriptsDelete 走同一队列,跳过 deleteBy=sync,结束后更新 digest - cloudSyncConfigChange 的 buildFileSystem 失败被 .catch 吞掉 --- .../service_worker/synchronize.test.ts | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index 326fb46f8..242a946b9 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -3,6 +3,7 @@ import { SynchronizeService } from "./synchronize"; import { initTestEnv } from "@Tests/utils"; import type FileSystem from "@Packages/filesystem/filesystem"; import type { CloudSyncConfig } from "@App/pkg/config/config"; +import { stackAsyncTask } from "@App/pkg/utils/async_queue"; initTestEnv(); @@ -395,4 +396,182 @@ console.log("ok");` expect(order).toContain("digest:list"); expect(order.indexOf("push:end")).toBeLessThan(order.indexOf("digest:list")); }); + + it("scriptInstall enters cloud_sync queue and updates digest after push", async () => { + let releaseSync!: () => void; + const syncGate = new Promise((resolve) => { + releaseSync = resolve; + }); + const order: string[] = []; + + // 第一个占用队列的 syncOnce,gate 在 updateFileDigest 阶段 + const syncFs = createFs({ + list: vi + .fn() + .mockImplementationOnce(async () => { + order.push("sync:list"); + return []; + }) + .mockImplementationOnce(async () => { + order.push("sync:digest"); + await syncGate; + return []; + }), + }); + + const installFs = createFs(); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + getCloudSync: vi.fn().mockResolvedValue({ ...syncConfig, enable: true }), + } as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([]), + } as any + ); + + vi.spyOn(service as any, "buildFileSystem").mockResolvedValue(installFs); + vi.spyOn(service, "pushScript").mockImplementation(async () => { + order.push("install:push"); + }); + const realUpdateDigest = service.updateFileDigest.bind(service); + vi.spyOn(service, "updateFileDigest").mockImplementation(async (fs) => { + if (fs === installFs) { + order.push("install:digest"); + return; + } + await realUpdateDigest(fs); + }); + + const syncPromise = service.syncOnce(syncConfig, syncFs); + await flushMicrotasks(); + expect(order).toEqual(["sync:list", "sync:digest"]); + + await service.scriptInstall({ + script: { uuid: "u1", name: "t" } as any, + upsertBy: "user", + } as any); + await flushMicrotasks(); + + // syncOnce 还没释放,install 的 pushScript 不能跑 + expect(order).toEqual(["sync:list", "sync:digest"]); + + releaseSync(); + await syncPromise; + + // 在同一队列上排一个 barrier,barrier 完成意味着 install 任务也已完成 + await stackAsyncTask("cloud_sync_queue", async () => "barrier"); + + expect(order).toEqual(["sync:list", "sync:digest", "install:push", "install:digest"]); + }); + + it("scriptsDelete enters cloud_sync queue and updates digest after deleting", async () => { + let releaseSync!: () => void; + const syncGate = new Promise((resolve) => { + releaseSync = resolve; + }); + const order: string[] = []; + + const syncFs = createFs({ + list: vi + .fn() + .mockImplementationOnce(async () => { + order.push("sync:list"); + return []; + }) + .mockImplementationOnce(async () => { + order.push("sync:digest"); + await syncGate; + return []; + }), + }); + + const deleteFs = createFs(); + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + getCloudSync: vi.fn().mockResolvedValue({ ...syncConfig, enable: true }), + } as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([]), + } as any + ); + + vi.spyOn(service as any, "buildFileSystem").mockResolvedValue(deleteFs); + vi.spyOn(service, "deleteCloudScript").mockImplementation(async (_fs: any, uuid: string) => { + order.push(`delete:${uuid}`); + }); + const realUpdateDigest = service.updateFileDigest.bind(service); + vi.spyOn(service, "updateFileDigest").mockImplementation(async (fs) => { + if (fs === deleteFs) { + order.push("delete:digest"); + return; + } + await realUpdateDigest(fs); + }); + + const syncPromise = service.syncOnce(syncConfig, syncFs); + await flushMicrotasks(); + expect(order).toEqual(["sync:list", "sync:digest"]); + + await service.scriptsDelete([ + { uuid: "from-user", deleteBy: "user" } as any, + { uuid: "from-sync", deleteBy: "sync" } as any, + ]); + await flushMicrotasks(); + + // syncOnce 还没释放,delete 任务一步都不能跑 + expect(order).toEqual(["sync:list", "sync:digest"]); + + releaseSync(); + await syncPromise; + await stackAsyncTask("cloud_sync_queue", async () => "barrier"); + + // deleteBy === "sync" 的不应触发云端删除;并且 digest 必须在删除全部完成后才更新 + expect(order).toEqual(["sync:list", "sync:digest", "delete:from-user", "delete:digest"]); + }); + + it("cloudSyncConfigChange swallows buildFileSystem error", async () => { + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([]), + } as any + ); + + const buildErr = new Error("build fs failed"); + vi.spyOn(service as any, "buildFileSystem").mockRejectedValue(buildErr); + const errorSpy = vi.spyOn(service.logger, "error").mockImplementation(() => undefined as any); + const unhandled = vi.fn(); + process.on("unhandledRejection", unhandled); + + try { + service.cloudSyncConfigChange({ ...syncConfig, enable: true }); + await flushMicrotasks(); + + expect(errorSpy).toHaveBeenCalledWith("cloud sync config change error", expect.anything()); + expect(unhandled).not.toHaveBeenCalled(); + } finally { + process.off("unhandledRejection", unhandled); + } + }); }); From 15d2695e20f7218e782f93866ad8af07127be99b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 27 Apr 2026 13:39:10 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=90=9B=20=E9=81=BF=E5=85=8D=20syncOnc?= =?UTF-8?q?e=20=E5=86=85=E9=83=A8=E5=88=A0=E9=99=A4=E4=BA=8B=E4=BB=B6?= =?UTF-8?q?=E5=9B=9E=E7=81=8C=E8=A7=A6=E5=8F=91=E7=A9=BA=E8=B7=91=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E4=BB=BB=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scriptsDelete 入口先过滤 deleteBy === "sync" 的条目,避免 syncOnce 通过 mq.publish 回灌的 sync 来源删除事件再排一次 buildFileSystem + updateFileDigest 的空跑任务。 --- .../service_worker/synchronize.test.ts | 29 +++++++++++++++++++ src/app/service/service_worker/synchronize.ts | 11 ++++--- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index 242a946b9..0f61b1a94 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -543,6 +543,35 @@ console.log("ok");` expect(order).toEqual(["sync:list", "sync:digest", "delete:from-user", "delete:digest"]); }); + it("scriptsDelete skips enqueue when all entries are deleteBy=sync", async () => { + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { + getCloudSync: vi.fn().mockResolvedValue({ ...syncConfig, enable: true }), + } as any, + { + scriptCodeDAO: {}, + all: vi.fn().mockResolvedValue([]), + } as any + ); + + const buildSpy = vi.spyOn(service as any, "buildFileSystem"); + const getCloudSyncSpy = (service as any).systemConfig.getCloudSync as ReturnType; + + await service.scriptsDelete([{ uuid: "a", deleteBy: "sync" } as any, { uuid: "b", deleteBy: "sync" } as any]); + await flushMicrotasks(); + + // 全部来源是 sync(来自 syncOnce 内部的 deleteScript 回灌),应直接 return + // 不应去读取云同步配置,也不应建立任何文件系统连接 + expect(getCloudSyncSpy).not.toHaveBeenCalled(); + expect(buildSpy).not.toHaveBeenCalled(); + }); + it("cloudSyncConfigChange swallows buildFileSystem error", async () => { const service = new SynchronizeService( {} as any, diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index 2d9ab368d..a25b9d577 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -712,15 +712,18 @@ export class SynchronizeService { } async scriptsDelete(data: TDeleteScript[]) { + // 过滤掉来源为 sync 的删除事件,避免 syncOnce 内部触发的 mq 回灌 + // 又排一次 buildFileSystem + updateFileDigest 的空跑任务 + const items = data.filter((d) => d.deleteBy !== "sync"); + if (!items.length) { + return; + } // 判断是否开启了同步 const config = await this.systemConfig.getCloudSync(); if (config.enable) { stackAsyncTask(SYNC_SERVICE_TASK_KEY, async () => { const fs = await this.buildFileSystem(config); - for (const { uuid, deleteBy } of data) { - if (deleteBy === "sync") { - continue; - } + for (const { uuid } of items) { await this.deleteCloudScript(fs, uuid, config.syncDelete); } await this.updateFileDigest(fs);