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:
co-authored by
Claude Fable 5.1
parent
485482531d
commit
26e47c2fdd
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user