fix(project): race-safe batch scene preview import and close

Serialize updateProject and preview finalize, drain before pack/close, buffer zip entries, and abort finalize cleanly when leaving the project to avoid ENOENT on multi-image drops.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-08-04 09:44:45 +08:00
parent f75444a5dd
commit 4e6f7321b8
5 changed files with 210 additions and 59 deletions
+7 -6
View File
@@ -954,13 +954,14 @@ async function main() {
const finalized = await projectStore.finalizeScenePreviewImport(sceneId, result.assetId); const finalized = await projectStore.finalizeScenePreviewImport(sceneId, result.assetId);
if (finalized.changed) { if (finalized.changed) {
emitSessionState(); emitSessionState();
emitScenePreviewImportProgress({
sceneId,
assetId: result.assetId,
phase: 'done',
project: finalized.project,
});
} }
// Always clear optimizing UI — including abort on project close (changed: false).
emitScenePreviewImportProgress({
sceneId,
assetId: result.assetId,
phase: 'done',
...(finalized.changed && finalized.project ? { project: finalized.project } : {}),
});
} catch (e) { } catch (e) {
emitScenePreviewImportProgress({ emitScenePreviewImportProgress({
sceneId, sceneId,
@@ -33,3 +33,19 @@ void test('generateScenePreviewThumbnailBytes: image scales to max edge', async
await fs.rm(tmp, { recursive: true, force: true }); await fs.rm(tmp, { recursive: true, force: true });
}); });
void test('generateScenePreviewThumbnailBytes: accepts image Buffer', async () => {
const png = await sharp({
create: {
width: 120,
height: 80,
channels: 3,
background: { r: 10, g: 20, b: 30 },
},
})
.png()
.toBuffer();
const buf = await generateScenePreviewThumbnailBytes(png, 'image');
assert.ok(buf !== null);
assert.ok(buf.length > 0);
});
+6 -2
View File
@@ -14,14 +14,15 @@ export const SCENE_PREVIEW_THUMB_MAX_PX = 320;
/** /**
* Builds a small WebP still for graph/list previews. Returns null if generation fails (import still succeeds). * Builds a small WebP still for graph/list previews. Returns null if generation fails (import still succeeds).
* For images, prefer a Buffer so callers are not racy with reconcileAssetFiles unlinking paths.
*/ */
export async function generateScenePreviewThumbnailBytes( export async function generateScenePreviewThumbnailBytes(
sourceAbsPath: string, source: string | Buffer,
kind: 'image' | 'video', kind: 'image' | 'video',
): Promise<Buffer | null> { ): Promise<Buffer | null> {
try { try {
if (kind === 'image') { if (kind === 'image') {
return await sharp(sourceAbsPath) return await sharp(source)
.rotate() .rotate()
.resize(SCENE_PREVIEW_THUMB_MAX_PX, SCENE_PREVIEW_THUMB_MAX_PX, { .resize(SCENE_PREVIEW_THUMB_MAX_PX, SCENE_PREVIEW_THUMB_MAX_PX, {
fit: 'inside', fit: 'inside',
@@ -31,6 +32,9 @@ export async function generateScenePreviewThumbnailBytes(
.toBuffer(); .toBuffer();
} }
const sourceAbsPath = typeof source === 'string' ? source : null;
if (!sourceAbsPath) return null;
const ffmpegPath = ffmpegStatic; const ffmpegPath = ffmpegStatic;
if (!ffmpegPath) return null; if (!ffmpegPath) return null;
@@ -50,6 +50,17 @@ void test('zipStore: pack and open operations are serialized', () => {
assert.match(src, /enqueueProjectSwitch/); assert.match(src, /enqueueProjectSwitch/);
}); });
void test('zipStore: project updates and preview finalize are serialized', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
assert.match(src, /private projectUpdateChain: Promise<unknown>/);
assert.match(src, /private previewFinalizeChain: Promise<unknown>/);
assert.match(src, /enqueueProjectUpdate/);
assert.match(src, /enqueuePreviewFinalize/);
assert.match(src, /finalizeScenePreviewImportInner/);
assert.match(src, /drainProjectMutations/);
assert.match(src, /addBuffer/);
});
void test('zipStore: closeOpenProject is serialized with open on projectSwitchChain', () => { void test('zipStore: closeOpenProject is serialized with open on projectSwitchChain', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8'); const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
assert.match(src, /async closeOpenProject\(\): Promise<void> \{[\s\S]*enqueueProjectSwitch/); assert.match(src, /async closeOpenProject\(\): Promise<void> \{[\s\S]*enqueueProjectSwitch/);
+170 -51
View File
@@ -130,12 +130,44 @@ export class ZipProjectStore {
private projectSession = 0; private projectSession = 0;
/** Serializes project.json writes — parallel renames caused ENOENT on Windows. */ /** Serializes project.json writes — parallel renames caused ENOENT on Windows. */
private projectWriteChain: Promise<void> = Promise.resolve(); private projectWriteChain: Promise<void> = Promise.resolve();
/**
* Serializes project mutations (mutator + reconcileAssetFiles).
* Parallel updateProject during batch scene-preview import caused lost updates and
* reconcileAssetFiles deleting preview files still referenced after a stale write (ENOENT).
*/
private projectUpdateChain: Promise<unknown> = Promise.resolve();
/** Serializes background preview optimize/thumb — avoids parallel sharp + orphan races. */
private previewFinalizeChain: Promise<unknown> = Promise.resolve();
/** Serializes zip pack operations — parallel yazl/yauzl caused «unexpected number of bytes». */ /** Serializes zip pack operations — parallel yazl/yauzl caused «unexpected number of bytes». */
private packChain: Promise<void> = Promise.resolve(); private packChain: Promise<void> = Promise.resolve();
/** Serializes open/close/unzip — concurrent IPC caused ghost open projects and deadlocks. */ /** Serializes open/close/unzip — concurrent IPC caused ghost open projects and deadlocks. */
private projectSwitchChain: Promise<void> = Promise.resolve(); private projectSwitchChain: Promise<void> = Promise.resolve();
private saveDebounceTimer: ReturnType<typeof setTimeout> | null = null; private saveDebounceTimer: ReturnType<typeof setTimeout> | null = null;
private enqueueProjectUpdate<T>(fn: () => Promise<T>): Promise<T> {
const task = this.projectUpdateChain.then(() => fn());
this.projectUpdateChain = task.then(
() => undefined,
() => undefined,
);
return task;
}
private enqueuePreviewFinalize<T>(fn: () => Promise<T>): Promise<T> {
const task = this.previewFinalizeChain.then(() => fn());
this.previewFinalizeChain = task.then(
() => undefined,
() => undefined,
);
return task;
}
/** Wait until preview optimize/thumb and project mutators finish (before pack/close). */
private async drainProjectMutations(): Promise<void> {
await this.previewFinalizeChain;
await this.projectUpdateChain;
}
private enqueuePack(cacheDir: string, zipPath: string): Promise<void> { private enqueuePack(cacheDir: string, zipPath: string): Promise<void> {
const next = this.packChain.then(async () => { const next = this.packChain.then(async () => {
await this.packZipFromCache(cacheDir, zipPath); await this.packZipFromCache(cacheDir, zipPath);
@@ -163,6 +195,8 @@ export class ZipProjectStore {
clearTimeout(this.saveDebounceTimer); clearTimeout(this.saveDebounceTimer);
this.saveDebounceTimer = null; this.saveDebounceTimer = null;
} }
// Finish preview finalizers before flush/pack — otherwise yazl ENOENT on deleted assets.
await this.drainProjectMutations();
if (this.saveQueued) { if (this.saveQueued) {
this.saveQueued = false; this.saveQueued = false;
await this.flushSave(); await this.flushSave();
@@ -172,6 +206,7 @@ export class ZipProjectStore {
} }
await this.packChain; await this.packChain;
await this.projectWriteChain; await this.projectWriteChain;
await this.drainProjectMutations();
} }
async ensureRoots(): Promise<void> { async ensureRoots(): Promise<void> {
@@ -457,32 +492,68 @@ export class ZipProjectStore {
sceneId: SceneId, sceneId: SceneId,
assetId: AssetId, assetId: AssetId,
): Promise<{ project: Project; changed: boolean }> { ): Promise<{ project: Project; changed: boolean }> {
return this.enqueuePreviewFinalize(() => this.finalizeScenePreviewImportInner(sceneId, assetId));
}
private async finalizeScenePreviewImportInner(
sceneId: SceneId,
assetId: AssetId,
): Promise<{ project: Project; changed: boolean }> {
const sessionAtStart = this.projectSession;
const open = this.openProject; const open = this.openProject;
if (!open) throw new Error('No open project'); if (!open) {
const sceneAtStart = open.project.scenes[sceneId]; // Queued finalize after close — nothing to apply.
return { project: undefined as unknown as Project, changed: false };
}
const projectAtStart = open.project;
const sceneAtStart = projectAtStart.scenes[sceneId];
if (sceneAtStart?.previewAssetId !== assetId) { if (sceneAtStart?.previewAssetId !== assetId) {
return { project: open.project, changed: false }; return { project: projectAtStart, changed: false };
} }
const sourceAsset = open.project.assets[assetId]; const sourceAsset = projectAtStart.assets[assetId];
if (!sourceAsset || (sourceAsset.type !== 'image' && sourceAsset.type !== 'video')) { if (!sourceAsset || (sourceAsset.type !== 'image' && sourceAsset.type !== 'video')) {
return { project: open.project, changed: false }; return { project: projectAtStart, changed: false };
} }
const cacheDir = open.cacheDir;
const stillOpen = () =>
this.openProject !== null &&
this.projectSession === sessionAtStart &&
this.openProject.cacheDir === cacheDir;
const generatedRelPaths: string[] = []; const generatedRelPaths: string[] = [];
let finalAsset = sourceAsset; let finalAsset = sourceAsset;
let finalAssetId = assetId; let finalAssetId = assetId;
let finalAbs = path.join(open.cacheDir, sourceAsset.relPath); let finalAbs = path.join(cacheDir, sourceAsset.relPath);
/** Image bytes kept in memory so thumb/optimize do not re-open a path that reconcile may unlink. */
let imageBytes: Buffer | null = null;
if (sourceAsset.type === 'image') { if (sourceAsset.type === 'image') {
const input = await fs.readFile(finalAbs); try {
const opt = await optimizeImageBufferVisuallyLossless(input); imageBytes = await fs.readFile(finalAbs);
} catch (e) {
const err = e as NodeJS.ErrnoException;
if (err?.code === 'ENOENT') {
const latest = this.getOpenProject();
return { project: latest ?? projectAtStart, changed: false };
}
throw e;
}
if (!stillOpen()) {
return { project: this.getOpenProject() ?? projectAtStart, changed: false };
}
const opt = await optimizeImageBufferVisuallyLossless(imageBytes);
if (!stillOpen()) {
return { project: this.getOpenProject() ?? projectAtStart, changed: false };
}
if (!opt.passthrough) { if (!opt.passthrough) {
finalAssetId = asAssetId(this.randomId()); finalAssetId = asAssetId(this.randomId());
const optimizedName = `${path.parse(sourceAsset.originalName).name}.${opt.ext}`; const optimizedName = `${path.parse(sourceAsset.originalName).name}.${opt.ext}`;
const safeOptimizedName = sanitizeFileName(optimizedName); const safeOptimizedName = sanitizeFileName(optimizedName);
const optimizedRelPath = `assets/${finalAssetId}_${safeOptimizedName}`; const optimizedRelPath = `assets/${finalAssetId}_${safeOptimizedName}`;
finalAbs = path.join(open.cacheDir, optimizedRelPath); finalAbs = path.join(cacheDir, optimizedRelPath);
const optimizedBuffer = Buffer.from(opt.buffer); const optimizedBuffer = Buffer.from(opt.buffer);
imageBytes = optimizedBuffer;
await fs.writeFile(finalAbs, optimizedBuffer); await fs.writeFile(finalAbs, optimizedBuffer);
generatedRelPaths.push(optimizedRelPath); generatedRelPaths.push(optimizedRelPath);
const optimizedAsset = buildMediaAsset( const optimizedAsset = buildMediaAsset(
@@ -500,14 +571,26 @@ export class ZipProjectStore {
} }
} }
if (!stillOpen()) {
await Promise.all(
generatedRelPaths.map((relPath) =>
fs.unlink(path.join(cacheDir, relPath)).catch(() => undefined),
),
);
return { project: this.getOpenProject() ?? projectAtStart, changed: false };
}
const thumbKind = finalAsset.type === 'image' ? 'image' : 'video'; const thumbKind = finalAsset.type === 'image' ? 'image' : 'video';
const thumbBytes = await generateScenePreviewThumbnailBytes(finalAbs, thumbKind); const thumbBytes =
thumbKind === 'image' && imageBytes
? await generateScenePreviewThumbnailBytes(imageBytes, 'image')
: await generateScenePreviewThumbnailBytes(finalAbs, thumbKind);
let thumbAsset: MediaAsset | null = null; let thumbAsset: MediaAsset | null = null;
let thumbId: AssetId | null = null; let thumbId: AssetId | null = null;
if (thumbBytes !== null && thumbBytes.length > 0) { if (thumbBytes !== null && thumbBytes.length > 0) {
thumbId = asAssetId(this.randomId()); thumbId = asAssetId(this.randomId());
const thumbRelPath = `assets/${thumbId}_preview_thumb.webp`; const thumbRelPath = `assets/${thumbId}_preview_thumb.webp`;
const thumbAbs = path.join(open.cacheDir, thumbRelPath); const thumbAbs = path.join(cacheDir, thumbRelPath);
await fs.writeFile(thumbAbs, thumbBytes); await fs.writeFile(thumbAbs, thumbBytes);
generatedRelPaths.push(thumbRelPath); generatedRelPaths.push(thumbRelPath);
const thumbOrigName = `${path.parse(finalAsset.originalName).name}_preview_thumb.webp`; const thumbOrigName = `${path.parse(finalAsset.originalName).name}_preview_thumb.webp`;
@@ -521,46 +604,67 @@ export class ZipProjectStore {
); );
} }
await this.updateProject((p) => { if (!stillOpen()) {
const scene = p.scenes[sceneId]; await Promise.all(
if (scene?.previewAssetId !== assetId) { generatedRelPaths.map((relPath) =>
return p; fs.unlink(path.join(cacheDir, relPath)).catch(() => undefined),
} ),
const assets: Record<AssetId, MediaAsset> = { ...p.assets, [finalAssetId]: finalAsset }; );
if (thumbAsset !== null && thumbId !== null) { return { project: this.getOpenProject() ?? projectAtStart, changed: false };
assets[thumbId] = thumbAsset; }
}
return { let applied = false;
...p, try {
assets, await this.updateProject((p) => {
scenes: { const scene = p.scenes[sceneId];
...p.scenes, if (scene?.previewAssetId !== assetId) {
[sceneId]: { return p;
...scene, }
previewAssetId: finalAssetId, applied = true;
previewAssetType: finalAsset.type, const assets: Record<AssetId, MediaAsset> = { ...p.assets, [finalAssetId]: finalAsset };
previewThumbAssetId: thumbId, if (finalAssetId !== assetId) {
previewVideoAutostart: finalAsset.type === 'video' ? scene.previewVideoAutostart : false, delete assets[assetId];
}
if (thumbAsset !== null && thumbId !== null) {
assets[thumbId] = thumbAsset;
}
return {
...p,
assets,
scenes: {
...p.scenes,
[sceneId]: {
...scene,
previewAssetId: finalAssetId,
previewAssetType: finalAsset.type,
previewThumbAssetId: thumbId,
previewVideoAutostart: finalAsset.type === 'video' ? scene.previewVideoAutostart : false,
},
}, },
}, };
}; });
}); } catch (e) {
if (!stillOpen() || (e instanceof Error && e.message === 'No open project')) {
await Promise.all(
generatedRelPaths.map((relPath) =>
fs.unlink(path.join(cacheDir, relPath)).catch(() => undefined),
),
);
return { project: this.getOpenProject() ?? projectAtStart, changed: false };
}
throw e;
}
const latest = this.getOpenProject(); const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
const latestScene = latest.scenes[sceneId];
const applied =
latestScene?.previewAssetId === finalAssetId &&
(thumbId === null || latestScene.previewThumbAssetId === thumbId);
if (!applied) { if (!applied) {
await Promise.all( await Promise.all(
generatedRelPaths.map((relPath) => generatedRelPaths.map((relPath) =>
fs.unlink(path.join(open.cacheDir, relPath)).catch(() => undefined), fs.unlink(path.join(cacheDir, relPath)).catch(() => undefined),
), ),
); );
} }
return { project: latest, changed: applied }; return { project: latest ?? projectAtStart, changed: applied };
} }
async clearScenePreview(sceneId: SceneId): Promise<Project> { async clearScenePreview(sceneId: SceneId): Promise<Project> {
@@ -601,15 +705,17 @@ export class ZipProjectStore {
} }
async updateProject(mutator: (draft: Project) => Project): Promise<Project> { async updateProject(mutator: (draft: Project) => Project): Promise<Project> {
const open = this.openProject; return this.enqueueProjectUpdate(async () => {
if (!open) throw new Error('No open project'); const open = this.openProject;
const prev = open.project; if (!open) throw new Error('No open project');
let next = mutator(prev); const prev = open.project;
next = await reconcileAssetFiles(prev, next, open.cacheDir); let next = mutator(prev);
open.project = next; next = await reconcileAssetFiles(prev, next, open.cacheDir);
await this.writeCacheProject(open.cacheDir, next); open.project = next;
this.queueSave(); await this.writeCacheProject(open.cacheDir, next);
return next; this.queueSave();
return next;
});
} }
async updateScene(sceneId: SceneId, patch: ScenePatch): Promise<Scene> { async updateScene(sceneId: SceneId, patch: ScenePatch): Promise<Scene> {
@@ -1653,14 +1759,18 @@ export class ZipProjectStore {
async saveNow(): Promise<void> { async saveNow(): Promise<void> {
const open = this.openProject; const open = this.openProject;
if (!open) return; if (!open) return;
// Let background preview finalizers finish before packing cache → zip.
await this.drainProjectMutations();
await this.projectWriteChain; await this.projectWriteChain;
await this.enqueuePack(open.cacheDir, open.zipPath); await this.enqueuePack(open.cacheDir, open.zipPath);
} }
async closeOpenProject(): Promise<void> { async closeOpenProject(): Promise<void> {
return this.enqueueProjectSwitch(async () => { return this.enqueueProjectSwitch(async () => {
// Invalidate in-flight finalize early; then wait for queue to settle.
this.projectSession += 1; this.projectSession += 1;
if (!this.openProject) return; if (!this.openProject) return;
await this.drainProjectMutations();
await this.saveNow(); await this.saveNow();
await this.drainSavePipeline(); await this.drainSavePipeline();
this.saveQueued = false; this.saveQueued = false;
@@ -2752,7 +2862,16 @@ async function zipDir(srcDir: string, outZipPath: string): Promise<void> {
const all = await listFilesRecursive(srcDir); const all = await listFilesRecursive(srcDir);
for (const abs of all) { for (const abs of all) {
const rel = path.relative(srcDir, abs).replace(/\\/gu, '/'); const rel = path.relative(srcDir, abs).replace(/\\/gu, '/');
zipfile.addFile(abs, rel, zipOptionsForRelativeEntry(rel)); // Bufferize: yazl.addFile opens lazily and can ENOENT if reconcile unlinks mid-pack.
let buf: Buffer;
try {
buf = await fs.readFile(abs);
} catch (e) {
const err = e as NodeJS.ErrnoException;
if (err?.code === 'ENOENT') continue;
throw e;
}
zipfile.addBuffer(buf, rel, zipOptionsForRelativeEntry(rel));
} }
await fs.mkdir(path.dirname(outZipPath), { recursive: true }); await fs.mkdir(path.dirname(outZipPath), { recursive: true });
const out = fssync.createWriteStream(outZipPath); const out = fssync.createWriteStream(outZipPath);