fix: 消除保存并发竞态——同步锁防重复保存,冲突判定先比对内容

This commit is contained in:
yu 2026-10-28 17:38:41 +08:00
parent 6cb10db375
commit a5d9f0bfee
2 changed files with 24 additions and 10 deletions

View File

@ -129,14 +129,23 @@ function registerIpc(): void {
});
ipcMain.handle("flows:get", (_event, id: string) => getFlow(id));
ipcMain.handle("flows:save", (_event, flow: FlowDefinition, opts?: FlowSaveOptions): FlowSaveResult => {
if (!opts?.force && opts?.expectedUpdatedAt) {
const disk = getFlow(flow.id);
if (disk && disk.meta?.updatedAt && disk.meta.updatedAt !== opts.expectedUpdatedAt) {
return { conflict: true, diskFlow: disk };
if (!opts?.force && opts?.expectedUpdatedAt) {
let disk: FlowDefinition | null = null;
try {
disk = getFlow(flow.id);
} catch {
disk = null; // 磁盘文件不存在或损坏时视为全新保存
}
if (disk && disk.meta?.updatedAt && disk.meta.updatedAt !== opts.expectedUpdatedAt) {
// 内容一致(仅 updatedAt 差异,如并发/重复保存)不视为冲突
const normalize = (item: FlowDefinition) => JSON.stringify({ ...item, meta: undefined });
if (normalize(disk) !== normalize(flow)) {
return { conflict: true, diskFlow: disk };
}
}
}
}
return { conflict: false, flow: saveFlow(flow) };
});
return { conflict: false, flow: saveFlow(flow) };
});
ipcMain.handle("flows:delete", (_event, id: string) => {
deleteFlow(id);
return true;

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { ArrowLeft, Check, CircleAlert, CirclePlay, Lightbulb, Loader2, Redo2, Save, Square, Undo2, X } from "lucide-react";
import type { Catalog } from "@shared/types";
import { useEditorStore } from "../store/editorStore";
@ -34,6 +34,9 @@ export function EditorPage({ catalog, onToast, onBack }: EditorPageProps) {
const startRun = useRunStore((state) => state.start);
const stopRun = useRunStore((state) => state.stop);
const [saving, setSaving] = useState(false);
// 同步锁:防止自动保存定时器与按钮点击(或双击)在同一帧内触发两次并发保存,
// 并发保存会让后到者误判磁盘版本冲突(saving state 异步更新拦不住同一帧的第二次调用)
const savingRef = useRef(false);
const [saveConflict, setSaveConflict] = useState(false);
const [savedAt, setSavedAt] = useState<string | null>(null);
const [validationIssues, setValidationIssues] = useState<FlowValidationIssue[] | null>(null);
@ -57,7 +60,8 @@ export function EditorPage({ catalog, onToast, onBack }: EditorPageProps) {
const save = useCallback(async (opts?: { interactive?: boolean }) => {
const current = useEditorStore.getState().flow;
if (!current || saving) return null;
if (!current || savingRef.current) return null;
savingRef.current = true;
setSaving(true);
try {
const result = await window.rpa.flows.save(current, {
@ -99,9 +103,10 @@ export function EditorPage({ catalog, onToast, onBack }: EditorPageProps) {
onToast(error instanceof Error ? error.message : String(error), "error");
return null;
} finally {
savingRef.current = false;
setSaving(false);
}
}, [loadFlow, markSaved, onToast, saving, syncSavedFlow]);
}, [loadFlow, markSaved, onToast, syncSavedFlow]);
useEffect(() => {
if (!dirty || saveConflict) return;