// 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 { homedir, hostname } from 'node:os'; import { mkdirSync, openSync, readFileSync, rmSync, statSync, utimesSync, writeSync, closeSync } from 'node:fs'; import { join } from 'node:path'; import { AsyncLocalStorage } from 'node:async_hooks'; 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); }; } // ---- Machine-wide short locks (Play edits) ---- // Google Play keeps one open edit per app: a second edits.insert for the same package invalidates the first ("This // Edit has been deleted"). Every storebound process that opens an edit (reads included) takes // ~/.storebound/locks/.lock first, so parallel reads of one package wait for each other; other packages run in // parallel. The holder touches the file every few seconds (heartbeat); a file whose process is gone or that was not // touched for STALE_SHORT_MS is taken over. Waiting has a timeout and logs who it waits for. export const machineLockDir = (): string => process.env.STOREBOUND_LOCK_DIR || join(homedir(), '.storebound', 'locks'); const STALE_SHORT_MS = 60_000; /** Whether a short lock may be taken over: unreadable, its process gone (same host) or not touched for a minute. */ export function staleShortLock(l: LockInfo | null, touchedAt: number, now = Date.now(), host = hostname(), isAlive = alive): boolean { if (!l) return now - touchedAt > 2000; // half written: give the writer a moment if (now - touchedAt > STALE_SHORT_MS) return true; return l.host === host && !isAlive(l.pid); } const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); /** The machine locks held by the current async call chain: a nested take of the same key runs inside the outer one * (it would otherwise wait for itself), while a parallel caller in the same process still waits. */ const inside = new AsyncLocalStorage>(); /** * Runs fn while holding the machine-wide lock `key` (e.g. `play-edit-`). Waits up to `timeoutMs` for another * holder (polling, a heartbeat line every `heartbeatMs` through `onWait`), then throws with the holder. */ export async function withMachineLock(key: string, command: string, fn: () => Promise, o: { timeoutMs?: number; pollMs?: number; heartbeatMs?: number; onWait?: (msg: string) => void } = {}): Promise { if (inside.getStore()?.has(key)) return fn(); const dir = machineLockDir(); mkdirSync(dir, { recursive: true, mode: 0o700 }); const path = join(dir, `${key.replace(/[^A-Za-z0-9._-]+/g, '_')}.lock`); const timeout = o.timeoutMs ?? 90_000, poll = o.pollMs ?? 100, beat = o.heartbeatMs ?? 10_000; const start = Date.now(); let lastBeat = start; for (;;) { try { const fd = openSync(path, 'wx', 0o600); writeSync(fd, JSON.stringify({ pid: process.pid, host: hostname(), command, startedAt: new Date().toISOString() } satisfies LockInfo) + '\n'); closeSync(fd); break; } catch (e) { if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e; let cur: LockInfo | null = null, touched = Date.now(); try { touched = statSync(path).mtimeMs; cur = JSON.parse(readFileSync(path, 'utf8')) as LockInfo; } catch { /* gone or half written */ } if (staleShortLock(cur, touched)) { rmSync(path, { force: true }); continue; } const waited = Date.now() - start; const who = cur ? `\`storebound ${cur.command}\` (pid ${cur.pid})` : 'another storebound run'; if (waited > timeout) throw new Error(`timed out after ${Math.round(waited / 1000)} s waiting for ${key}: ${who} still holds it (Google Play allows one open edit per app). Try again when it is done.`); if (Date.now() - lastBeat >= beat) { lastBeat = Date.now(); o.onWait?.(`waiting ${Math.round(waited / 1000)} s for ${key}, held by ${who}`); } await sleep(poll); } } held.add(path); if (!hooked) { hooked = true; process.on('exit', () => { for (const p of held) rmSync(p, { force: true }); }); } const hb = setInterval(() => { try { const t = new Date(); utimesSync(path, t, t); } catch { /* removed */ } }, 5000); (hb as { unref?: () => void }).unref?.(); try { return await inside.run(new Set([...(inside.getStore() ?? []), key]), fn); } finally { clearInterval(hb); 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 []; }