Files
ProdDash/modules/prodcom-transcript/supervisor.js
T

438 lines
18 KiB
JavaScript
Raw Normal View History

'use strict';
/**
* ProdCom Transcript — keeping ProdCom itself running.
*
* When ProdCom runs on the same Mac as ProdDash (the booth Mac) and its API
* stops answering, the usual reason is that nobody has opened ProdCom, or that
* its Application API is switched off — ProdCom reads that switch only at
* launch. This watches for that and puts it right: open ProdCom if it is not
* running; if it is running with the API off, turn the API on and restart it.
* Nothing here does anything on another OS, or when ProdCom is on another
* machine — a remote ProdCom is somebody else's to look after.
*
* The module calls kick(code) every time a connection to ProdCom fails and
* noteReachable() when one succeeds; the rest is decided here. A "run" is up to
* three attempts, each: act, wait five seconds, ask the API whether it is up.
* When a run cannot fix it, the supervisor gives up for that outage — kick()
* then returns at once, so the module's reconnect loop can call it every few
* seconds without anything happening — until a connection succeeds again.
*
* Every touch of the machine (is ProdCom running, open it, read/write its
* preference, quit it) and of the network (the ping) is injected, so
* supervisor.test.js drives the decisions with fakes; the real ones are
* defaultOs() and httpProbe() below, all child_process.execFile with an
* argument list — never a shell string — and a timeout.
*/
const http = require('http');
const nodeOs = require('os');
const { execFile } = require('child_process');
const BUNDLE_ID = 'com.prodcom.prodcom';
const PROCESS_NAME = 'ProdCom';
const API_PREF = 'applicationAPIEnabled'; // a UserDefaults bool in the app's own domain
const MAX_ATTEMPTS = 3;
const ATTEMPT_WAIT_MS = 5000; // after acting, before asking whether the API answers
const PROBE_TIMEOUT_MS = 3000;
const QUICK_TOOL_MS = 5000; // pgrep, defaults, pkill
const SLOW_TOOL_MS = 10000; // open
const QUIT_ASK_MS = 5000; // osascript's patience: a ProdCom that will not answer an Apple Event is told with SIGTERM
const QUIT_WAIT_MS = 10000; // for the process to be gone after asking it to quit
const QUIT_POLL_MS = 500;
/* ── the real machine ───────────────────────────────────────────────── */
/** Run a program with an argument list (`execFileImpl` is child_process.execFile, or a fake in tests).
Never rejects: { ok, exitCode, error, stdout, stderr } —
exitCode is the program's own exit status, or null when it never got to exit
normally (not found, timed out, killed). */
function runProgram(execFileImpl, file, args, timeout) {
return new Promise((resolve) => {
execFileImpl(file, args, { timeout, windowsHide: true }, (error, stdout, stderr) => {
resolve({
ok: !error,
exitCode: error ? (typeof error.code === 'number' ? error.code : null) : 0,
error,
stdout: String(stdout || ''),
stderr: String(stderr || ''),
});
});
});
}
/** What went wrong with an exec() result, for a message: how it failed, then the first line the program said about it. */
function why(r) {
let how = 'unknown';
if (r.exitCode !== null) how = `exit ${r.exitCode}`;
else if (r.error) how = r.error.killed ? 'timed out' : (r.error.code || r.error.message);
const said = r.stderr.split('\n').map((l) => l.trim()).find(Boolean);
if (!said) return how;
return `${how}: ${said.length > 120 ? `${said.slice(0, 120)}…` : said}`;
}
/** A sleep the supervisor can cut short: resolves true after `ms`, false at once
when `signal` aborts (its timer is cleared, so nothing is left behind). */
function abortableSleep(ms, signal) {
return new Promise((resolve) => {
if (signal && signal.aborted) return resolve(false);
let timer = null;
const onAbort = () => {
clearTimeout(timer);
resolve(false);
};
timer = setTimeout(() => {
if (signal) signal.removeEventListener('abort', onAbort);
resolve(true);
}, ms);
timer.unref?.();
if (signal) signal.addEventListener('abort', onAbort, { once: true });
});
}
/** `defaults read` prints a bool as 1 / 0; anything else is not an answer. */
function parseApiPref(stdout) {
const s = String(stdout || '').trim().toLowerCase();
if (s === '1' || s === 'true' || s === 'yes') return true;
if (s === '0' || s === 'false' || s === 'no') return false;
return null;
}
/** The address as one canonical spelling, so a configured "::1" matches the interface's. */
function canonicalAddress(a) {
let s = String(a ?? '').trim().toLowerCase();
if (s.startsWith('[') && s.endsWith(']')) s = s.slice(1, -1);
s = s.replace(/%.*$/, ''); // a scope id (fe80::1%en0)
if (s.includes(':')) {
try { s = new URL(`http://[${s}]`).hostname.slice(1, -1); } catch { /* not an address */ }
}
return s;
}
/** Is `host` this machine? `hostname` and `addresses` are this machine's own
(os.hostname(), every address of os.networkInterfaces()) — passed in so this
is a pure function. */
function isLocalHostName(host, hostname, addresses) {
const h = canonicalAddress(host).replace(/\.$/, '');
if (h === '' || h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '0.0.0.0') return true;
const bare = (s) => String(s).toLowerCase().replace(/\.$/, '').replace(/\.local$/, '');
if (hostname && bare(hostname) === bare(h)) return true;
return (addresses || []).some((a) => canonicalAddress(a) === h);
}
/** Is anything answering HTTP at host:port? Any response counts, whatever its
status — the API's ping needs no key, and a 401 is still a live server. */
function httpProbe(host, port) {
return new Promise((resolve) => {
let done = false;
const finish = (up) => {
if (done) return;
done = true;
resolve(up);
};
let h = canonicalAddress(host);
if (!h || h === '0.0.0.0') h = '127.0.0.1';
try {
const req = http.get({
host: h,
port,
path: '/api/v1/ping',
agent: false,
timeout: PROBE_TIMEOUT_MS,
headers: { accept: 'application/json' },
}, (res) => {
res.resume();
finish(true);
});
req.on('timeout', () => {
req.destroy();
finish(false);
});
req.on('error', () => finish(false));
} catch {
finish(false);
}
});
}
/**
* The operating-system layer: macOS commands, each run with execFile.
* `deps` is for tests only — { execFile, sleep, now } replace the real
* child_process.execFile, abortableSleep and Date.now.
*/
function defaultOs(deps = {}) {
const exec = (file, args, timeout) => runProgram(deps.execFile || execFile, file, args, timeout);
const wait = deps.sleep || abortableSleep;
const now = deps.now || Date.now;
async function isRunning() {
const r = await exec('/usr/bin/pgrep', ['-x', PROCESS_NAME], QUICK_TOOL_MS);
if (r.ok) return true;
if (r.exitCode === 1) return false; // pgrep's "nothing matched"
throw new Error(`Could not check whether ProdCom is running (${why(r)})`);
}
/** Open ProdCom in the background (-g): a booth Mac must not lose the keyboard from ProPresenter or OBS mid-service. */
async function launch() {
const r = await exec('/usr/bin/open', ['-g', '-b', BUNDLE_ID], SLOW_TOOL_MS);
if (r.ok) return;
if (r.exitCode !== null) throw new Error('ProdCom is not installed on this machine');
throw new Error(`Could not open ProdCom (${why(r)})`);
}
async function readApiPref() {
const r = await exec('/usr/bin/defaults', ['read', BUNDLE_ID, API_PREF], QUICK_TOOL_MS);
if (r.ok) return parseApiPref(r.stdout);
if (r.exitCode !== null) return null; // the key does not exist yet
// Not knowing is not the same as "off": it would restart a ProdCom that is fine.
throw new Error(`Could not read ProdCom's settings (${why(r)})`);
}
async function writeApiPref() {
const r = await exec('/usr/bin/defaults', ['write', BUNDLE_ID, API_PREF, '-bool', 'true'], QUICK_TOOL_MS);
if (!r.ok) throw new Error(`Could not turn on ProdCom's API (${why(r)})`);
}
/** Ask ProdCom to quit; if it will not be asked, terminate it. Resolves when it is gone.
Once `signal` aborts it does nothing more — no SIGTERM, no further look at the process list. */
async function quit(signal) {
const cancelled = () => Boolean(signal && signal.aborted);
if (cancelled()) throw new Error('cancelled');
const asked = await exec('/usr/bin/osascript', ['-e', `tell application id "${BUNDLE_ID}" to quit`], QUIT_ASK_MS);
if (cancelled()) throw new Error('cancelled');
if (!asked.ok) await exec('/usr/bin/pkill', ['-x', '-TERM', PROCESS_NAME], QUICK_TOOL_MS);
const until = now() + QUIT_WAIT_MS;
for (;;) {
if (cancelled()) throw new Error('cancelled');
if (!(await isRunning())) return;
if (now() >= until) throw new Error('ProdCom would not quit');
await wait(QUIT_POLL_MS, signal);
}
}
return {
platform: process.platform,
isLocalHost(host) {
const addresses = [];
for (const list of Object.values(nodeOs.networkInterfaces())) {
for (const a of list || []) addresses.push(a.address);
}
return isLocalHostName(host, nodeOs.hostname(), addresses);
},
isRunning,
launch,
readApiPref,
writeApiPref,
quit,
};
}
/* ── the decisions ──────────────────────────────────────────────────── */
/**
* @param opts.settings () => { autoStart, apiRestart } read live, every time
* @param opts.target () => { host, port } where the module points ProdCom
* @param opts.probe () => Promise<boolean> optional; the API answers (default: GET /api/v1/ping)
* @param opts.os the machine layer (see defaultOs); optional, defaults to the real one
* @param opts.sleep (ms, signal) => Promise optional; the default is cut short by `signal`
* @param opts.log (line) => void
* @param opts.status (state, message) => void the tile's status; state is 'connecting' | 'error'
* @param opts.onUp () => void optional; ProdCom answers again — the module may reconnect now
*/
function createSupervisor(opts) {
const { settings, target, log, status } = opts;
const onUp = opts.onUp || (() => {});
const os = opts.os || defaultOs();
const probe = opts.probe || (() => { const t = target(); return httpProbe(t.host, t.port); });
const sleep = opts.sleep || abortableSleep;
let stopped = false;
let busy = false; // a run is in progress
let gaveUp = false; // this outage is beyond the supervisor until a connection succeeds
let msg = null; // the supervisor's words for the tile
let epoch = 0; // a run belongs to the epoch it started in; a new one ends it
let abort = null; // cuts the running run's sleep (and quit) short
let current = Promise.resolve();
const said = new Set(); // no-op reasons already logged
/** End the run in progress, if any: it unwinds without another word. */
function cancelRun() {
epoch += 1;
if (abort) {
abort.abort();
abort = null;
}
}
/** Nothing to do, and why — logged once per distinct reason, not on every reconnect tick. */
function skip(reason) {
if (!said.has(reason)) {
said.add(reason);
log(reason);
}
return reason;
}
/** Wait `ms`, but never past stop() or the end of the run, whatever `sleep` does with the signal. */
function pause(ms, signal) {
return new Promise((resolve) => {
if (signal.aborted) return resolve();
const done = () => {
signal.removeEventListener('abort', done);
resolve();
};
signal.addEventListener('abort', done, { once: true });
Promise.resolve().then(() => sleep(ms, signal)).then(done, done);
});
}
async function run(myEpoch, signal) {
const live = () => !stopped && epoch === myEpoch;
const say = (state, text) => {
msg = text;
status(state, text);
};
const giveUp = (text) => {
gaveUp = true;
say('error', text);
log(text);
};
let restarted = false; // ProdCom was quit and opened again in this run — once is all it gets
let opened = false; // this run opened ProdCom itself: if it is running now, it may simply still be starting up
let apiOnSilent = false; // the latest look found ProdCom running, API on, port silent, and not of our opening
try {
for (let n = 1; n <= MAX_ATTEMPTS; n += 1) {
const s = settings();
apiOnSilent = false;
// Look afresh every attempt: one sample proves little (a ProdCom that is quitting still shows as running).
const running = await os.isRunning();
if (!live()) return;
if (!running) {
// Not there — never opened, quit by hand, or gone since the restart. A launch, never a second quit.
await os.launch();
if (!live()) return;
opened = true;
say('connecting', `ProdCom is not running — starting it (${n}/${MAX_ATTEMPTS})`);
} else if (!restarted) {
const pref = await os.readApiPref();
if (!live()) return;
if (pref === true) {
// Running with its API on and the port still silent. Slow to start, quitting, or the wrong port —
// one look cannot tell, so wait and look again; only the last attempt blames the port.
apiOnSilent = !opened;
} else {
// The API is off (or the key was never written): switch it on for ProdCom's next start…
await os.writeApiPref();
if (!live()) return;
// …and restart ProdCom only if the setting really holds: that restart cuts its intercom.
const now = await os.readApiPref();
if (!live()) return;
if (now !== true) {
return giveUp(`Could not turn on ProdCom's API — it still reads ${now === false ? 'off' : 'unset'}`);
}
if (!s.apiRestart) {
return giveUp("ProdCom's API is off — restart ProdCom to turn it on");
}
say('connecting', "ProdCom's API is off — turning it on and restarting ProdCom");
await os.quit(signal);
if (!live()) return;
await os.launch();
if (!live()) return;
restarted = true;
}
}
await pause(ATTEMPT_WAIT_MS, signal);
if (!live()) return;
const up = await Promise.resolve().then(probe).catch(() => false);
if (!live()) return;
if (up) {
log(`ProdCom is up after ${n} attempt(s)`);
say('connecting', 'ProdCom is back — reconnecting');
try {
onUp();
} catch (err) {
log(`ProdCom auto-start: reconnect nudge failed (${err && err.message ? err.message : err})`);
}
return;
}
}
giveUp(apiOnSilent
? `ProdCom is running with its API on, but port ${target().port} does not answer — check its API port`
: 'ProdCom would not start — open it by hand');
} catch (err) {
if (!live()) return;
giveUp(err && err.message ? err.message : String(err));
}
}
/** Decide whether a run is called for, and start it: the reason it did not, or null. */
function start(code) {
if (stopped) return 'stopped';
if (!settings().autoStart) return skip('ProdCom auto-start is off');
if (os.platform !== 'darwin') return skip('ProdCom auto-start is macOS only');
const { host } = target();
if (!os.isLocalHost(host)) return skip(`ProdCom at ${host} is not on this machine`);
if (busy) return skip('ProdCom auto-start is already working on it');
if (gaveUp) return skip('ProdCom auto-start has given up until ProdCom answers');
busy = true;
said.clear(); // a new outage says its reasons again
const myEpoch = epoch;
const controller = new AbortController();
abort = controller;
log(`ProdCom did not answer (${code}) — checking whether to start it`);
current = run(myEpoch, controller.signal)
.catch((err) => log(`ProdCom auto-start failed: ${err && err.message ? err.message : err}`))
.then(() => {
busy = false;
if (abort === controller) abort = null;
});
return null;
}
return {
/**
* A connection to ProdCom failed (`code` is the error code). Starts a run
* unless there is nothing to do; answers the reason when it does nothing,
* null when it started one.
*/
kick(code) {
try {
return start(code);
} catch (err) {
// Called from the module's reconnect path: whatever goes wrong here must not stop it reconnecting.
return skip(`ProdCom auto-start could not decide (${err && err.message ? err.message : err})`);
}
},
/** A connection to ProdCom succeeded: this outage is over, the next gets a fresh run. */
noteReachable() {
if (busy) log('ProdCom answered on its own — ending the start-up run');
gaveUp = false;
msg = null;
said.clear();
cancelRun();
},
/** The supervisor's words for the tile while it is working or has given up, else null. */
message() {
return busy || gaveUp ? msg : null;
},
/** Resolves when no run is in progress. */
idle() {
return current;
},
stop() {
stopped = true;
msg = null;
cancelRun();
},
};
}
module.exports = { createSupervisor, defaultOs, isLocalHostName, parseApiPref, abortableSleep };