diff --git a/modules/spl-meter/spl-listener.js b/modules/spl-meter/spl-listener.js index 8893848..cffe363 100644 --- a/modules/spl-meter/spl-listener.js +++ b/modules/spl-meter/spl-listener.js @@ -14,9 +14,10 @@ * { error: '' } 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 ''; - } - 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'; + 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; + } + 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; }