Play edits under a machine-wide per-package lock (parallel reads of one app no longer delete each other's edits; a read whose edit was deleted is redone once, writes are not); a store that failed to read is 'Couldn't read Google Play' and keeps its last good releases with 'couldn't refresh'; stores on different versions: neutral 'Current · Play x · App Store y' header and each lane its own state, 'Not on this store' only without a record; Play status decided from the public store page's live version (Live / In review / In review (live: x) / managed publishing / Rolling out / Halted / Draft; only a network failure says 'Couldn't check the store page', with the last known answer and its time); tests/play-edit-lock.test.ts, recorded page fixture, boot check auto-read cases (pitfall 58)
This commit is contained in:
parent
e59f439101
commit
714352ec18
24 changed files with 570 additions and 103 deletions
|
|
@ -3,9 +3,10 @@
|
|||
// 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 { 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';
|
||||
|
||||
|
|
@ -58,6 +59,66 @@ export function acquireLock(root: string, key: string, command: string, o: { for
|
|||
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']);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue