Skip to content

Commit 4b9ab40

Browse files
fix(eta): retest-aware + skew-proof remaining-duration wire + loop-staleness refresh + explicit ring reset
1 parent 79888b0 commit 4b9ab40

6 files changed

Lines changed: 216 additions & 83 deletions

File tree

admin.html

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1324,7 +1324,7 @@ <h1 style="margin:0;flex:0 0 auto">SENTINEL NODE TEST</h1>
13241324
onReady(() => {
13251325
// Load stats instantly (tiny payload, no results)
13261326
fetch('/api/stats').then(r => r.json()).then(d => {
1327-
if (d.state) { state = Object.assign(state, d.state); applyState(); }
1327+
if (d.state) { state = Object.assign(state, d.state); anchorEta(); applyState(); }
13281328
}).catch(() => {});
13291329
loadSdkVersions();
13301330
connectSSE();
@@ -1449,6 +1449,7 @@ <h1 style="margin:0;flex:0 0 auto">SENTINEL NODE TEST</h1>
14491449
const msg = JSON.parse(e.data);
14501450
if (msg.type === 'init' || msg.type === 'state') {
14511451
state = Object.assign(state, msg.state);
1452+
anchorEta(); // re-anchor the skew-immune ETA on every state update
14521453
if (msg.results) { resultsArr = msg.results; renderTable(); }
14531454
// Restore logs from server buffer on init — batch-render in a single
14541455
// DocumentFragment so a full 5000-line buffer doesn't trigger one
@@ -1469,9 +1470,10 @@ <h1 style="margin:0;flex:0 0 auto">SENTINEL NODE TEST</h1>
14691470
}
14701471
applyState();
14711472
} else if (msg.type === 'progress') {
1472-
state = Object.assign(state, msg.state); applyState();
1473+
state = Object.assign(state, msg.state); anchorEta(); applyState();
14731474
} else if (msg.type === 'result') {
14741475
state = Object.assign(state, msg.state);
1476+
anchorEta(); // result events carry fresh counters → fresh etaRemainingMs
14751477
if (msg.result) { upsertLocal(msg.result); addSingleRow(msg.result); }
14761478
applyState();
14771479
} else if (msg.type === 'log') {
@@ -1487,16 +1489,30 @@ <h1 style="margin:0;flex:0 0 auto">SENTINEL NODE TEST</h1>
14871489
};
14881490
}
14891491

