ProdCom 1.5.0: start ProdCom when it is offline, and turn its API on

ProdCom is a sandboxed Mac App Store app whose HTTP API lives inside it,
behind the UserDefaults switch applicationAPIEnabled — absent means off,
and a running ProdCom does not notice the switch changing; it reads it
at launch. So when the tile says "unreachable" on a booth where ProdCom
runs on the same Mac, there are two causes and two fixes: ProdCom is not
running (open it), or it is running with the API off (set the switch,
quit it, open it again).

The module's server now does both, behind two Admin switches, both on:
"Start ProdCom when it is offline" and "Restart ProdCom to turn its API
on". On a connection-level failure (never an HTTP answer) the supervisor
runs up to three attempts five seconds apart: not running → open -g -b;
running with the switch off → write it, read it back, ask ProdCom to
quit (Apple Event, SIGTERM after 5 s), open it again — at most once per
outage; running with the switch on → keep probing, and only blame the
port if that is still true on the third look, because a quitting ProdCom
still shows in pgrep. A successful run nudges the module to reconnect at
once. It gives up with a tile message until ProdCom answers again, and
does nothing at all for a ProdCom on another machine or off macOS.

The launcher gains the Apple Events entitlement and usage string so the
polite quit can be granted; if it is denied, SIGTERM does the job.

