Compare commits

..
Author SHA1 Message Date
Waters ChurchandClaude Fable 5 34a6def839 Shell 1.12.1: a catalog install keeps the tarball's file modes
parseTar threw away each entry's mode, so installModule wrote every
downloaded file 0644 — including the prebuilt helpers modules ship under
bin/. spawn() then failed with EACCES: on the 1.10.2 rollout (AB,
2026-10-04) that silently killed both LTC capture (Timers 1.7.0) and SPL
metering (0.5.0) until the binaries were chmod +x'd by hand.
carryOverExecutables (1.9.2) never applied: it only carries files the
download does NOT include, and these downloads include the helpers.

parseTar now keeps the header's mode field (permission bits only,
defaulting to 0644) and both writers pass it on: installModule's staging
and the archive-based shell update. The unbundled-shell test's mock repo
now ships an executable bin/ helper and asserts it still spawns-worthy
after a catalog install, alongside the existing carry-over check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-07 11:45:07 -04:00
5 changed files with 42 additions and 21 deletions
@@ -162,14 +162,7 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
}); });
child.on('error', (err) => { child.on('error', (err) => {
// A spawn that fails (EACCES on a helper that lost its execute bit,
// ENOENT) emits 'error' with no 'exit' to follow. Leaving `child` set
// would let the eval loop report "No signal" over a dead child forever
// and nothing would ever retry — what parked LTC on AB on 2026-10-04.
// Null it and retry on the slow timer, the way spl-listener does.
report({ error: TOOL_MISSING, detail: `LTC capture failed to start: ${err.message}` }); report({ error: TOOL_MISSING, detail: `LTC capture failed to start: ${err.message}` });
child = null;
scheduleRestart(BAD_CONFIG_RETRY_MS);
}); });
child.on('exit', (code) => { child.on('exit', (code) => {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "propresenter-timers", "id": "propresenter-timers",
"name": "Timers", "name": "Timers",
"version": "1.7.1", "version": "1.7.0",
"proddash": ">=1.4.0", "proddash": ">=1.4.0",
"description": "Timers, Clocks, and LTC From configured Modules", "description": "Timers, Clocks, and LTC From configured Modules",
"provides": [ "provides": [
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "proddash", "name": "proddash",
"version": "1.12.0", "version": "1.12.1",
"private": true, "private": true,
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client", "description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
"license": "UNLICENSED", "license": "UNLICENSED",
+18 -11
View File
@@ -1121,6 +1121,11 @@ function parseTar(buf) {
while (off + 512 <= buf.length) { while (off + 512 <= buf.length) {
const h = buf.subarray(off, off + 512); const h = buf.subarray(off, off + 512);
if (h.every((b) => b === 0)) break; // end-of-archive blocks if (h.every((b) => b === 0)) break; // end-of-archive blocks
// The mode matters: modules ship prebuilt helpers under bin/, and writing
// one out without its execute bits leaves spawn() with EACCES and the
// feature silently dead. Permission bits only — setuid/sticky from an
// archive are not wanted here.
const mode = (parseInt(cstr(h, 100, 8).trim() || '0', 8) & 0o777) || 0o644;
const size = parseInt(cstr(h, 124, 12).trim() || '0', 8) || 0; const size = parseInt(cstr(h, 124, 12).trim() || '0', 8) || 0;
const type = h[156] === 0 ? '0' : String.fromCharCode(h[156]); const type = h[156] === 0 ? '0' : String.fromCharCode(h[156]);
const prefix = cstr(h, 257, 6).startsWith('ustar') ? cstr(h, 345, 155) : ''; const prefix = cstr(h, 257, 6).startsWith('ustar') ? cstr(h, 345, 155) : '';
@@ -1136,7 +1141,7 @@ function parseTar(buf) {
} }
const name = pendingName || shortName; const name = pendingName || shortName;
pendingName = null; pendingName = null;
if (type === '0') files.set(name, Buffer.from(data)); if (type === '0') files.set(name, { data: Buffer.from(data), mode });
} }
return { files, comment }; return { files, comment };
} }
@@ -1165,9 +1170,9 @@ async function fetchArchive() {
} }
const { files, comment } = parseTar(zlib.gunzipSync(gz)); const { files, comment } = parseTar(zlib.gunzipSync(gz));
const stripped = new Map(); // drop the archive's single top-level folder const stripped = new Map(); // drop the archive's single top-level folder
for (const [p, data] of files) { for (const [p, entry] of files) {
const i = p.indexOf('/'); const i = p.indexOf('/');
if (i > 0 && i < p.length - 1) stripped.set(p.slice(i + 1), data); if (i > 0 && i < p.length - 1) stripped.set(p.slice(i + 1), entry);
} }
return { files: stripped, sha: /^[0-9a-f]{7,40}$/.test(comment) ? comment : '' }; return { files: stripped, sha: /^[0-9a-f]{7,40}$/.test(comment) ? comment : '' };
} }
@@ -1182,17 +1187,17 @@ async function refreshRemote(force = false) {
const job = (async () => { const job = (async () => {
try { try {
const { files, sha } = await fetchArchive(); const { files, sha } = await fetchArchive();
const pkg = safeJson(files.get('package.json')); const pkg = safeJson(files.get('package.json')?.data);
const modules = new Map(); const modules = new Map();
for (const [p, data] of files) { for (const [p, file] of files) {
const m = p.match(/^modules\/([^/]+)\/(.+)$/); const m = p.match(/^modules\/([^/]+)\/(.+)$/);
if (!m) continue; if (!m) continue;
const entry = modules.get(m[1]) || { id: m[1], manifest: null, files: new Map() }; const entry = modules.get(m[1]) || { id: m[1], manifest: null, files: new Map() };
entry.files.set(m[2], data); entry.files.set(m[2], file);
modules.set(m[1], entry); modules.set(m[1], entry);
} }
for (const [id, entry] of modules) { for (const [id, entry] of modules) {
const man = safeJson(entry.files.get('module.json')); const man = safeJson(entry.files.get('module.json')?.data);
if (!man || typeof man !== 'object' || (man.id && man.id !== id) || !man.client) { if (!man || typeof man !== 'object' || (man.id && man.id !== id) || !man.client) {
modules.delete(id); modules.delete(id);
continue; continue;
@@ -1322,12 +1327,14 @@ async function installModule(id) {
const staging = path.join(INSTALLED_MODULES_DIR, `.${id}.installing`); const staging = path.join(INSTALLED_MODULES_DIR, `.${id}.installing`);
fs.rmSync(staging, { recursive: true, force: true }); fs.rmSync(staging, { recursive: true, force: true });
const shipped = new Set(); const shipped = new Set();
for (const [rel, data] of remoteEntry.files) { for (const [rel, { data, mode }] of remoteEntry.files) {
if (!safeRelPath(rel)) continue; if (!safeRelPath(rel)) continue;
shipped.add(rel); shipped.add(rel);
const target = path.join(staging, rel); const target = path.join(staging, rel);
fs.mkdirSync(path.dirname(target), { recursive: true }); fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, data); // The tarball's mode, or a shipped bin/ helper lands 0644 and spawns
// EACCES (what broke LTC and SPL on the 1.10.2 rollout, 2026-10-04).
fs.writeFileSync(target, data, { mode });
} }
const dest = path.join(INSTALLED_MODULES_DIR, id); const dest = path.join(INSTALLED_MODULES_DIR, id);
// Per-machine executables — a capture tool compiled here, or copied in // Per-machine executables — a capture tool compiled here, or copied in
@@ -1439,11 +1446,11 @@ async function updateShell() {
} else { } else {
const staging = path.join(ROOT, '.proddash-update'); const staging = path.join(ROOT, '.proddash-update');
fs.rmSync(staging, { recursive: true, force: true }); fs.rmSync(staging, { recursive: true, force: true });
for (const [rel, data] of remote.files) { for (const [rel, { data, mode }] of remote.files) {
if (!safeRelPath(rel) || isKeptOnUpdate(rel)) continue; if (!safeRelPath(rel) || isKeptOnUpdate(rel)) continue;
const target = path.join(staging, rel); const target = path.join(staging, rel);
fs.mkdirSync(path.dirname(target), { recursive: true }); fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, data); fs.writeFileSync(target, data, { mode });
} }
const newServer = path.join(staging, 'server.js'); const newServer = path.join(staging, 'server.js');
if (!fs.existsSync(newServer)) { if (!fs.existsSync(newServer)) {
+22 -1
View File
@@ -12,7 +12,12 @@
* 2. a shell update from an archive that carries modules/ never writes * 2. a shell update from an archive that carries modules/ never writes
* them into the app folder — they show up in the catalog instead; * them into the app folder — they show up in the catalog instead;
* 3. a fresh install lists everything the repo offers, and installing * 3. a fresh install lists everything the repo offers, and installing
* several at once lands them in the data directory and mounts them. * several at once lands them in the data directory and mounts them;
* 4. a module downloaded from the catalog keeps the tarball's modes — a
* shipped bin/ helper is still executable (0644 means spawn EACCES,
* which is what broke LTC and SPL on the 1.10.2 rollout) — while
* per-machine executables the download does not include are still
* carried over from the copy being replaced.
* *
* node tools/unbundled.test.js * node tools/unbundled.test.js
* *
@@ -70,6 +75,11 @@ function buildArchive(dir) {
fs.copyFileSync(path.join(REPO, 'server.js'), path.join(top, 'server.js')); fs.copyFileSync(path.join(REPO, 'server.js'), path.join(top, 'server.js'));
fs.writeFileSync(path.join(top, 'package.json'), JSON.stringify({ name: 'proddash', version: '9.9.9' })); fs.writeFileSync(path.join(top, 'package.json'), JSON.stringify({ name: 'proddash', version: '9.9.9' }));
writeModule(path.join(top, 'modules', 'demo'), 'demo', '2.0.0'); writeModule(path.join(top, 'modules', 'demo'), 'demo', '2.0.0');
// The repo ships this one prebuilt (the way Timers and SPL ship their
// capture helpers): it must come out of the download still executable.
fs.mkdirSync(path.join(top, 'modules', 'demo', 'bin'));
fs.writeFileSync(path.join(top, 'modules', 'demo', 'bin', 'demo-capture'), '#!/bin/sh\necho capture\n');
fs.chmodSync(path.join(top, 'modules', 'demo', 'bin', 'demo-capture'), 0o755);
writeModule(path.join(top, 'modules', 'other'), 'other', '1.0.0'); writeModule(path.join(top, 'modules', 'other'), 'other', '1.0.0');
writeModule(path.join(top, 'modules', 'third'), 'third', '1.0.0'); writeModule(path.join(top, 'modules', 'third'), 'third', '1.0.0');
const out = path.join(dir, 'main.tar.gz'); const out = path.join(dir, 'main.tar.gz');
@@ -188,6 +198,17 @@ function buildPackagedApp(app) {
const empty = await api('/api/admin/modules/install', { method: 'POST', body: '{}' }); const empty = await api('/api/admin/modules/install', { method: 'POST', body: '{}' });
assert.equal(empty.status, 400, 'nothing to install is said'); assert.equal(empty.status, 400, 'nothing to install is said');
// ── 4. a download keeps executable modes, and carry-over still works ──
const upd = await api('/api/admin/modules/demo/install', { method: 'POST', body: '{}' });
assert.equal(upd.status, 200, 'demo update: ' + JSON.stringify(upd.body));
assert.equal(upd.body.version, '2.0.0', 'demo was updated from the repo');
const shipped = path.join(dataDir, 'modules', 'demo', 'bin', 'demo-capture');
assert.ok(fs.existsSync(shipped), 'the downloaded helper is there');
assert.ok(fs.statSync(shipped).mode & 0o111, 'and the tarball\'s execute bit survived the install');
const carried = path.join(dataDir, 'modules', 'demo', 'bin', 'demo-helper');
assert.ok(fs.existsSync(carried), 'the per-machine executable the download lacks was carried over');
assert.ok(fs.statSync(carried).mode & 0o111, 'still executable');
console.log('unbundled shell: every check passed'); console.log('unbundled shell: every check passed');
} catch (err) { } catch (err) {
failed = true; failed = true;