From ffce066842208f0a2b0091166e1e178d077bf820 Mon Sep 17 00:00:00 2001 From: Ivan Fontosh Date: Wed, 22 Apr 2026 15:12:25 +0800 Subject: [PATCH] fix(project): stabilize project deletion - Guard renderer project list/get against stale initial loads - Retry project zip/cache removal to handle transient Windows locks - Surface deletion failures in UI and add regression tests Made-with: Cursor --- .cursor/rules/project.mdc | 60 +++++++++---------- app/main/project/fsRetry.test.ts | 31 ++++++++++ app/main/project/fsRetry.ts | 32 ++++++++++ app/main/project/zipStore.ts | 5 +- app/renderer/editor/EditorApp.tsx | 8 ++- .../editor/state/projectState.race.test.ts | 23 +++++++ app/renderer/editor/state/projectState.ts | 9 +++ package.json | 2 +- 8 files changed, 136 insertions(+), 34 deletions(-) create mode 100644 app/main/project/fsRetry.test.ts create mode 100644 app/main/project/fsRetry.ts create mode 100644 app/renderer/editor/state/projectState.race.test.ts diff --git a/.cursor/rules/project.mdc b/.cursor/rules/project.mdc index 98845b3..2b0b84a 100644 --- a/.cursor/rules/project.mdc +++ b/.cursor/rules/project.mdc @@ -1,39 +1,39 @@ -# .cursor/rules/project.mdc +--- +description: Project-wide workflow and conventions +alwaysApply: true +--- -# Project +# DNDGamePlayer — правила работы над задачами (future-pipeline) -React + TypeScript frontend project. +Эти правила применяются **только** когда запрос пользователя требует **изменений в репозитории** (код/конфиги/тесты). Для чисто текстовых задач (описания, маркетинг, переписка) pipeline не запускаем. -# Commands +## future-pipeline (обязательный порядок) +### 1) Implementation +- Прочитать релевантный код (минимум 1 файл), найти реальную причину бага/задачи. +- Делать **minimal, review-friendly diff** и следовать текущим паттернам проекта. +- Не добавлять зависимости без явной причины. + +### 2) Review +- Самопроверка изменений: edge-cases, состояние UI (loading/error/empty/disabled), a11y, регрессии. +- Если задача нетривиальная: запустить внутренний “строгий ревью” (под-агент reviewer). + +### 3) Tests +- Обновить/добавить тест(ы), если поведение изменилось или был баг. +- Для мелких правок допускается “облегчённый режим” без под-агентов, но тесты всё равно должны проходить. + +### 4) Verify (всегда, перед ответом) +Обязательно выполнить: +- `npm run lint` +- `npm run typecheck` +- `npm run test` + +Если что-то упало — исправить и повторить до green. + +## Команды проекта (справка) - install: `npm install` - dev: `npm run dev` - build: `npm run build` - lint: `npm run lint` - typecheck: `npm run typecheck` -- test: `npm run test` -- test single: `npm run test -- ` - -# Global rules - -- Всегда изучай existing code и nearby components перед изменениями -- Делай minimal, review-friendly diff -- Не добавляй new dependencies без явной причины -- Следуй существующим patterns - -# Done criteria - -Перед завершением: - -- lint passes -- typecheck passes -- tests pass -- нет regressions - -# UI checklist - -- loading state -- error state -- empty state -- disabled state -- accessibility \ No newline at end of file +- test: `npm run test` \ No newline at end of file diff --git a/app/main/project/fsRetry.test.ts b/app/main/project/fsRetry.test.ts new file mode 100644 index 0000000..c544d47 --- /dev/null +++ b/app/main/project/fsRetry.test.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { rmWithRetries } from './fsRetry'; + +void test('rmWithRetries: retries on EPERM and then succeeds', async () => { + let calls = 0; + const rm = () => { + calls += 1; + if (calls < 3) { + const err = new Error('nope') as Error & { code: string }; + err.code = 'EPERM'; + throw err; + } + return Promise.resolve(); + }; + await rmWithRetries(rm, 'x', { force: true }, 5); + assert.equal(calls, 3); +}); + +void test('rmWithRetries: does not retry on ENOENT', async () => { + let calls = 0; + const rm = () => { + calls += 1; + const err = new Error('missing') as Error & { code: string }; + err.code = 'ENOENT'; + return Promise.reject(err); + }; + await assert.rejects(() => rmWithRetries(rm, 'x', { force: true }, 5), /missing/); + assert.equal(calls, 1); +}); diff --git a/app/main/project/fsRetry.ts b/app/main/project/fsRetry.ts new file mode 100644 index 0000000..adecf08 --- /dev/null +++ b/app/main/project/fsRetry.ts @@ -0,0 +1,32 @@ +type RmLike = (path: string, opts: { recursive?: boolean; force?: boolean }) => Promise; + +function isRetryableRmError(err: unknown): boolean { + if (!err || typeof err !== 'object') return false; + const code = (err as { code?: unknown }).code; + return code === 'EBUSY' || code === 'EPERM' || code === 'EACCES'; +} + +export async function rmWithRetries( + rm: RmLike, + targetPath: string, + opts: { recursive?: boolean; force?: boolean }, + retries = 6, +): Promise { + let attempt = 0; + // Windows: file locks/antivirus/indexers can cause transient EPERM/EBUSY. + // We retry a few times before surfacing the error. + // Delays: 20, 40, 80, 160, 320, 640ms (capped by retries). + for (;;) { + try { + await rm(targetPath, opts); + return; + } catch (e) { + attempt += 1; + if (attempt > retries || !isRetryableRmError(e)) { + throw e; + } + const delayMs = Math.min(800, 20 * 2 ** (attempt - 1)); + await new Promise((r) => setTimeout(r, delayMs)); + } + } +} diff --git a/app/main/project/zipStore.ts b/app/main/project/zipStore.ts index dbeb520..89fbeaf 100644 --- a/app/main/project/zipStore.ts +++ b/app/main/project/zipStore.ts @@ -24,6 +24,7 @@ import { asAssetId, asGraphNodeId, asProjectId } from '../../shared/types/ids'; import { getAppSemanticVersion } from '../versionInfo'; import { reconcileAssetFiles } from './assetPrune'; +import { rmWithRetries } from './fsRetry'; import { getLegacyProjectsRootDirs, getProjectsCacheRootDir, getProjectsRootDir } from './paths'; import { readProjectJsonFromZip, unzipToDir } from './yauzlProjectZip'; @@ -792,8 +793,8 @@ export class ZipProjectStore { this.projectSession += 1; } - await fs.rm(zipPath, { force: true }).catch(() => undefined); - await fs.rm(cacheDir, { recursive: true, force: true }).catch(() => undefined); + await rmWithRetries(fs.rm, zipPath, { force: true }); + await rmWithRetries(fs.rm, cacheDir, { recursive: true, force: true }); } private randomId(): string { diff --git a/app/renderer/editor/EditorApp.tsx b/app/renderer/editor/EditorApp.tsx index 64b60ea..0914e3c 100644 --- a/app/renderer/editor/EditorApp.tsx +++ b/app/renderer/editor/EditorApp.tsx @@ -917,7 +917,13 @@ function ProjectPicker({ projects, onCreate, onOpen, onDelete }: ProjectPickerPr ) { return; } - void onDelete(id); + void (async () => { + try { + await onDelete(id); + } catch (e) { + window.alert(e instanceof Error ? e.message : String(e)); + } + })(); }} > Удалить diff --git a/app/renderer/editor/state/projectState.race.test.ts b/app/renderer/editor/state/projectState.race.test.ts new file mode 100644 index 0000000..717fc35 --- /dev/null +++ b/app/renderer/editor/state/projectState.race.test.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); + +void test('projectState: list/get after delete invalidates in-flight initial load (epoch guard)', () => { + const src = fs.readFileSync(path.join(here, 'projectState.ts'), 'utf8'); + assert.match(src, /projectDataEpochRef/); + assert.match(src, /const epoch = projectDataEpochRef\.current/); + assert.match(src, /if \(projectDataEpochRef\.current !== epoch\) return/); + assert.match( + src, + /const deleteProject = async[\s\S]+?projectDataEpochRef\.current \+= 1[\s\S]+?await api\.invoke/, + ); + assert.match( + src, + /const openProject = async[\s\S]+?projectDataEpochRef\.current \+= 1[\s\S]+?await api\.invoke/, + ); + assert.match(src, /const refreshProjects = async \(\) => \{[\s\S]+?projectDataEpochRef\.current \+= 1/); +}); diff --git a/app/renderer/editor/state/projectState.ts b/app/renderer/editor/state/projectState.ts index 3f89459..ff4e87f 100644 --- a/app/renderer/editor/state/projectState.ts +++ b/app/renderer/editor/state/projectState.ts @@ -58,12 +58,15 @@ export function useProjectState(licenseActive: boolean): readonly [State, Action const api = getDndApi(); const [state, setState] = useState({ projects: [], project: null, selectedSceneId: null }); const projectRef = useRef(null); + /** Bumps on mutations / refresh; initial license load only applies if still current (avoids racing late list/get over newer state). */ + const projectDataEpochRef = useRef(0); useEffect(() => { projectRef.current = state.project; }, [state.project]); const actions = useMemo(() => { const refreshProjects = async () => { + projectDataEpochRef.current += 1; const res = await api.invoke(ipcChannels.project.list, {}); setState((s) => ({ ...s, projects: res.projects })); }; @@ -75,6 +78,7 @@ export function useProjectState(licenseActive: boolean): readonly [State, Action }; const openProject = async (id: ProjectId) => { + projectDataEpochRef.current += 1; const res = await api.invoke(ipcChannels.project.open, { projectId: id }); setState((s) => ({ ...s, project: res.project, selectedSceneId: res.project.currentSceneId })); }; @@ -276,6 +280,7 @@ export function useProjectState(licenseActive: boolean): readonly [State, Action }; const deleteProject = async (projectId: ProjectId) => { + projectDataEpochRef.current += 1; await api.invoke(ipcChannels.project.deleteProject, { projectId }); const listRes = await api.invoke(ipcChannels.project.list, {}); const res = await api.invoke(ipcChannels.project.get, {}); @@ -316,14 +321,18 @@ export function useProjectState(licenseActive: boolean): readonly [State, Action useEffect(() => { if (!licenseActive) { queueMicrotask(() => { + projectDataEpochRef.current += 1; setState({ projects: [], project: null, selectedSceneId: null }); }); return; } void (async () => { + const epoch = projectDataEpochRef.current; const listRes = await api.invoke(ipcChannels.project.list, {}); + if (projectDataEpochRef.current !== epoch) return; setState((s) => ({ ...s, projects: listRes.projects })); const res = await api.invoke(ipcChannels.project.get, {}); + if (projectDataEpochRef.current !== epoch) return; setState((s) => ({ ...s, project: res.project, selectedSceneId: res.project?.currentSceneId ?? null })); })(); }, [licenseActive, api]); diff --git a/package.json b/package.json index edd2f6b..4df68aa 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "build:obfuscate": "node scripts/build.mjs --production --obfuscate", "lint": "eslint . --max-warnings 0", "typecheck": "tsc -p tsconfig.eslint.json --noEmit", - "test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/zipRead.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs", + "test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/editor/state/projectState.race.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs", "format": "prettier . --check", "format:write": "prettier . --write", "release:info": "node scripts/print-release-info.mjs",