Never build the capture tool on the event loop

The missing-binary fallback was a spawnSync, copied from the LTC listener
where the source is a third the size and builds in a few seconds. On the
production Mac this file took nearly two minutes, and spawnSync held the
Node event loop for every second of it: one module's optional helper froze
the entire dashboard, every other module with it, and then did it again
thirty seconds later on retry.

The build is now a plain spawn that reports "building" and calls start()
itself when it lands, so nothing waits on it. A failed build says so once
instead of spinning, and a build that never finishes is given up on after
five minutes.

The real lesson is the one in the comment: the binary should already be
there. It is built beside the app, or copied from any Mac of the same
architecture. Building on the machine that might be running a service is
the fallback, not the plan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-17 15:56:27 -04:00
co-authored by Claude Opus 5
parent 188d1c878d
commit e90ae51495
+59 -13
View File
@@ -14,9 +14,10 @@
* { error: '<what is wrong and how to fix it>' } not measuring
*
* The capture binary is per-machine and gitignored, exactly like the Timers
* module's ltc-capture: if it is missing this tries one swiftc build of the
* source next to it, and otherwise says how to build it elsewhere and copy
* it over.
* module's ltc-capture. It normally arrives already built — compiled beside
* the app, or copied from any Mac of the same architecture. When it is
* missing this will build it, but in the background and never on the event
* loop: see ensureBinary for what that cost the first time it was wrong.
*
* The same macOS gotcha applies as for LTC, and is worth repeating because
* it costs an afternoon every time: a ProdDash launched over SSH or from a
@@ -27,7 +28,7 @@
const path = require('path');
const fs = require('fs');
const { spawn, spawnSync } = require('child_process');
const { spawn } = require('child_process');
const RESTART_MS = 5000;
/** An immediate exit is config-shaped (bad device, channel out of range) —
@@ -52,21 +53,64 @@ function createSplListener(opts) {
let stallTimer = null;
let startedAt = 0;
let meta = null;
let building = false;
let buildFailed = false;
let lastFrameWall = 0;
const BUILD_HELP = 'Sound level capture tool missing — build it on any Apple Silicon Mac with '
+ '`swiftc -O -o modules/spl-meter/spl-capture modules/spl-meter/spl-capture.swift` '
+ 'and copy it next to spl-capture.swift';
/**
* Build the capture tool if it is missing, WITHOUT blocking.
*
* This used to be a spawnSync, copied from the LTC listener where the
* source is a third the size and builds in a few seconds. Here it took
* nearly two minutes on the production Mac and froze the entire ProdDash
* server for every second of it — one module's optional helper took the
* whole dashboard down, then did it again on every retry. Nothing that
* runs while a service might be on is allowed to hold the event loop.
*
* The build is a fallback for a module installed from the catalog on a
* machine with a Swift toolchain. The normal path is a binary built
* alongside the app, or copied from any same-architecture Mac.
*
* @returns {string} '' when the binary is ready, otherwise what to say.
*/
function ensureBinary() {
if (fs.existsSync(bin)) return '';
const src = bin + '.swift';
if (!fs.existsSync(src)) return 'spl-capture.swift is missing from the module folder';
log('spl-capture binary missing — trying a one-time swiftc build');
const built = spawnSync('swiftc', ['-O', '-o', bin, src], { timeout: 180000 });
if (built.status === 0 && fs.existsSync(bin)) {
log('spl-capture compiled');
return '';
if (building) return 'Building the capture tool — this takes a minute or two';
if (buildFailed) return BUILD_HELP;
building = true;
log('spl-capture binary missing — building it in the background');
const build = spawn('swiftc', ['-O', '-o', bin, src], { stdio: ['ignore', 'ignore', 'pipe'] });
let buildErr = '';
build.stderr.setEncoding('utf8');
build.stderr.on('data', (t) => { buildErr = (buildErr + t).slice(-800); });
const giveUp = setTimeout(() => {
try { build.kill('SIGKILL'); } catch { /* already gone */ }
}, 300000);
build.on('error', (err) => {
clearTimeout(giveUp);
building = false; buildFailed = true;
log(`swiftc could not be run: ${err.message}`);
});
build.on('exit', (code) => {
clearTimeout(giveUp);
building = false;
if (code === 0 && fs.existsSync(bin)) {
log('spl-capture built');
buildFailed = false;
if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); }
return;
}
return 'Sound level capture tool missing — build it on any Apple Silicon Mac with '
+ '`swiftc -O -o modules/spl-meter/spl-capture modules/spl-meter/spl-capture.swift` '
+ 'and copy it here';
buildFailed = true;
log(`spl-capture build failed (exit ${code})` + (buildErr ? `: ${buildErr.trim().split('\n').pop()}` : ''));
});
return 'Building the capture tool — this takes a minute or two';
}
function scheduleRestart(ms) {
@@ -91,7 +135,9 @@ function createSplListener(opts) {
const missing = ensureBinary();
if (missing) {
onUpdate({ error: missing });
scheduleRestart(BAD_CONFIG_RETRY_MS);
// A build in flight calls start() itself when it lands; anything else
// is config-shaped and should back off hard rather than spin.
if (!building) scheduleRestart(BAD_CONFIG_RETRY_MS);
return;
}