storebound/cli/lib/lock.ts

151 lines
8.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 { 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<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); };
}
// ---- 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/<key>.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<void>(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<Set<string>>();
/**
* Runs fn while holding the machine-wide lock `key` (e.g. `play-edit-<package>`). Waits up to `timeoutMs` for another
* holder (polling, a heartbeat line every `heartbeatMs` through `onWait`), then throws with the holder.
*/
export async function withMachineLock<T>(key: string, command: string, fn: () => Promise<T>,
o: { timeoutMs?: number; pollMs?: number; heartbeatMs?: number; onWait?: (msg: string) => void } = {}): Promise<T> {
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-<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 [];
}