90 lines
4.9 KiB
TypeScript
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 [];
|
|
}
|