import { link, mkdir, open, readFile, rename, rm, stat } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { existsSync } from 'node:fs'; import { resolveProjectWorkspace } from './workspace.js'; import { writeTextFileAtomic } from './atomic-write.js'; import type { SceneCandidatesArtifact } from './types.js'; export function sceneCandidatesPathFor(root: string, slug: string): string { return join( resolveProjectWorkspace(slug, root).projectDir, 'artifacts', 'scene-candidates.json', ); } export async function readSceneCandidatesArtifact( root: string, slug: string, ): Promise { const path = sceneCandidatesPathFor(root, slug); if (!existsSync(path)) { return { schemaVersion: 1, scenes: [] }; } const raw = await readFile(path, 'utf8'); return JSON.parse(raw) as SceneCandidatesArtifact; } export async function writeSceneCandidatesArtifact( root: string, slug: string, artifact: SceneCandidatesArtifact, ): Promise { const path = sceneCandidatesPathFor(root, slug); await mkdir(dirname(path), { recursive: true }); await writeTextFileAtomic(path, JSON.stringify(artifact, null, 2) + '\n'); } export async function updateSceneCandidatesArtifact( root: string, slug: string, updater: (artifact: SceneCandidatesArtifact) => Promise<{ artifact: SceneCandidatesArtifact; result: T; }> | { artifact: SceneCandidatesArtifact; result: T; }, ): Promise { const path = sceneCandidatesPathFor(root, slug); await mkdir(dirname(path), { recursive: true }); const release = await acquireSceneCandidatesLock(path); try { const current = await readSceneCandidatesArtifact(root, slug); const updated = await updater(current); await writeTextFileAtomic(path, JSON.stringify(updated.artifact, null, 2) + '\n'); return updated.result; } finally { await release(); } } /** * Run `fn` while holding the SINGLE per-project artifact lock that guards BOTH * `scene-candidates.json` AND `scene-selection.json`. Used to make the * concurrent `pool` render path's per-scene candidate/selection read-modify-write * atomic: concurrent scenes serialize on the fast on-disk write while their slow * provider submit/poll stays OUTSIDE the lock. * * The canonical concurrency-safe shape inside `fn` is "RE-READ current → apply * MY deltas to the fresh current → write", so a concurrent writer's update is * never lost (the re-read inside the lock sees it). * * DEADLOCK RULE — the file lock is NOT re-entrant. Code inside `fn` must use * ONLY the RAW read/write functions (`readSceneCandidatesArtifact` / * `writeSceneCandidatesArtifact` / `readSceneSelectionArtifact` / * `writeSceneSelectionArtifact`) and the pure transforms (`appendCandidate` / * `markPending` / `updateCandidate` / `selectCandidate`). It must NEVER call * `updateSceneCandidatesArtifact` or `withSceneArtifactsLock` again (acquiring * the same `open(wx)` lock a second time on the same path would block forever). */ export async function withSceneArtifactsLock( root: string, slug: string, fn: () => Promise, ): Promise { const path = sceneCandidatesPathFor(root, slug); await mkdir(dirname(path), { recursive: true }); const release = await acquireSceneCandidatesLock(path); try { return await fn(); } finally { await release(); } } const STALE_LOCK_MS = 30_000; let lockStealCounter = 0; function errnoCode(error: unknown): string | undefined { return error instanceof Error && 'code' in error ? (error as NodeJS.ErrnoException).code : undefined; } async function acquireSceneCandidatesLock(path: string): Promise<() => Promise> { const lockPath = `${path}.lock`; const deadline = Date.now() + 10_000; while (true) { try { const handle = await open(lockPath, 'wx'); await handle.writeFile(JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString(), })); return async () => { await handle.close(); await rm(lockPath, { force: true }); }; } catch (error) { if (errnoCode(error) !== 'EEXIST') throw error; await breakStaleLock(lockPath); if (Date.now() >= deadline) { throw new Error(`timed out waiting for scene-candidates lock: ${lockPath}`); } await sleep(25); } } } /** * Break a crashed holder's lock SAFELY. The previous implementation stat()'d the * lock age and then unconditionally rm()'d it — so two contenders that both * observed the same stale lock could each remove a lock, the second deleting the * FRESH lock the first had just acquired via open(wx). That let two writers run * the read-modify-write concurrently and lose updates to scene-candidates.json. * * Instead we atomically rename the stale lock to a per-contender unique path: * rename moves a given inode exactly once, so at most one contender ever removes a * given lock, and a fresh lock acquired by another contender is never collaterally * deleted (a losing racer just gets ENOENT and retries the acquire loop). */ async function breakStaleLock(lockPath: string): Promise { let ageMs: number; try { ageMs = Date.now() - (await stat(lockPath)).mtimeMs; } catch (error) { if (errnoCode(error) === 'ENOENT') return; // already released throw error; } if (ageMs <= STALE_LOCK_MS) return; // live lock — leave it for the holder const claimPath = `${lockPath}.stale-${process.pid}-${++lockStealCounter}`; try { await rename(lockPath, claimPath); // atomic claim; a losing racer gets ENOENT } catch (error) { if (errnoCode(error) === 'ENOENT') return; // another contender already claimed it throw error; } // We now exclusively own the claimed inode. Re-confirm staleness before // discarding; if it is NOT actually stale (a backward clock adjustment, or it // was replaced by a fresh lock between the pre-check and the claim), restore // it WITHOUT clobbering a lock another contender may have created at lockPath // in the meantime: link() creates lockPath only if it does not already exist // (fails EEXIST), so a concurrently-acquired fresh lock always wins and we // drop our claim. (A fully race-free break would need flock(2) / a native // lock; avoided here to keep the package dependency-free — this is the cold // crash-recovery path, not the hot acquire path.) try { if (Date.now() - (await stat(claimPath)).mtimeMs > STALE_LOCK_MS) { await rm(claimPath, { force: true }); } else { try { await link(claimPath, lockPath); } catch (error) { if (errnoCode(error) !== 'EEXIST') throw error; } await rm(claimPath, { force: true }); } } catch (error) { if (errnoCode(error) !== 'ENOENT') throw error; } } async function sleep(milliseconds: number): Promise { await new Promise((resolve) => setTimeout(resolve, milliseconds)); }