From e90ae514959db0ba86d7d200f538a00852074625 Mon Sep 17 00:00:00 2001 From: Waters Church Date: Thu, 17 Sep 2026 11:10:50 -0400 Subject: [PATCH] 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 --- modules/spl-meter/spl-listener.js | 74 +++++++++++++++++++++++++------ 1 file changed, 60 insertions(+), 14 deletions(-) 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; }