import { createHash, randomUUID } from 'node:crypto' import * as fs from 'node:fs/promises' import { hostname, tmpdir } from 'node:os' import * as path from 'node:path' /** * The install mutex is per-machine (staleness is judged by pid + hostname), so it lives in the OS * temp dir keyed by the project root — never inside the project, where it could leak into packs * or version control. */ export function projectInstallLockPath(projectRoot: string): string { const key = createHash('sha256').update(path.resolve(projectRoot)).digest('hex').slice(0, 16) return path.join(tmpdir(), `drawcall-market-install-${key}.lock`) } const OWNER_FILE = 'owner.json' const DEFAULT_TIMEOUT_MS = 30_000 const DEFAULT_RETRY_DELAY_MS = 50 const DEFAULT_STALE_MS = 5 * 60_000 interface LockOwner { token: string pid: number hostname: string createdAtMs: number } interface LockSnapshot { owner: LockOwner | null device: number inode: number modifiedAtMs: number } export interface ProjectInstallLockOptions { timeoutMs?: number retryDelayMs?: number staleMs?: number } export async function withProjectInstallLock( projectRoot: string, task: () => Promise, options: ProjectInstallLockOptions = {}, ): Promise { const lockPath = projectInstallLockPath(projectRoot) const owner = await acquireLock(lockPath, options) try { return await task() } finally { await releaseLock(lockPath, owner) } } async function acquireLock( lockPath: string, options: ProjectInstallLockOptions, ): Promise { const timeoutMs = validDuration(options.timeoutMs, DEFAULT_TIMEOUT_MS, 'timeoutMs') const retryDelayMs = validDuration(options.retryDelayMs, DEFAULT_RETRY_DELAY_MS, 'retryDelayMs') const staleMs = validDuration(options.staleMs, DEFAULT_STALE_MS, 'staleMs') const startedAtMs = Date.now() const owner = createOwner() await fs.mkdir(path.dirname(lockPath), { recursive: true }) while (true) { if (await tryCreateLock(lockPath, owner)) return owner const snapshot = await readLockSnapshot(lockPath) if (!snapshot) continue if (isStale(snapshot, staleMs) && (await quarantineStaleLock(lockPath, snapshot))) { continue } const elapsedMs = Date.now() - startedAtMs if (elapsedMs >= timeoutMs) { throw new Error(lockTimeoutMessage(lockPath, timeoutMs, snapshot.owner)) } await delay(Math.min(retryDelayMs, timeoutMs - elapsedMs)) } } async function tryCreateLock(lockPath: string, owner: LockOwner): Promise { // Directory creation is the cross-process compare-and-set: exactly one installer can succeed. try { await fs.mkdir(lockPath) } catch (error) { if (hasCode(error, 'EEXIST')) return false throw error } try { await fs.writeFile(path.join(lockPath, OWNER_FILE), JSON.stringify(owner, null, 2) + '\n', { flag: 'wx', }) } catch (error) { await fs.rm(lockPath, { recursive: true, force: true }) throw error } return true } async function releaseLock(lockPath: string, owner: LockOwner): Promise { const snapshot = await readLockSnapshot(lockPath) if (snapshot?.owner?.token !== owner.token) { throw new Error(`Lost ownership of Market install lock at ${lockPath}; refusing to release it.`) } const releasedPath = `${lockPath}.released-${owner.token}` // Cleanup happens at an owner-specific path, so a successor can acquire the canonical path // without being vulnerable to this process deleting its lock. await fs.rename(lockPath, releasedPath) const released = await readLockSnapshot(releasedPath) if (released?.owner?.token !== owner.token) { await restoreLock(releasedPath, lockPath) throw new Error(`Market install lock at ${lockPath} changed while it was being released.`) } await fs.rm(releasedPath, { recursive: true, force: true }) } async function quarantineStaleLock(lockPath: string, expected: LockSnapshot): Promise { const stalePath = `${lockPath}.stale-${randomUUID()}` try { await fs.rename(lockPath, stalePath) } catch (error) { if (hasCode(error, 'ENOENT')) return true throw error } const quarantined = await readLockSnapshot(stalePath) if (!quarantined) return true // Another process may have replaced the stale lock after our read. Inode identity keeps this // recovery attempt from deleting that replacement. if ( quarantined.device !== expected.device || quarantined.inode !== expected.inode || quarantined.modifiedAtMs !== expected.modifiedAtMs || quarantined.owner?.token !== expected.owner?.token ) { await restoreLock(stalePath, lockPath) return false } await fs.rm(stalePath, { recursive: true, force: true }) return true } async function restoreLock(from: string, to: string): Promise { try { await fs.rename(from, to) } catch (error) { throw new Error(`Could not restore Market install lock at ${to}.`, { cause: error }) } } async function readLockSnapshot(lockPath: string): Promise { let stats try { stats = await fs.stat(lockPath) } catch (error) { if (hasCode(error, 'ENOENT')) return null throw error } return { owner: await readOwner(lockPath), device: stats.dev, inode: stats.ino, modifiedAtMs: stats.mtimeMs, } } async function readOwner(lockPath: string): Promise { let value: unknown try { value = JSON.parse(await fs.readFile(path.join(lockPath, OWNER_FILE), 'utf-8')) } catch (error) { if (hasCode(error, 'ENOENT') || error instanceof SyntaxError) return null throw error } if (!isRecord(value)) return null if (typeof value.token !== 'string') return null if (typeof value.pid !== 'number' || !Number.isSafeInteger(value.pid) || value.pid <= 0) return null if (typeof value.hostname !== 'string') return null if (typeof value.createdAtMs !== 'number' || Number.isNaN(new Date(value.createdAtMs).getTime())) return null return { token: value.token, pid: value.pid, hostname: value.hostname, createdAtMs: value.createdAtMs, } } function isStale(snapshot: LockSnapshot, staleMs: number): boolean { if (snapshot.owner?.hostname === hostname()) { const running = isProcessRunning(snapshot.owner.pid) if (running !== null) return !running } return Date.now() - snapshot.modifiedAtMs >= staleMs } function isProcessRunning(pid: number): boolean | null { try { process.kill(pid, 0) return true } catch (error) { if (hasCode(error, 'ESRCH')) return false if (hasCode(error, 'EPERM')) return true return null } } function createOwner(): LockOwner { return { token: randomUUID(), pid: process.pid, hostname: hostname(), createdAtMs: Date.now(), } } function lockTimeoutMessage(lockPath: string, timeoutMs: number, owner: LockOwner | null): string { const ownerDescription = owner ? ` Owner PID ${owner.pid} on ${owner.hostname} acquired it at ${new Date(owner.createdAtMs).toISOString()}.` : '' return `Timed out after ${timeoutMs}ms waiting for Market install lock at ${lockPath}.${ownerDescription}` } function validDuration(value: number | undefined, fallback: number, name: string): number { if (value === undefined) return fallback if (!Number.isFinite(value) || value < 0) { throw new TypeError(`${name} must be a non-negative finite number.`) } return value } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } function hasCode(error: unknown, code: string): boolean { return error instanceof Error && 'code' in error && error.code === code } function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) }