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
This commit is contained in:
Ivan Fontosh
2026-04-22 15:12:25 +08:00
parent add699a320
commit ffce066842
8 changed files with 136 additions and 34 deletions
+31
View File
@@ -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);
});
+32
View File
@@ -0,0 +1,32 @@
type RmLike = (path: string, opts: { recursive?: boolean; force?: boolean }) => Promise<void>;
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<void> {
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));
}
}
}
+3 -2
View File
@@ -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 {