Skip to content

Commit 652e9a0

Browse files
committed
修正 synchronize.ts 避免 误判和误删
1 parent 144dc25 commit 652e9a0

2 files changed

Lines changed: 250 additions & 33 deletions

File tree

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import { SynchronizeService } from "./synchronize";
3+
import { initTestEnv } from "@Tests/utils";
4+
import type FileSystem from "@Packages/filesystem/filesystem";
5+
import type { CloudSyncConfig } from "@App/pkg/config/config";
6+
7+
initTestEnv();
8+
9+
const syncConfig: CloudSyncConfig = {
10+
enable: true,
11+
syncDelete: true,
12+
syncStatus: true,
13+
filesystem: "webdav",
14+
params: {},
15+
};
16+
17+
const createFs = (overrides: Partial<FileSystem> = {}): FileSystem =>
18+
({
19+
verify: vi.fn().mockResolvedValue(undefined),
20+
list: vi.fn().mockResolvedValue([]),
21+
open: vi.fn(),
22+
openDir: vi.fn(),
23+
create: vi.fn().mockResolvedValue({
24+
write: vi.fn().mockResolvedValue(undefined),
25+
}),
26+
createDir: vi.fn().mockResolvedValue(undefined),
27+
delete: vi.fn().mockResolvedValue(undefined),
28+
getDirUrl: vi.fn().mockResolvedValue(""),
29+
...overrides,
30+
}) as unknown as FileSystem;
31+
32+
describe("SynchronizeService", () => {
33+
beforeEach(() => {
34+
vi.clearAllMocks();
35+
chrome.storage.local.clear();
36+
});
37+
38+
it("serializes concurrent syncOnce calls", async () => {
39+
let releaseFirst!: () => void;
40+
const firstGate = new Promise<void>((resolve) => {
41+
releaseFirst = resolve;
42+
});
43+
const order: string[] = [];
44+
const fs1 = createFs({
45+
list: vi
46+
.fn()
47+
.mockImplementationOnce(async () => {
48+
order.push("first:start");
49+
await firstGate;
50+
order.push("first:end");
51+
return [];
52+
})
53+
.mockResolvedValue([]),
54+
});
55+
const fs2 = createFs({
56+
list: vi
57+
.fn()
58+
.mockImplementationOnce(async () => {
59+
order.push("second:start");
60+
return [];
61+
})
62+
.mockResolvedValue([]),
63+
});
64+
const service = new SynchronizeService(
65+
{} as any,
66+
{} as any,
67+
{} as any,
68+
{} as any,
69+
{} as any,
70+
{} as any,
71+
{} as any,
72+
{
73+
scriptCodeDAO: {},
74+
all: vi.fn().mockResolvedValue([]),
75+
} as any
76+
);
77+
78+
const first = service.syncOnce(syncConfig, fs1);
79+
await Promise.resolve();
80+
const second = service.syncOnce(syncConfig, fs2);
81+
await Promise.resolve();
82+
83+
expect(order).toEqual(["first:start"]);
84+
expect((fs2.list as any).mock.calls.length).toBe(0);
85+
86+
releaseFirst();
87+
await Promise.all([first, second]);
88+
89+
expect(order).toEqual(["first:start", "first:end", "second:start"]);
90+
});
91+
92+
it("does not delete orphan cloud script without meta", async () => {
93+
const fs = createFs({
94+
list: vi.fn().mockResolvedValue([
95+
{
96+
name: "orphan.user.js",
97+
path: "orphan.user.js",
98+
size: 1,
99+
digest: "d1",
100+
createtime: 1,
101+
updatetime: 1,
102+
},
103+
]),
104+
});
105+
const service = new SynchronizeService(
106+
{} as any,
107+
{} as any,
108+
{} as any,
109+
{} as any,
110+
{} as any,
111+
{} as any,
112+
{} as any,
113+
{
114+
scriptCodeDAO: {},
115+
all: vi.fn().mockResolvedValue([]),
116+
} as any
117+
);
118+
119+
await service.syncOnce(syncConfig, fs);
120+
121+
expect(fs.delete).not.toHaveBeenCalled();
122+
});
123+
124+
it("waits for installScript during pullScript", async () => {
125+
let releaseInstall!: () => void;
126+
const installGate = new Promise<void>((resolve) => {
127+
releaseInstall = resolve;
128+
});
129+
const installScript = vi.fn().mockImplementation(() => installGate);
130+
const fs = createFs({
131+
open: vi.fn().mockImplementation(async (file) => ({
132+
read: vi.fn().mockResolvedValue(
133+
file.name.endsWith(".user.js")
134+
? `// ==UserScript==
135+
// @name Pull Test
136+
// @namespace sync-test
137+
// @match https://example.com/*
138+
// ==/UserScript==
139+
console.log("ok");`
140+
: JSON.stringify({ uuid: "pull-uuid" })
141+
),
142+
})),
143+
});
144+
const service = new SynchronizeService(
145+
{} as any,
146+
{} as any,
147+
{
148+
installScript,
149+
} as any,
150+
{} as any,
151+
{} as any,
152+
{} as any,
153+
{} as any,
154+
{
155+
scriptCodeDAO: {},
156+
} as any
157+
);
158+
159+
let settled = false;
160+
const pullPromise = service
161+
.pullScript(
162+
fs,
163+
{
164+
script: {
165+
name: "pull-uuid.user.js",
166+
path: "pull-uuid.user.js",
167+
size: 1,
168+
digest: "d1",
169+
createtime: 1,
170+
updatetime: 1,
171+
},
172+
meta: {
173+
name: "pull-uuid.meta.json",
174+
path: "pull-uuid.meta.json",
175+
size: 1,
176+
digest: "d2",
177+
createtime: 1,
178+
updatetime: 1,
179+
},
180+
},
181+
undefined
182+
)
183+
.then(() => {
184+
settled = true;
185+
});
186+
187+
await Promise.resolve();
188+
expect(settled).toBe(false);
189+
190+
releaseInstall();
191+
await pullPromise;
192+
193+
expect(installScript).toHaveBeenCalledTimes(1);
194+
expect(settled).toBe(true);
195+
});
196+
});