1492+
// Skew-immune ETA anchor. The server broadcasts a REMAINING DURATION
1493+
// (state.etaRemainingMs), not an absolute epoch. We snapshot it against THIS
1494+
// client's own clock the instant a state update lands, then the 1s ticker
1495+
// counts it down using only our clock — so a skewed browser clock can't
1496+
// distort the ETA. A new server value RE-ANCHORS (server corrections land);
1497+
// between updates we just tick our anchor down. null clears the anchor.
1498+
let _etaAnchor = null; // { remainingMs, at } or null
1499+
function anchorEta() {
1500+
if (Number.isFinite(state.etaRemainingMs)) {
1501+
_etaAnchor = { remainingMs: state.etaRemainingMs, at: Date.now() };
1502+
} else {
1503+
_etaAnchor = null;
1504+
}
1505+
}
1506+
14901507
function updateETA() {
1491-
// Server-authoritative ETA: the server computes ONE absolute finish epoch
1492-
// (state.etaFinishAtMs) from a windowed real-throughput estimate and
1493-
// broadcasts it. Both admin and live render the same value — we just
1494-
// count down to it. See computeEtaFinishAt() in server.js.
1508+
// Render the anchored remaining duration, decremented by the time elapsed
1509+
// on OUR clock since the anchor was set. See computeEtaRemainingMs() +
1510+
// the etaRemainingMs broadcast in server.js.
14951511
const el = document.getElementById('etaTime');
14961512
if (state.status === 'done') { el.textContent = '00:00:00'; return; }
14971513
if (state.status !== 'running') return;
1498-
if (Number.isFinite(state.etaFinishAtMs)) {
1499-
const rem = Math.max(0, state.etaFinishAtMs - Date.now());
1514+
if (_etaAnchor) {
1515+
const rem = Math.max(0, _etaAnchor.remainingMs - (Date.now() - _etaAnchor.at));
15001516
const h = Math.floor(rem / 3600000), m = Math.floor((rem % 3600000) / 60000), s = Math.floor((rem % 60000) / 1000);
15011517
el.textContent = `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
15021518
} else {

audit/continuous.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,21 @@ async function _runOnePass(loopState, batchId, frozenNodes = null, resume = fals
303303
_emitScoped('result', { result: raw, batchId });
304304
const payload = _sanitizeBatchNodeResult(raw, batchId);
305305
_emitScoped('batch:node:result', payload);
306+
// ETA staleness fix: the pipeline only emits a per-node `state` event on
307+
// SUCCESS (it gates that broadcast on actualMbps != null — pipeline.js:978),
308+
// and batchBroadcast forwards that success `state` via the type==='state'
309+
// branch above. During a failing streak no `state` reaches the bus and the
310+
// server's windowed ETA (etaRemainingMs) never refreshes — clients tick
311+
// their anchor down to 0 and sit there. So forward the loop's current state
312+
// (carried on the result payload, with up-to-date counters) as a lightweight
313+
// `state` event ONLY for nodes the pipeline skipped its state emit on
314+
// (actualMbps == null: failures + skips). Successful nodes already got their
315+
// state above; emitting again here would double every success frame. Net:
316+
// exactly one `state` per completed node, pass/fail alike — and the server
317+
// recomputes etaRemainingMs each time. No new fields, no public-payload bloat.
318+
if (data.state && typeof data.state === 'object' && raw.actualMbps == null) {
319+
_emitScoped('state', { state: data.state });
320+
}
306321
// Persist to batch_results (non-blocking, non-fatal). Guard on batchId > 0:
307322
// if insertBatch failed upstream (logged, non-fatal) currentBatchId stays 0,
308323
// and writing insertBatchResult(0, …) would violate the FK to a non-existent

live.html

Lines changed: 40 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1650,9 +1650,9 @@ <h1 class="live-page-title">
16501650
if (hdrDot) hdrDot.className = 'pulse-dot';
16511651
// Log-panel header label: 'stopped' / 'complete' / 'error' / 'idle'.
16521652
setStatus(status === 'done' ? 'complete' : status);
1653-
// cbRender recomputes the ETA: with startedAt now null it resolves to
1654-
// "ETA 00:00:00" (all tested) or "ETA —" (stopped mid-run), never a
1655-
// climbing number.
1653+
// cbRender recomputes the ETA: a 'done' run reads "ETA 00:00:00" (matches
1654+
// admin), any other terminal/stopped status with no anchor reads "ETA —",
1655+
// never a climbing number.
16561656
cbRender();
16571657
}
16581658

@@ -1718,14 +1718,23 @@ <h1 class="live-page-title">
17181718

17191719
const etaEl = _cbEl('cbEta');
17201720
if (etaEl) {
1721-
// Server-authoritative ETA: the server computes ONE absolute finish
1722-
// epoch (_liveState.etaFinishAtMs) from a windowed real-throughput
1723-
// estimate and broadcasts it. We just count down to it — so this page
1724-
// and admin.html always show the same value. The old elapsed/tested
1725-
// per-node math (which diverged from admin's) is gone.
1726-
const finishAt = Number(_liveState && _liveState.etaFinishAtMs);
1727-
if (Number.isFinite(finishAt) && finishAt > 0) {
1728-
const rem = Math.max(0, finishAt - Date.now());
1721+
// Skew-immune ETA: the server broadcasts a REMAINING DURATION
1722+
// (_liveState.etaRemainingMs), not an absolute epoch. We anchor it to
1723+
// THIS client's own clock at receipt (see mergeLiveState → _etaAnchor)
1724+
// and count it down using only our clock — so a skewed browser clock
1725+
// can't distort the ETA. This page and admin.html show the same value.
1726+
// Parity with admin.html (updateETA): a terminal 'done' run shows a
1727+
// zeroed clock — "ETA 00:00:00" — NOT "ETA —". The server stops
1728+
// broadcasting a positive etaRemainingMs once status leaves 'running'
1729+
// (computeEtaRemainingMs returns null), so _etaAnchor is already cleared
1730+
// by the time a run reads 'done'; without this branch a completed run
1731+
// would show "ETA —" here while admin shows "00:00:00". "ETA —" stays
1732+
// reserved for genuinely unknown / stopped-mid-run states (no anchor,
1733+
// not done).
1734+
if (_liveState && _liveState.status === 'done') {
1735+
etaEl.textContent = 'ETA 00:00:00';
1736+
} else if (_etaAnchor) {
1737+
const rem = Math.max(0, _etaAnchor.remainingMs - (Date.now() - _etaAnchor.at));
17291738
const h = Math.floor(rem / 3600000);
17301739
const m = Math.floor((rem % 3600000) / 60000);
17311740
const s = Math.floor((rem % 60000) / 1000);
@@ -1890,11 +1899,11 @@ <h1 class="live-page-title">
18901899
applyHeaderStatsFromState();
18911900
}, 5000);
18921901

1893-
// Dedicated 1s ETA ticker. The ETA is now a countdown to the
1894-
// server-broadcast absolute finish epoch (_liveState.etaFinishAtMs), so it
1895-
// must re-render every second to tick down between server events. Gated on
1896-
// _cb.startedAt (cbFreeze nulls it on stop/done/error/idle) so a finished
1897-
// run's ETA freezes instead of marching.
1902+
// Dedicated 1s ETA ticker. The ETA is a countdown of the server-broadcast
1903+
// REMAINING DURATION (_etaAnchor, anchored to our own clock in
1904+
// mergeLiveState), so it must re-render every second to tick the anchor down
1905+
// between server events. Gated on _cb.startedAt (cbFreeze nulls it on
1906+
// stop/done/error/idle) so a finished run's ETA freezes instead of marching.
18981907
setInterval(() => {
18991908
if (_cb.startedAt) cbRender();
19001909
}, 1000);
@@ -1914,6 +1923,17 @@ <h1 class="live-page-title">
19141923
const LIVE_CACHE_KEY = 'live:snapshot:v1';
19151924
const LIVE_CACHE_TTL_MS = 60 * 60 * 1000; // 1h — enough to survive a reload
19161925
let _liveState = {};
1926+
// Skew-immune ETA anchor. The server broadcasts a REMAINING DURATION
1927+
// (_liveState.etaRemainingMs); we snapshot it against THIS client's own
1928+
// clock the instant a state update lands (mergeLiveState), then cbRender's
1929+
// 1s ticker counts it down using only our clock. A new server value
1930+
// re-anchors; null clears it (cbRender shows "ETA —").
1931+
let _etaAnchor = null; // { remainingMs, at } or null
1932+
function anchorEta() {
1933+
const rem = Number(_liveState && _liveState.etaRemainingMs);
1934+
if (Number.isFinite(rem)) _etaAnchor = { remainingMs: rem, at: Date.now() };
1935+
else _etaAnchor = null;
1936+
}
19171937
// True once we've received an AUTHORITATIVE run status — either the REST
19181938
// live-state seed or an SSE state/init. Until then, applyPauseFromState must
19191939
// NOT decide "paused" from the empty/cached defaults: on a cold load the
@@ -1988,6 +2008,7 @@ <h1 class="live-page-title">
19882008
// state/init) — applyPauseFromState may now decide the overlay.
19892009
_liveStateResolved = true;
19902010
_liveState = { ..._liveState, ...s };
2011+
anchorEta(); // re-anchor the skew-immune ETA on every state update
19912012
applyHeaderStatsFromState();
19922013
applyPauseFromState();
19932014
schedulePersist();
@@ -2448,8 +2469,9 @@ <h1 class="live-page-title">
24482469
// paused_balance / paused_internet — show a friendly 'paused' label.
24492470
// Not terminal: don't cbFreeze (keep startedAt so the resumed run's
24502471
// ETA continues). The ETA is frozen meanwhile by the server:
2451-
// computeEtaFinishAt returns null when status !== 'running', so the
2452-
// broadcast etaFinishAtMs goes null and cbRender shows "ETA —".
2472+
// computeEtaRemainingMs returns null when status !== 'running', so
2473+
// the broadcast etaRemainingMs goes null, anchorEta clears
2474+
// _etaAnchor, and cbRender shows "ETA —".
24532475
setStatus('paused');
24542476
}
24552477
renderLiveStats();

server.js

Lines changed: 62 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -353,29 +353,56 @@ function appendEventLog(msg) {
353353
// different inputs, so they disagreed. The server now owns the single source
354354
// of truth: it keeps a rolling window of the last ETA_WINDOW node-completion
355355
// timestamps, derives the current completion RATE (nodes/ms), and broadcasts
356-
// an ABSOLUTE finish epoch (`etaFinishAtMs`). Clients render
357-
// max(0, etaFinishAtMs - Date.now()) as HH:MM:SS, ticking every second. This
356+
// a REMAINING DURATION (`etaRemainingMs`) — NOT an absolute epoch. Each client
357+
// anchors that duration to its OWN clock at receipt and counts it down, so a
358+
// skewed browser clock can't distort the ETA (an absolute server epoch rendered
359+
// against a skewed client Date.now() drifted by the offset — minutes). The math
358360
// is throughput-based (parallelism-aware), not per-node-duration based.
359361
const ETA_WINDOW = 12;
360362
let _etaCompletions = []; // recent node-completion epoch ms (rolling, current pass)
361363
let _etaLastDone = -1; // last seen completed-count, to detect a new pass
364+
let _etaPrevStatus = null; // last seen status, to detect a fresh →running transition
365+
366+
// Resolve the (done, total) pair the ETA should measure against. During a
367+
// retest (runRetestSkips), every node already has a result row so the whole-run
368+
// counters are saturated (done ≈ total) and remaining ≤ 0 — the ETA would pin
369+
// at 00:00:00 for the entire retest. The retest's REAL progress lives in
370+
// separate fields (retestTested / retestPassed+retestFailed / retestTotal), so
371+
// when retestMode is set with a usable retestTotal we measure against those
372+
// instead. Falls back to the whole-run counters whenever the retest fields are
373+
// absent or non-positive (defensive — never let a missing field zero the ETA).
374+
function etaProgress(st) {
375+
if (st.retestMode && Number(st.retestTotal) > 0) {
376+
// retestTested already counts every retested node (pass + fail), so it is
377+
// the authoritative "done" for the retest pass; fall back to summing
378+
// retestPassed+retestFailed if retestTested is somehow absent.
379+
let done = Number(st.retestTested);
380+
if (!(done >= 0)) done = (Number(st.retestPassed) || 0) + (Number(st.retestFailed) || 0);
381+
return { done, total: Number(st.retestTotal) };
382+
}
383+
const done = (st.testedNodes || 0) + (st.failedNodes || 0) + (st.skippedNodes || 0);
384+
const total = st.totalNodes || 0;
385+
return { done, total };
386+
}
362387

363-
function computeEtaFinishAt(st) {
388+
// Returns the REMAINING milliseconds until the active pass completes, or null
389+
// when not computable (not running / <2 completions / total<=0). 0 when there
390+
// is no work left. Clients anchor this to their own clock and tick it down.
391+
function computeEtaRemainingMs(st) {
364392
if (!st || typeof st !== 'object') return null;
365393
// Only meaningful while a pass is actively running. done/idle/paused_* → null.
366394
if (st.status !== 'running') return null;
367-
const done = (st.testedNodes || 0) + (st.failedNodes || 0) + (st.skippedNodes || 0);
368-
const total = st.totalNodes || 0;
395+
const { done, total } = etaProgress(st);
369396
if (total <= 0) return null;
370397
const remaining = total - done;
371-
if (remaining <= 0) return Date.now(); // finish now → renders 00:00:00
398+
if (remaining <= 0) return 0; // no work left → renders 00:00:00
372399
if (_etaCompletions.length < 2) return null; // not enough data → client shows "Calculating…"
373400
const span = _etaCompletions[_etaCompletions.length - 1] - _etaCompletions[0];
374401
if (span <= 0) return null;
375402
const n = _etaCompletions.length - 1; // intervals across the window
376403
const ratePerMs = n / span;
377404
const etaMs = remaining / ratePerMs;
378-
return Date.now() + Math.round(etaMs);
405+
return Math.round(etaMs);
379406
}
380407

381408
function broadcast(type, data = {}) {
@@ -396,9 +423,9 @@ function broadcast(type, data = {}) {
396423
if (type === 'state' || type === 'result') saveStateSnapshot();
397424
// ─── ETA bookkeeping ──────────────────────────────────────────────────────
398425
// Record the just-finished node FIRST (so it's in the window), then compute
399-
// the absolute finish epoch and stamp it onto any state payload so admin
400-
// (which ticks on 'result' events) and live (which ticks on 'state') both
401-
// render the same value.
426+
// the remaining duration and stamp it onto any state payload so admin (which
427+
// ticks on 'result' events) and live (which ticks on 'state') both anchor the
428+
// same value to their own clocks.
402429
if (type === 'result') {
403430
_etaCompletions.push(Date.now());
404431
if (_etaCompletions.length > ETA_WINDOW) {
@@ -407,16 +434,28 @@ function broadcast(type, data = {}) {
407434
}
408435
if (data && data.state && typeof data.state === 'object') {
409436
const s = data.state;
410-
const done = (s.testedNodes || 0) + (s.failedNodes || 0) + (s.skippedNodes || 0);
411-
if (done < _etaLastDone) _etaCompletions = []; // counters reset → new pass
437+
// Explicit fresh-run ring reset: when status transitions INTO 'running' from
438+
// any non-running status, wipe the window so a new run can never inherit the
439+
// prior run's completion timestamps. This no longer relies on an incidental
440+
// zero-`done` broadcast landing before the first result of the new run.
441+
if (s.status === 'running' && _etaPrevStatus !== 'running') {
442+
_etaCompletions = [];
443+
_etaLastDone = -1;
444+
}
445+
_etaPrevStatus = s.status;
446+
const { done } = etaProgress(s);
447+
// Belt-and-suspenders per-pass reset within a continuous loop: each
448+
// iteration's counters reset to 0, so a drop below the last-seen done count
449+
// means a new pass started — clear the window.
450+
if (done < _etaLastDone) _etaCompletions = [];
412451
_etaLastDone = done;
413452
// NOTE: for most callers data.state IS the long-lived global `state` object,
414-
// so this assignment MUTATES the global in place — etaFinishAtMs is not a
453+
// so this assignment MUTATES the global in place — etaRemainingMs is not a
415454
// per-payload-only field. That's fine: it's recomputed on every broadcast,
416-
// and computeEtaFinishAt SELF-CLEARS it to null whenever status !== 'running'
417-
// (done/idle/paused_*), so a stale value can never linger on the global.
418-
// saveStateSnapshot's allowlist also excludes it, so it never persists.
419-
data.state.etaFinishAtMs = computeEtaFinishAt(data.state);
455+
// and computeEtaRemainingMs SELF-CLEARS it to null whenever status !==
456+
// 'running' (done/idle/paused_*), so a stale value can never linger on the
457+
// global. saveStateSnapshot's allowlist also excludes it, so it never persists.
458+
data.state.etaRemainingMs = computeEtaRemainingMs(data.state);
420459
}
421460
// NOTE: spread `data` FIRST so a payload field named `type` (e.g. the node's
422461
// service-type like 'wireguard') cannot clobber the SSE event type. The
@@ -1954,13 +1993,13 @@ function _redactPublicError(v, max = 200) {
19541993
// the Server-Baseline header tile.
19551994
// - activeSDK ('js' | 'tkd' | 'csharp') surfaces next to the run-mode label;
19561995
// display name + version are joined client-side via /api/public/sdk-info.
1957-
// - etaFinishAtMs is the server-authoritative absolute finish epoch (ms);
1958-
// live.html renders max(0, etaFinishAtMs - Date.now()) as a ticking ETA so
1959-
// it matches admin.html exactly.
1996+
// - etaRemainingMs is the server-authoritative REMAINING duration (ms);
1997+
// live.html anchors it to its own clock at receipt and counts it down, so it
1998+
// matches admin.html without being distorted by client clock skew.
19601999
const PUBLIC_STATE_KEYS = [
19612000
'status',
19622001
'totalNodes',
1963-
'etaFinishAtMs',
2002+
'etaRemainingMs',
19642003
'baselineMbps',
19652004
'baselineHistory',
19662005
'testRun',
@@ -2374,10 +2413,10 @@ app.get('/api/events', adminOnly, rlAdminSse, (req, res) => {
23742413
// Strip wallet + balance internals AND runGranter (subscription granter
23752414
// address — operator-internal, never needs to leave the server).
23762415
const { walletAddress, balance, balanceUdvpn, spentUdvpn, runGranter, ...stateForSse } = state;
2377-
// The global `state` doesn't carry a fresh etaFinishAtMs (it's stamped onto
2416+
// The global `state` doesn't carry a fresh etaRemainingMs (it's stamped onto
23782417
// broadcast payloads, not the global), so a freshly-connected admin would be
23792418
// blank until the next event. Compute it once here for the init frame.
2380-
stateForSse.etaFinishAtMs = computeEtaFinishAt(state);
2419+
stateForSse.etaRemainingMs = computeEtaRemainingMs(state);
23812420
// Trim each result row's diag to the 4 fields the dashboard reads (drop the
23822421
// credential/config/stdout blob). New array of clones — getResults() returns
23832422
// the shared in-memory state rows; never mutate them.

0 commit comments

Comments
 (0)