@@ -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.
359361const ETA_WINDOW = 12 ;
360362let _etaCompletions = [ ] ; // recent node-completion epoch ms (rolling, current pass)
361363let _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
381408function 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 .
19601999const 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