src/app/service/service_worker/synchronize.ts

Lines changed: 54 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { ExtVersion } from "@App/app/const";
3333
import { dayFormat } from "@App/pkg/utils/day_format";
3434
import i18n, { i18nName } from "@App/locales/locales";
3535
import { InfoNotification } from "./utils";
36+
import { stackAsyncTask } from "@App/pkg/utils/async_queue";
3637

3738
// type SynchronizeTarget = "local";
3839

@@ -66,6 +67,8 @@ type ScriptcatSyncStatus = {
6667

6768
type PushScriptParam = TInstallScriptParams;
6869

70+
const SYNC_SERVICE_TASK_KEY = "cloud_sync";
71+
6972
export class SynchronizeService {
7073
logger: Logger;
7174

@@ -329,6 +332,10 @@ export class SynchronizeService {
329332

330333
// 同步一次
331334
async syncOnce(syncConfig: CloudSyncConfig, fs: FileSystem) {
335+
return stackAsyncTask(SYNC_SERVICE_TASK_KEY, () => this.syncOnceInternal(syncConfig, fs));
336+
}
337+
338+
private async syncOnceInternal(syncConfig: CloudSyncConfig, fs: FileSystem) {
332339
this.logger.info("start sync once");
333340
// 获取文件列表
334341
const list = await fs.list();
@@ -402,15 +409,15 @@ export class SynchronizeService {
402409
const metaObj = JSON.parse(metaJson) as SyncMeta;
403410
if (metaObj.isDeleted) {
404411
// 删除脚本
405-
this.script.deleteScript(script.uuid, "sync");
412+
await this.script.deleteScript(script.uuid, "sync");
406413
InfoNotification(
407414
i18n.t("notification.script_sync_delete"),
408415
i18n.t("notification.script_sync_delete_desc", { scriptName: i18nName(script) })
409416
);
410417
} else {
411418
// 否则认为是一个无效的.meta文件,进行删除,并进行同步
412419
await fs.delete(file.meta!.name);
413-
result.push(this.pushScript(fs, script));
420+
await this.pushScript(fs, script);
414421
}
415422
})()
416423
);
@@ -436,8 +443,11 @@ export class SynchronizeService {
436443
// 如果脚本不存在,但文件存在,则安装脚本
437444
if (file.script) {
438445
if (!file.meta) {
439-
// 如果.meta文件不存在,则删除脚本文件,并跳过
440-
result.push(fs.delete(file.script.name));
446+
// .meta 文件可能尚未上传完成,跳过本次以避免误删云端脚本
447+
this.logger.warn("skip orphan cloud script without meta", {
448+
uuid,
449+
file: file.script.name,
450+
});
441451
return;
442452
}
443453
updateScript.set(uuid, true);
@@ -616,7 +626,7 @@ export class SynchronizeService {
616626
script.status = status.enable ? SCRIPT_STATUS_ENABLE : SCRIPT_STATUS_DISABLE;
617627
}
618628
}
619-
this.script.installScript({
629+
await this.script.installScript({
620630
script,
621631
code,
622632
upsertBy: "sync",
@@ -630,33 +640,37 @@ export class SynchronizeService {
630640
cloudSyncConfigChange(value: CloudSyncConfig) {
631641
if (value.enable) {
632642
// 开启云同步同步
633-
this.buildFileSystem(value).then(async (fs) => {
634-
await this.syncOnce(value, fs);
635-
// 开启定时器, 一小时一次
636-
chrome.alarms.get("cloudSync", (alarm) => {
637-
const lastError = chrome.runtime.lastError;
638-
if (lastError) {
639-
console.error("chrome.runtime.lastError in chrome.alarms.get:", lastError);
640-
// 非预期的异常API错误,停止处理
641-
}
642-
if (!alarm) {
643-
chrome.alarms.create(
644-
"cloudSync",
645-
{
646-
periodInMinutes: 60,
647-
},
648-
() => {
649-
const lastError = chrome.runtime.lastError;
650-
if (lastError) {
651-
console.error("chrome.runtime.lastError in chrome.alarms.create:", lastError);
652-
// Starting in Chrome 117, the number of active alarms is limited to 500. Once this limit is reached, chrome.alarms.create() will fail.
653-
console.error("Chrome alarm is unable to create. Please check whether limit is reached.");
643+
this.buildFileSystem(value)
644+
.then(async (fs) => {
645+
await this.syncOnce(value, fs);
646+
// 开启定时器, 一小时一次
647+
chrome.alarms.get("cloudSync", (alarm) => {
648+
const lastError = chrome.runtime.lastError;
649+
if (lastError) {
650+
console.error("chrome.runtime.lastError in chrome.alarms.get:", lastError);
651+
// 非预期的异常API错误,停止处理
652+
}
653+
if (!alarm) {
654+
chrome.alarms.create(
655+
"cloudSync",
656+
{
657+
periodInMinutes: 60,
658+
},
659+
() => {
660+
const lastError = chrome.runtime.lastError;
661+
if (lastError) {
662+
console.error("chrome.runtime.lastError in chrome.alarms.create:", lastError);
663+
// Starting in Chrome 117, the number of active alarms is limited to 500. Once this limit is reached, chrome.alarms.create() will fail.
664+
console.error("Chrome alarm is unable to create. Please check whether limit is reached.");
665+
}
654666
}
655-
}
656-
);
657-
}
667+
);
668+
}
669+
});
670+
})
671+
.catch((e) => {
672+
this.logger.error("cloud sync config change error", Logger.E(e));
658673
});
659-
});
660674
} else {
661675
// 停止计时器
662676
chrome.alarms.clear("cloudSync");
@@ -670,9 +684,12 @@ export class SynchronizeService {
670684
// 判断是否开启了同步
671685
const config = await this.systemConfig.getCloudSync();
672686
if (config.enable) {
673-
this.buildFileSystem(config).then(async (fs) => {
687+
stackAsyncTask(SYNC_SERVICE_TASK_KEY, async () => {
688+
const fs = await this.buildFileSystem(config);
674689
await this.pushScript(fs, params.script);
675-
this.updateFileDigest(fs);
690+
await this.updateFileDigest(fs);
691+
}).catch((e) => {
692+
this.logger.error("push script on install error", Logger.E(e));
676693
});
677694
}
678695
}
@@ -681,13 +698,17 @@ export class SynchronizeService {
681698
// 判断是否开启了同步
682699
const config = await this.systemConfig.getCloudSync();
683700
if (config.enable) {
684-
this.buildFileSystem(config).then(async (fs) => {
701+
stackAsyncTask(SYNC_SERVICE_TASK_KEY, async () => {
702+
const fs = await this.buildFileSystem(config);
685703
for (const { uuid, deleteBy } of data) {
686704
if (deleteBy === "sync") {
687705
continue;
688706
}
689707
await this.deleteCloudScript(fs, uuid, config.syncDelete);
690708
}
709+
await this.updateFileDigest(fs);
710+
}).catch((e) => {
711+
this.logger.error("delete cloud script error", Logger.E(e));
691712
});
692713
}
693714
}

0 commit comments

Comments
 (0)