storebound/cli/lib/lock.ts

90 lines
4.9 KiB
TypeScript

// One writer per app and store: a write command takes .storebound/locks/<store>-<app>.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<string>();
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-<bundle id>` / `play-<package>` for store
* writes, both for `prices import`, `revenuecat-<project>` 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 [];
}