feat(diag): comprehensive high-concurrency measurement build

User asked to measure everything so we can pinpoint the lag/ECONNRESET at 70 concurrent uploads. All additive instrumentation, no upload-behavior change.

main process: the eventloop-delay log line now also reports cpu=X%core (process.cpuUsage delta over wall time; >100% means multiple cores), rss, per-hoster live connection distribution (active-by-hoster), cumulative transient (ECONNRESET-class) error count, and pending count — pulled from a new UploadManager.getDiagnostics(). This shows whether the main thread is CPU-bound and which hoster is oversubscribed.

renderer: a PerformanceObserver('longtask') plus a requestAnimationFrame frame-time monitor log 'renderer-perf' every 5s while uploading — fps, jankFrames (>33ms), worstFrame, longtask count and max. This is the direct renderer ground truth (real frame rate under load), which the component-timing harness could not capture. Low fps / high jank => renderer is blocked; ~60fps while it still feels laggy => the cpu/eld numbers in the same log decide CPU-bound vs IO-bound in the main process.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Administrator 2026-06-21 16:23:43 +02:00
parent ad87e36a8f
commit 53c3448836
3 changed files with 75 additions and 5 deletions

View File

@ -34,10 +34,11 @@ class UploadManager extends EventEmitter {
this.stopAfterActive = false; this.stopAfterActive = false;
this.statsInterval = null; this.statsInterval = null;
this.startTime = 0; this.startTime = 0;
this.activeJobs = new Map(); // uploadId -> { jobId, speedKbs, bytesUploaded } this.activeJobs = new Map(); // uploadId -> { jobId, speedKbs, bytesUploaded, hoster }
this.jobAbortControllers = new Map(); // jobId -> AbortController this.jobAbortControllers = new Map(); // jobId -> AbortController
this.cancelledJobIds = new Set(); this.cancelledJobIds = new Set();
this.sessionBytes = 0; this.sessionBytes = 0;
this._transientErrorTotal = 0;
this.lastStartTime = {}; // hoster -> timestamp of last upload start this.lastStartTime = {}; // hoster -> timestamp of last upload start
this.intervalLocks = {}; // hoster -> Promise chain for serialized interval waits this.intervalLocks = {}; // hoster -> Promise chain for serialized interval waits
this.globalThrottle = null; this.globalThrottle = null;
@ -81,6 +82,17 @@ class UploadManager extends EventEmitter {
return this.activeJobs.size; return this.activeJobs.size;
} }
getDiagnostics() {
const activeByHoster = {};
for (const v of this.activeJobs.values()) {
const h = v && v.hoster ? v.hoster : 'unknown';
activeByHoster[h] = (activeByHoster[h] || 0) + 1;
}
let pending = 0;
for (const sem of Object.values(this.semaphores)) pending += (sem && sem.pending) || 0;
return { activeByHoster, transientErrors: this._transientErrorTotal, pending, active: this.activeJobs.size };
}
clearFailedAccount(hoster, accountId) { clearFailedAccount(hoster, accountId) {
return this._failedAccounts.delete(`${hoster}:${accountId}`); return this._failedAccounts.delete(`${hoster}:${accountId}`);
} }
@ -625,7 +637,7 @@ class UploadManager extends EventEmitter {
// Mutate this single object on each progress callback instead of // Mutate this single object on each progress callback instead of
// allocating a fresh one — callback fires on every stream chunk // allocating a fresh one — callback fires on every stream chunk
// (hundreds/sec per active job). // (hundreds/sec per active job).
const activeEntry = { jobId, speedKbs: 0, bytesUploaded: 0 }; const activeEntry = { jobId, speedKbs: 0, bytesUploaded: 0, hoster: task.hoster };
this.activeJobs.set(uploadId, activeEntry); this.activeJobs.set(uploadId, activeEntry);
let lastEmitTime = 0; let lastEmitTime = 0;
@ -686,6 +698,7 @@ class UploadManager extends EventEmitter {
return; return;
} catch (err) { } catch (err) {
this.activeJobs.delete(uploadId); this.activeJobs.delete(uploadId);
if (this._isTransientNetworkError(err)) this._transientErrorTotal++;
const isSpeedRestart = speedAbort && speedAbort.signal.aborted && !signal.aborted; const isSpeedRestart = speedAbort && speedAbort.signal.aborted && !signal.aborted;
if (!signal.aborted && !isSpeedRestart) { if (!signal.aborted && !isSpeedRestart) {
@ -938,7 +951,7 @@ class UploadManager extends EventEmitter {
let lastBytes = 0; let lastBytes = 0;
let lastSpeedTime = jobStart; let lastSpeedTime = jobStart;
let currentSpeedKbs = 0; let currentSpeedKbs = 0;
const activeEntry = { jobId, speedKbs: 0, bytesUploaded: 0 }; const activeEntry = { jobId, speedKbs: 0, bytesUploaded: 0, hoster: task.hoster };
this.activeJobs.set(uploadId, activeEntry); this.activeJobs.set(uploadId, activeEntry);
let lastEmitTime = 0; let lastEmitTime = 0;
@ -1074,7 +1087,7 @@ class UploadManager extends EventEmitter {
let lastBytes = 0; let lastBytes = 0;
let lastSpeedTime = jobStart; let lastSpeedTime = jobStart;
let currentSpeedKbs = 0; let currentSpeedKbs = 0;
const activeEntry = { jobId, speedKbs: 0, bytesUploaded: 0 }; const activeEntry = { jobId, speedKbs: 0, bytesUploaded: 0, hoster: task.hoster };
this.activeJobs.set(uploadId, activeEntry); this.activeJobs.set(uploadId, activeEntry);
let lastEmitTime = 0; let lastEmitTime = 0;
const PROGRESS_EMIT_INTERVAL = 250; const PROGRESS_EMIT_INTERVAL = 250;

22
main.js
View File

@ -30,6 +30,8 @@ const { createAgent } = require('./lib/diagnostics-agent');
const _eventLoopDelay = monitorEventLoopDelay({ resolution: 10 }); const _eventLoopDelay = monitorEventLoopDelay({ resolution: 10 });
_eventLoopDelay.enable(); _eventLoopDelay.enable();
let _eldLastLog = 0; let _eldLastLog = 0;
let _lastCpu = process.cpuUsage();
let _lastCpuT = Date.now();
let mainWindow; let mainWindow;
let _lastImportPath = null; let _lastImportPath = null;
@ -205,7 +207,25 @@ function _maybeLogEventLoopDelay(activeJobs) {
const top = Object.entries(hist).sort((a, b) => b[1] - a[1]).slice(0, 6).map(([k, v]) => `${k}:${v}`).join(','); const top = Object.entries(hist).sort((a, b) => b[1] - a[1]).slice(0, 6).map(([k, v]) => `${k}:${v}`).join(',');
resStr = ` resources=${info.length} {${top}}`; resStr = ` resources=${info.length} {${top}}`;
} catch {} } catch {}
logInfo('perf', `eventloop-delay active=${activeJobs} mean=${mean}ms p99=${p99}ms max=${max}ms stddev=${stddev}ms threadpool=${process.env.UV_THREADPOOL_SIZE}${resStr}`); let cpuStr = '';
try {
const d = process.cpuUsage(_lastCpu);
const wall = now - _lastCpuT;
const pct = wall > 0 ? Math.round((d.user + d.system) / 1000 / wall * 100) : 0;
const rss = Math.round(process.memoryUsage().rss / 1048576);
cpuStr = ` cpu=${pct}%core rss=${rss}MB`;
_lastCpu = process.cpuUsage();
_lastCpuT = now;
} catch {}
let upStr = '';
try {
if (uploadManager && typeof uploadManager.getDiagnostics === 'function') {
const d = uploadManager.getDiagnostics();
const byHoster = Object.entries(d.activeByHoster || {}).map(([h, c]) => `${h.replace(/\..*$/, '')}:${c}`).join(',');
upStr = ` active-by-hoster={${byHoster}} transient-errs=${d.transientErrors || 0} pending=${d.pending || 0}`;
}
} catch {}
logInfo('perf', `eventloop-delay active=${activeJobs} mean=${mean}ms p99=${p99}ms max=${max}ms stddev=${stddev}ms threadpool=${process.env.UV_THREADPOOL_SIZE}${cpuStr}${resStr}${upStr}`);
_eventLoopDelay.reset(); _eventLoopDelay.reset();
} catch {} } catch {}
} }

View File

@ -18,6 +18,37 @@ let config = { hosters: {}, hosterSettings: {}, globalSettings: {} };
let hosterSettings = {}; let hosterSettings = {};
let uploading = false; let uploading = false;
let healthCheckRunning = false; let healthCheckRunning = false;
let _rLongTasks = 0, _rLongTaskMax = 0, _rFrameLast = 0, _rFrameWorst = 0, _rFrameCount = 0, _rFrameJank = 0, _rPerfLastLog = 0, _rPerfWindowStart = 0;
try {
if (window.PerformanceObserver) {
new window.PerformanceObserver((list) => {
for (const e of list.getEntries()) { _rLongTasks++; if (e.duration > _rLongTaskMax) _rLongTaskMax = e.duration; }
}).observe({ entryTypes: ['longtask'] });
}
} catch {}
function _rFrameTick(ts) {
if (_rFrameLast) { const d = ts - _rFrameLast; _rFrameCount++; if (d > _rFrameWorst) _rFrameWorst = d; if (d > 33) _rFrameJank++; }
_rFrameLast = ts;
requestAnimationFrame(_rFrameTick);
}
requestAnimationFrame(_rFrameTick);
function _resetRendererPerf() {
_rPerfWindowStart = Date.now();
_rFrameCount = 0; _rFrameJank = 0; _rFrameWorst = 0; _rLongTasks = 0; _rLongTaskMax = 0;
}
function _maybeLogRendererPerf(activeJobs) {
const now = Date.now();
if (!_rPerfWindowStart) _rPerfWindowStart = now;
if (now - _rPerfLastLog < 5000) return;
const winSec = (now - _rPerfWindowStart) / 1000;
const fps = winSec > 0 ? Math.round(_rFrameCount / winSec) : 0;
if (window.api && window.api.debugLog) {
window.api.debugLog(`renderer-perf active=${activeJobs} fps=${fps} jankFrames=${_rFrameJank} worstFrame=${Math.round(_rFrameWorst)}ms longtasks=${_rLongTasks} maxTask=${Math.round(_rLongTaskMax)}ms`);
}
_rPerfLastLog = now;
_resetRendererPerf();
}
let accountStatuses = {}; // { accountId: { status: 'ok'|'warn'|'error'|'checking'|'unchecked', message: '' } } let accountStatuses = {}; // { accountId: { status: 'ok'|'warn'|'error'|'checking'|'unchecked', message: '' } }
let editingAccountId = null; // null = adding, string = editing account by ID let editingAccountId = null; // null = adding, string = editing account by ID
let autoHealthCheckEnabled = true; let autoHealthCheckEnabled = true;
@ -2539,6 +2570,12 @@ function _handleStatsImpl(data) {
updateStatusBar(); updateStatusBar();
updateStatsPanel(); updateStatsPanel();
if (data.state === 'uploading' && (data.activeJobs || 0) > 0) {
_maybeLogRendererPerf(data.activeJobs);
} else {
_resetRendererPerf();
}
// Track run time // Track run time
if (data.state === 'uploading' || data.state === 'stopping') { if (data.state === 'uploading' || data.state === 'stopping') {
if (!statsStartTime) { if (!statsStartTime) {