Skip to content

Commit ae69abd

Browse files
fix(eta): explicit state.scanning flag so 'Scanning…' shows on RESUME too
The first 'Scanning…' fix keyed the label on totalNodes<=0, which only holds for a FRESH run. On RESUME, recomputeCounters() (pipeline.js:658) never sets totalNodes — it's restored from the prior run's snapshot as a positive value — so the scan was never detected and the ETA stayed stuck on 'Calculating…' for the whole Phase-2 scan (the exact symptom reported). Add an explicit state.scanning flag set true across the online scan in runAudit (throw-safe via try/finally; initialised false at run start), whitelist it into PUBLIC_STATE_KEYS so /live receives it, and have both dashboards' ETA branch render 'Scanning…' when scanning is true OR totalNodes<=0 (the latter still covers the fresh-run pre-scan window). Admin gets it via the blacklist sanitizeAdminState; live via the whitelist. Tests: public-sse-fields key count 11→12; new scanning-eta-label.test.js guards the flag wiring end-to-end (pipeline → server whitelist → both dashboards). Full suite green.
1 parent 2c81b69 commit ae69abd

7 files changed

Lines changed: 107 additions & 15 deletions

File tree

admin.html

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1515,11 +1515,13 @@ <h1 style="margin:0;flex:0 0 auto">SENTINEL NODE TEST</h1>
15151515
const rem = Math.max(0, _etaAnchor.remainingMs - (Date.now() - _etaAnchor.at));
15161516
const h = Math.floor(rem / 3600000), m = Math.floor((rem % 3600000) / 60000), s = Math.floor((rem % 60000) / 1000);
15171517
el.textContent = `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
1518-
} else if (!Number.isFinite(state.totalNodes) || state.totalNodes <= 0) {
1519-
// No testable population established yet → we're still in the Phase-2
1520-
// online scan (totalNodes is only set post-scan, pipeline.js ~line 802).
1521-
// ETA is genuinely incomputable here (no completions to measure), so say
1522-
// "Scanning…" rather than "Calculating…" so it doesn't read as frozen.
1518+
} else if (state.scanning || !Number.isFinite(state.totalNodes) || state.totalNodes <= 0) {
1519+
// Still in the Phase-2 online scan → ETA is genuinely incomputable (no
1520+
// node-test completions to measure yet). state.scanning is the reliable
1521+
// signal: on a RESUME, totalNodes is a restored positive value, so the
1522+
// totalNodes<=0 check alone misses it (that check still catches the
1523+
// fresh-run pre-scan window where totalNodes is 0). Show "Scanning…"
1524+
// rather than a frozen-looking "Calculating…".
15231525
el.textContent = 'Scanning…';
15241526
} else {
15251527
// Population known but <2 fresh completions in the ETA window → a real

audit/pipeline.js

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -572,6 +572,7 @@ export async function runAudit(resume, state, broadcast, preloadedNodes = null,
572572
resetPipelineStop();
573573
refreshAuditSettings();
574574
state.status = 'running';
575+
state.scanning = false; // set true only across the Phase-2 online scan (below)
575576
// Preserve the original startedAt across Stop/Resume so the dashboard's
576577
// "started at" / ETA / elapsed-time mirror the original run exactly.
577578
// Only stamp a fresh time on a brand-new run.
@@ -759,11 +760,25 @@ export async function runAudit(resume, state, broadcast, preloadedNodes = null,
759760
}
760761
const nodesToTest = MAX_NODES > 0 ? allNodes.slice(0, MAX_NODES) : allNodes;
761762
broadcast('log', { msg: `Fetched ${nodesToTest.length} nodes total.` });
763+
// Phase-2 scan flag: the ETA is genuinely incomputable during the parallel
764+
// online scan (no node-test completions to measure yet), and on RESUME
765+
// totalNodes is a restored positive value — so the client can't infer "we're
766+
// scanning" from totalNodes<=0 alone. This explicit flag rides the pre-scan
767+
// frame below; the dashboards show "Scanning…" while it's true instead of a
768+
// frozen-looking "Calculating…". Cleared the instant the scan returns.
769+
state.scanning = true;
762770
broadcast('state', { state });
763771

764772
// ── Phase 2: Parallel online scan ──
765773
broadcast('log', { msg: `🔍 Phase 2: Scanning ${nodesToTest.length} nodes in parallel (30 concurrent)...` });
766-
const onlineNodes = await scanNodesParallel(nodesToTest, 30, broadcast, state);
774+
// try/finally so a scan throw can't leave state.scanning stuck true (which
775+
// would freeze the dashboards on "Scanning…" for the rest of the process).
776+
let onlineNodes;
777+
try {
778+
onlineNodes = await scanNodesParallel(nodesToTest, 30, broadcast, state);
779+
} finally {
780+
state.scanning = false;
781+
}
767782
broadcast('log', { msg: `Scan complete: ${onlineNodes.length}/${nodesToTest.length} online.` });
768783

769784
const _isHours = state.pricingMode === 'hours';

live.html

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1740,11 +1740,13 @@ <h1 class="live-page-title">
17401740
const s = Math.floor((rem % 60000) / 1000);
17411741
etaEl.textContent = `ETA ${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
17421742
} else if (_liveState && _liveState.status === 'running'
1743-
&& (!Number.isFinite(Number(_liveState.totalNodes)) || Number(_liveState.totalNodes) <= 0)) {
1744-
// Running but no testable population yet → still in the Phase-2 online
1745-
// scan (totalNodes is only set post-scan, pipeline.js ~line 802). ETA is
1746-
// genuinely incomputable (no completions to measure), so show "Scanning…"
1747-
// rather than a frozen-looking "ETA —". Parity with admin.html updateETA.
1743+
&& (_liveState.scanning || !Number.isFinite(Number(_liveState.totalNodes)) || Number(_liveState.totalNodes) <= 0)) {
1744+
// Still in the Phase-2 online scan → ETA genuinely incomputable (no
1745+
// completions to measure yet). _liveState.scanning is the reliable
1746+
// signal: on a RESUME totalNodes is a restored positive value, so the
1747+
// totalNodes<=0 check alone misses it (it still catches the fresh-run
1748+
// pre-scan window where totalNodes is 0). Show "Scanning…" rather than a
1749+
// frozen-looking "ETA —". Parity with admin.html updateETA.
17481750
etaEl.textContent = 'ETA Scanning…';
17491751
} else {
17501752
etaEl.textContent = 'ETA —';

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
"postinstall": "node scripts/postinstall.js",
3939
"start": "node server.js",
4040
"audit": "node server.js",
41-
"test": "node test/smoke.test.js && node test/db.smoke.test.js && node test/continuous.smoke.test.js && node test/security.test.js && node test/failure-log-ux.test.js && node test/public-no-action-buttons.test.js && node test/onchain-decode.test.js && node test/admin-log-batch.test.js && node test/live-pause-overlay.test.js && node test/live-load-gating.test.js && node test/public-run-active.test.js && node test/live-single-poll-gate.test.js && node test/public-sse-fields.test.js && node test/admin-sse-diag-trim.test.js && node test/eta-estimate.test.js && node test/error-code-classification.test.js",
41+
"test": "node test/smoke.test.js && node test/db.smoke.test.js && node test/continuous.smoke.test.js && node test/security.test.js && node test/failure-log-ux.test.js && node test/public-no-action-buttons.test.js && node test/onchain-decode.test.js && node test/admin-log-batch.test.js && node test/live-pause-overlay.test.js && node test/live-load-gating.test.js && node test/public-run-active.test.js && node test/live-single-poll-gate.test.js && node test/public-sse-fields.test.js && node test/admin-sse-diag-trim.test.js && node test/eta-estimate.test.js && node test/error-code-classification.test.js && node test/scanning-eta-label.test.js",
4242
"test:ui": "node test/ui.smoke.test.js",
4343
"test:integration": "node test/continuous.audit-integration.test.js && node test/continuous.live-db-write.test.js && node test/node-detail.smoke.test.js && node test/resume-results-seed.test.js",
4444
"verify:prod-db": "node scripts/verify-prod-db.mjs",

server.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2018,6 +2018,7 @@ function _redactPublicError(v, max = 200) {
20182018
// matches admin.html without being distorted by client clock skew.
20192019
const PUBLIC_STATE_KEYS = [
20202020
'status',
2021+
'scanning',
20212022
'totalNodes',
20222023
'etaRemainingMs',
20232024
'baselineMbps',

test/public-sse-fields.test.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
* - sanitizePublicResult keeps every consumed field and drops the 6 removed
1313
* ones (advertisedMbps, dynamicThreshold, pass10mbps, latencyMs,
1414
* handshakeMs, sessionMs).
15-
* - PUBLIC_STATE_KEYS is exactly the 11 kept keys, none of the 14 removed.
15+
* - PUBLIC_STATE_KEYS is exactly the 12 kept keys, none of the 14 removed.
1616
* - sanitizeForPublic drops top-level mode + durationMs, keeps the rest.
1717
* Each removed field is asserted absent as a regression guard so a future
1818
* re-add fails the suite.
@@ -111,7 +111,7 @@ const REMOVED_RESULT_FIELDS = [
111111
'latencyMs', 'handshakeMs', 'sessionMs',
112112
];
113113
const KEPT_STATE_KEYS = [
114-
'status', 'totalNodes', 'etaRemainingMs', 'baselineMbps', 'baselineHistory',
114+
'status', 'scanning', 'totalNodes', 'etaRemainingMs', 'baselineMbps', 'baselineHistory',
115115
'testRun', 'runMode', 'runPlanId', 'pricingMode', 'activeSDK',
116116
'activeRunNumber',
117117
];
@@ -171,7 +171,7 @@ console.log('[1b] serviceType uses r.type ?? r.serviceType (fallback + nullish)'
171171
}
172172

173173
// ─── 2. PUBLIC_STATE_KEYS ─────────────────────────────────────────────────────
174-
console.log('[2] PUBLIC_STATE_KEYS is exactly the 11 consumed keys');
174+
console.log('[2] PUBLIC_STATE_KEYS is exactly the 12 consumed keys');
175175
{
176176
const keys = vm.runInContext('PUBLIC_STATE_KEYS', sandbox);
177177
ok(Array.isArray(keys), 'PUBLIC_STATE_KEYS is an array');

test/scanning-eta-label.test.js

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* Sentinel Node Tester — "Scanning…" ETA label regression tests
3+
*
4+
* Root cause this guards (2026-06): during the Phase-2 parallel online scan the
5+
* ETA is genuinely incomputable (no node-test completions to measure yet). The
6+
* dashboards showed a frozen-looking "Calculating…" (admin) / "ETA —" (live) for
7+
* the whole scan. The first fix keyed the label on totalNodes<=0 — which works
8+
* for a FRESH run but NOT a RESUME, where recomputeCounters() leaves totalNodes
9+
* at the restored positive value, so the scan was never detected and the label
10+
* stayed "Calculating…". The durable fix is an explicit state.scanning flag set
11+
* across the scan in pipeline.js, whitelisted into PUBLIC_STATE_KEYS, and read by
12+
* both dashboards' ETA branch.
13+
*
14+
* These are static source assertions (no native imports, no server, no DB).
15+
*
16+
* Run: node test/scanning-eta-label.test.js
17+
*/
18+
19+
import { readFileSync } from 'fs';
20+
import { fileURLToPath } from 'url';
21+
import path from 'path';
22+
23+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
24+
const ROOT = path.join(__dirname, '..');
25+
const pipeline = readFileSync(path.join(ROOT, 'audit/pipeline.js'), 'utf8');
26+
const server = readFileSync(path.join(ROOT, 'server.js'), 'utf8');
27+
const admin = readFileSync(path.join(ROOT, 'admin.html'), 'utf8');
28+
const live = readFileSync(path.join(ROOT, 'live.html'), 'utf8');
29+
30+
const results = { pass: 0, fail: 0 };
31+
function ok(cond, name) {
32+
if (cond) { results.pass++; console.log(` PASS ${name}`); }
33+
else { results.fail++; console.error(` FAIL ${name}`); }
34+
}
35+
36+
console.log('"Scanning…" ETA label — regression tests\n');
37+
38+
// ─── 1. pipeline.js sets state.scanning across the Phase-2 scan ───────────────
39+
console.log('1. pipeline.js drives the scanning flag');
40+
ok(/state\.scanning\s*=\s*true\s*;/.test(pipeline),
41+
'pipeline sets state.scanning = true before the scan');
42+
// The clear must be in a finally so a scan throw can't leave it stuck true.
43+
ok(/finally\s*{\s*[\s\S]*?state\.scanning\s*=\s*false\s*;[\s\S]*?}/.test(pipeline),
44+
'pipeline clears state.scanning = false in a finally (throw-safe)');
45+
// Ordering: the `= true` assignment precedes the scanNodesParallel await it guards.
46+
{
47+
const trueIdx = pipeline.indexOf('state.scanning = true');
48+
const scanIdx = pipeline.indexOf('scanNodesParallel(nodesToTest');
49+
ok(trueIdx !== -1 && scanIdx !== -1 && trueIdx < scanIdx,
50+
'scanning=true is set before the runAudit scanNodesParallel call');
51+
}
52+
// Reset at run start so a prior errored run can't leak a stale true.
53+
ok(/state\.scanning\s*=\s*false;\s*\/\/[^\n]*Phase-2/.test(pipeline),
54+
'runAudit initialises state.scanning = false at the top');
55+
56+
// ─── 2. server.js ships scanning to the public/live surface ───────────────────
57+
console.log('\n2. server.js whitelists scanning for public SSE');
58+
ok(/PUBLIC_STATE_KEYS\s*=\s*\[[\s\S]*?'scanning'[\s\S]*?\]/.test(server),
59+
"PUBLIC_STATE_KEYS includes 'scanning' (else /live never sees it)");
60+
61+
// ─── 3. both dashboards key the "Scanning…" label on the flag ─────────────────
62+
console.log('\n3. dashboards read the flag in the ETA branch');
63+
ok(/state\.scanning\s*\|\|/.test(admin) && /Scanning/.test(admin),
64+
"admin.html ETA branch tests state.scanning and renders 'Scanning…'");
65+
ok(/_liveState\.scanning\s*\|\|/.test(live) && /Scanning/.test(live),
66+
"live.html ETA branch tests _liveState.scanning and renders 'Scanning…'");
67+
68+
// ─── Summary ─────────────────────────────────────────────────────────────────
69+
console.log(`\n============================================================`);
70+
console.log(`RESULTS: ${results.pass} passed, ${results.fail} failed (${results.pass + results.fail} total)`);
71+
console.log(`============================================================`);
72+
process.exit(results.fail === 0 ? 0 : 1);

0 commit comments

Comments
 (0)