Verified live on the dev Mac against ProdCom 2.3.4: not running + switch
absent → opened, switch written, restarted, Live after two attempts; an
operator's Cmd-Q → reopened. 71 fake-driven tests (node --test), the OS
layer included through an injectable execFile.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-10-27 15:50:17 -04:00
co-authored by Claude Fable 5.1
parent 485482531d
commit 26e47c2fdd
7 changed files with 1355 additions and 9 deletions
+58 -7
View File
@@ -32,9 +32,15 @@
* extractEntries). POST /api/v1/channels/{id}/send with { text } puts typed
* text on a channel (it comes back on the stream with source "typed"). A
* bearer API key is sent when one is configured.
*
* When ProdCom runs on this very Mac and stops answering, supervisor.js opens
* it again — or turns its Application API on and restarts it, the API being
* read only at launch. Every failed connection is reported to it (fail()) and
* every good one (connect()); it does nothing for a ProdCom on another machine.
*/
const http = require('http');
const { createSupervisor } = require('./supervisor');
/** The module that owns the PIN and the unlock tokens — see verifyUnlock(). */
const SENDER_ID = 'send-message';
@@ -60,6 +66,19 @@ function resolveTarget(config) {
return null;
}
/** Codes that mean ProdCom could not be reached at all — not that it answered badly. */
const CONNECTION_CODES = new Set(['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EHOSTUNREACH', 'ENOTFOUND']);
/** The code of an error that is a failed (or timed-out) connection to ProdCom, else ''.
An HTTP status from ProdCom is not one: its API is up, so there is nothing to start. */
function connectionCode(err) {
const code = err && (err.cause?.code || err.code);
if (CONNECTION_CODES.has(code)) return code;
if (code === 'UND_ERR_SOCKET') return 'ECONNRESET'; // fetch's spelling of a connection dropped under it
if (err && (err.name === 'TimeoutError' || err.name === 'AbortError')) return 'ETIMEDOUT';
return '';
}
/** Entries inside whatever ProdCom sent: a bare entry, or one wrapped in data / payload / entry / entries / transcript. */
function extractEntries(obj) {
if (!obj || typeof obj !== 'object') return [];
@@ -181,6 +200,7 @@ module.exports = {
let upstream = null;
let generation = 0; // callbacks from a superseded connection are ignored
let retryTimer = null;
let retryPending = false; // waiting out the backoff, with no connect() under way
let retryMs = RETRY_MIN_MS;
const authHeaders = () => (apiKey ? { authorization: `Bearer ${apiKey}` } : {});
@@ -190,7 +210,11 @@ module.exports = {
headers: { accept: 'application/json', ...authHeaders() },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!res.ok) throw new Error(`HTTP ${res.status} for ${pathname}`);
if (!res.ok) {
const err = new Error(`HTTP ${res.status} for ${pathname}`);
err.httpStatus = res.status; // ProdCom answered, so it is up
throw err;
}
return res.json();
}
@@ -220,6 +244,22 @@ module.exports = {
broadcast('status', status);
}
// Keeps ProdCom itself up when it is on this machine (supervisor.js); a
// no-op everywhere else. Its words reach the tile through setStatus. This
// module is re-init'ed whenever its config is saved, so `config` is current.
const supervisor = createSupervisor({
settings: () => ({ autoStart: config.autoStart !== false, apiRestart: config.apiRestart !== false }),
target: () => ({ host: target ? target.hostname : '', port: target ? Number(target.port) || 80 : 0 }),
log,
status: setStatus,
// ProdCom answers again: connect now rather than when the backoff runs out — unless a connect is already under way.
onUp: () => {
if (stopped || !retryPending) return;
clearTimeout(retryTimer);
connect();
},
});
function trim() {
while (entries.size > ENTRIES_MAX) entries.delete(entries.keys().next().value);
}
@@ -281,22 +321,29 @@ module.exports = {
function scheduleRetry() {
if (stopped) return;
clearTimeout(retryTimer);
retryPending = true;
retryTimer = setTimeout(connect, retryMs);
retryMs = Math.min(RETRY_MAX_MS, retryMs * 2);
}
function fail(gen, why) {
/** `code` is set when the connection itself failed (see connectionCode), not when ProdCom answered badly. */
function fail(gen, why, code = '') {
if (stopped || gen !== generation) return;
generation += 1; // whatever else this connection reports is stale now
try { upstream?.destroy(); } catch { /* already gone */ }
upstream = null;
setStatus('error', `ProdCom unreachable (${why}) — retrying`);
log(`ProdCom ${target.origin}: ${why} — retrying in ${retryMs / 1000} s`);
if (code) supervisor.kick(code);
// While the supervisor is starting ProdCom — or has given up and says why —
// its words are the ones worth reading, not "unreachable" on every retry.
const shown = code || why; // the normalised code where there is one (a timeout reads ETIMEDOUT, not 23)
if (!(code && supervisor.message())) setStatus('error', `ProdCom unreachable (${shown}) — retrying`);
log(`ProdCom ${target.origin}: ${shown} — retrying in ${retryMs / 1000} s`);
scheduleRetry();
}
/** Lists and history first (a fresh snapshot for the tiles), then the live stream. */
async function connect() {
retryPending = false;
if (stopped || !target) return;
const gen = ++generation;
try {
@@ -304,7 +351,8 @@ module.exports = {
await loadHistory();
} catch (err) {
if (gen !== generation || stopped) return;
return fail(gen, err.cause?.code || err.code || err.message);
if (err.httpStatus) supervisor.noteReachable(); // 401, 5xx…: ProdCom is up, whatever it said
return fail(gen, err.cause?.code || err.code || err.message, connectionCode(err));
}
if (gen !== generation || stopped) return;
broadcast('state', snapshot());
@@ -319,9 +367,11 @@ module.exports = {
if (gen !== generation) return void res.destroy();
if (res.statusCode !== 200) {
res.resume();
supervisor.noteReachable(); // ProdCom answered
return fail(gen, `HTTP ${res.statusCode} from the transcript stream`);
}
retryMs = RETRY_MIN_MS;
supervisor.noteReachable();
setStatus('ok', 'Live');
log(`following ${target.origin} — ${channels.length} channels, ${entries.size} entries`);
let buf = '';
@@ -337,9 +387,9 @@ module.exports = {
if (buf.length > 1e6) buf = ''; // a frame that never ends is dropped
});
res.on('end', () => fail(gen, 'stream ended'));
res.on('error', (err) => fail(gen, err.code || err.message));
res.on('error', (err) => fail(gen, err.code || err.message, connectionCode(err)));
});
req.on('error', (err) => fail(gen, err.code || err.message));
req.on('error', (err) => fail(gen, err.code || err.message, connectionCode(err)));
req.end();
upstream = req;
}
@@ -449,6 +499,7 @@ module.exports = {
stop() {
stopped = true;
generation += 1;
supervisor.stop();
clearTimeout(retryTimer);
clearInterval(listsTimer);
clearInterval(heartbeat);