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:
parent
ad87e36a8f
commit
53c3448836
@ -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
22
main.js
@ -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 {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user