Don't take the server down when the module unmounts
Deploying 0.3.0 to AB crashed the ProdDash server on restart:
TypeError: Cannot read properties of null (reading 'streams')
at server.js:273 req.on('close', () => state.streams.delete(res))
teardown() nulls `state` and then ends every open stream, which fires each
request's close handler — and the handler reached back through the `state`
it had just discarded. Any re-init with a browser connected would do it, so
saving admin config while a dashboard was open was enough to stop the whole
server, every module with it.
The stream handler now closes over this mount's Set rather than the module's
state: after teardown it deletes from a set nobody reads, which is harmless.
The same hazard existed anywhere a callback could outlive the mount — the
keepalive ping, a frame arriving from the listener mid-teardown, and the
/measure interval — and each is closed over or guarded now.
Reproduced first (open an SSE client, save config, watch it fall over) and
confirmed fixed against the same sequence.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
46d44d494b
commit
87929bfd0b
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "spl-meter",
|
"id": "spl-meter",
|
||||||
"name": "SPL Meter",
|
"name": "SPL Meter",
|
||||||
"version": "0.3.0",
|
"version": "0.3.1",
|
||||||
"proddash": ">=1.5.0",
|
"proddash": ">=1.5.0",
|
||||||
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
|
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ module.exports = {
|
|||||||
moduleDir,
|
moduleDir,
|
||||||
log,
|
log,
|
||||||
onUpdate(update) {
|
onUpdate(update) {
|
||||||
|
if (!state) return; // a frame in flight when the module unmounted
|
||||||
if (update.error) {
|
if (update.error) {
|
||||||
if (update.error !== state.error) log(update.error);
|
if (update.error !== state.error) log(update.error);
|
||||||
state.error = update.error;
|
state.error = update.error;
|
||||||
@@ -146,9 +147,10 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const mineStreams = state.streams;
|
||||||
state.pinger = setInterval(() => {
|
state.pinger = setInterval(() => {
|
||||||
for (const res of state.streams) {
|
for (const res of mineStreams) {
|
||||||
try { res.write(': ping\n\n'); } catch { state.streams.delete(res); }
|
try { res.write(': ping\n\n'); } catch { mineStreams.delete(res); }
|
||||||
}
|
}
|
||||||
}, PING_MS);
|
}, PING_MS);
|
||||||
|
|
||||||
@@ -260,8 +262,14 @@ module.exports = {
|
|||||||
try {
|
try {
|
||||||
res.write(`event: state\ndata: ${JSON.stringify(snapshot())}\n\n`);
|
res.write(`event: state\ndata: ${JSON.stringify(snapshot())}\n\n`);
|
||||||
} catch { return; }
|
} catch { return; }
|
||||||
state.streams.add(res);
|
// Hold this mount's set, not `state`. teardown() nulls `state` and
|
||||||
req.on('close', () => state.streams.delete(res));
|
// then ends every stream, which fires these close handlers — a
|
||||||
|
// handler that reached through `state` threw on null and took the
|
||||||
|
// whole server down with it, on any re-init with a browser
|
||||||
|
// connected. Deleting from a set nobody reads any more is harmless.
|
||||||
|
const streams = state.streams;
|
||||||
|
streams.add(res);
|
||||||
|
req.on('close', () => streams.delete(res));
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Device list for the admin page. Runs the capture tool's --list,
|
/** Device list for the admin page. Runs the capture tool's --list,
|
||||||
@@ -311,6 +319,7 @@ module.exports = {
|
|||||||
const samples = [];
|
const samples = [];
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
const tick = setInterval(() => {
|
const tick = setInterval(() => {
|
||||||
|
if (!state) { clearInterval(tick); return; } // module unmounted mid-measure
|
||||||
const ch = state.frame && state.frame.ch.find((c) => c.i === wanted);
|
const ch = state.frame && state.frame.ch.find((c) => c.i === wanted);
|
||||||
if (ch && Number.isFinite(ch.dbfs) && ch.dbfs > -200) samples.push(ch.dbfs);
|
if (ch && Number.isFinite(ch.dbfs) && ch.dbfs > -200) samples.push(ch.dbfs);
|
||||||
if (Date.now() - started < MEASURE_MS) return;
|
if (Date.now() - started < MEASURE_MS) return;
|
||||||
@@ -343,6 +352,7 @@ module.exports = {
|
|||||||
/* ── helpers ────────────────────────────────────────────────────────── */
|
/* ── helpers ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
function snapshot() {
|
function snapshot() {
|
||||||
|
if (!state) return { error: 'not measuring', calibrated: false, channels: [], frame: null };
|
||||||
return {
|
return {
|
||||||
error: state.error,
|
error: state.error,
|
||||||
calibrated: state.calibrated,
|
calibrated: state.calibrated,
|
||||||
@@ -356,6 +366,7 @@ function snapshot() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function broadcast() {
|
function broadcast() {
|
||||||
|
if (!state) return; // a listener callback landing after teardown
|
||||||
const payload = snapshot();
|
const payload = snapshot();
|
||||||
const line = JSON.stringify(payload);
|
const line = JSON.stringify(payload);
|
||||||
if (line === state.lastSent) return; // nothing moved since last frame
|
if (line === state.lastSent) return; // nothing moved since last frame
|
||||||
|
|||||||
Reference in New Issue
Block a user