// One writer per app and store: a write command takes .storebound/locks/-.lock before it changes // anything, so two storebound runs (two terminals, an agent and a CI job, a flow and a hand run) never write to the // same app at once and race each other into duplicates. Dry runs and reads take no lock. // A lock whose process is gone (same machine) is taken over; a live one stops the run with who holds it. A lock // left by a crash on another machine (shared folder) stays until --force-unlock or 12 hours. import { hostname } from 'node:os'; import { mkdirSync, openSync, readFileSync, rmSync, writeSync, closeSync } from 'node:fs'; import { join } from 'node:path'; import type { ShipConfig } from './config'; import type { Args } from './args'; export interface LockInfo { pid: number; host: string; command: string; startedAt: string } const STALE_MS = 12 * 3600_000; export const lockPath = (root: string, key: string): string => join(root, '.storebound', 'locks', `${key.replace(/[^A-Za-z0-9._-]+/g, '_')}.lock`); function alive(pid: number): boolean { try { process.kill(pid, 0); return true; } catch (e) { return (e as NodeJS.ErrnoException).code === 'EPERM'; } } /** Whether a lock may be taken over: its process is gone (same host) or it is older than 12 hours. Pure apart from * the process check. */ export function staleLock(l: LockInfo | null, now = Date.now(), host = hostname(), isAlive = alive): boolean { if (!l) return true; if (now - Date.parse(l.startedAt) > STALE_MS) return true; return l.host === host && !isAlive(l.pid); } const held = new Set(); let hooked = false; /** Takes the lock `key` for `command` (`apple iap`); throws with the holder when another run has it. Released when the * process exits. `force` removes a lock first (--force-unlock). */ export function acquireLock(root: string, key: string, command: string, o: { force?: boolean } = {}): () => void { const path = lockPath(root, key); mkdirSync(join(root, '.storebound', 'locks'), { recursive: true }); if (o.force) rmSync(path, { force: true }); for (let attempt = 0; ; attempt++) { try { const fd = openSync(path, 'wx', 0o600); const info: LockInfo = { pid: process.pid, host: hostname(), command, startedAt: new Date().toISOString() }; writeSync(fd, JSON.stringify(info) + '\n'); closeSync(fd); break; } catch (e) { if ((e as NodeJS.ErrnoException).code !== 'EEXIST' || attempt > 2) throw e; let cur: LockInfo | null = null; try { cur = JSON.parse(readFileSync(path, 'utf8')) as LockInfo; } catch { /* half written or unreadable: treat as stale */ } if (!staleLock(cur)) { throw new Error(`another storebound run is writing to ${key.replace(/^(\w+)-/, '$1 ')}: \`storebound ${cur!.command}\` (pid ${cur!.pid} on ${cur!.host}, since ${cur!.startedAt.slice(0, 16).replace('T', ' ')} UTC). ` + `Two writers at once can create duplicates, so this run stops. Wait for it to finish; if it is no longer running, re-run with --force-unlock (removes ${join('.storebound', 'locks', path.split('/').pop()!)}).`); } rmSync(path, { force: true }); } } held.add(path); if (!hooked) { hooked = true; process.on('exit', () => { for (const p of held) rmSync(p, { force: true }); }); } return () => { rmSync(path, { force: true }); held.delete(path); }; } /** Read-only subcommands and flags: no lock. */ const READ_SUBS = new Set(['status', 'list', 'devices', 'diff', 'export', 'pull', 'wait', 'options', 'coverage', 'report', 'campaigns', 'check', 'help']); /** * The lock keys a command run needs: none for dry runs and reads, `apple-` / `play-` for store * writes, both for `prices import`, `revenuecat-` for revenuecat sync. Pure. */ export function lockKeys(command: string, args: Args, config: ShipConfig): string[] { // a dry run never writes, so it never waits for (or blocks) a writer const readOnly = args.has('dry'); if (readOnly) return []; const sub = args._[0]; const apple = config.apple?.bundleId ? [`apple-${config.apple.bundleId}`] : []; const play = config.play?.packageName ? [`play-${config.play.packageName}`] : []; if (command.startsWith('apple ')) { if (['apple status', 'apple next-build', 'apple build'].includes(command)) return []; if (command === 'apple listing' && sub === 'pull') return []; if (sub && READ_SUBS.has(sub)) return []; if (command === 'apple adhoc' && ['options', 'export'].includes(sub ?? '')) return []; return apple; } if (command.startsWith('play ')) { if (['play status', 'play next-version-code', 'play build'].includes(command)) return []; if (command === 'play listing' && sub === 'pull') return []; return play; } if (command === 'prices') return sub === 'import' && !args.has('json') ? [...apple, ...play] : []; if (command === 'revenuecat') return sub === 'sync' ? [`revenuecat-${config.apple?.bundleId ?? config.play?.packageName ?? 'project'}`] : []; return []; }