Compare commits

..

3 Commits

Author SHA1 Message Date
Administrator
58db913275 release: v3.3.94 2026-06-21 16:24:24 +02:00
Administrator
958bc35c14 docs(tasks): v3.3.94 measurement build + confirmed localization (renderer innocent, lag from active uploads/oversubscription)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 16:23:43 +02:00
Administrator
53c3448836 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>
2026-06-21 16:23:43 +02:00
5 changed files with 111 additions and 6 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

@ -1,6 +1,6 @@
{ {
"name": "multi-hoster-uploader", "name": "multi-hoster-uploader",
"version": "3.3.93", "version": "3.3.94",
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously", "description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {

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) {

View File

@ -1,3 +1,38 @@
# v3.3.94 — comprehensive measurement build (user: "mach alles messen was man messen kann")
Localization so far (each step EMPIRICAL, not by elimination — advisor caught the elimination-leap):
- Renderer queue render PROVEN cheap: loaded the REAL app.js in headless Chromium (Playwright) with a mocked
window.api, populated Q=1000 / 61 active / progress sort, drove the real onUploadProgressBatch +
renderQueueTable + scroll → ALL <0.5ms. (Caveat: component cost, not frame rate.)
- User CONFIRMED the discriminator: a full 1000-row queue scrolls SMOOTH when idle, ruckelt ONLY while ~6170
uploads are active → the lag is driven by the active uploads (main-process / system load), not the table.
- Screenshot: 70 connections, 1413 files, 41.3 MB/s, "write ECONNRESET". ECONNRESET is already classified
transient (upload-manager _isTransientNetworkError line 171 → retried, not account-fatal) — it's the
SIGNATURE of oversubscription (servers RST the excess connections). Same root cause as the lag.
Immediate user lever (already exists): Settings → Uploads → "Globale parallele Uploads" (parallelUploadCount,
global semaphore, default 0=off). Capping total concurrent uploads (~20) should fix lag AND ECONNRESET AND
likely keep throughput (bandwidth-limited at 41 MB/s; reset connections waste bandwidth on retries).
SHIPPED measurement (all additive, zero upload-behavior change) to pinpoint CPU-vs-IO vs renderer from the
user's REAL 70-connection run:
- main.js ELD line now also logs: cpu=X%core (process.cpuUsage delta / wall, >100% = multi-core),
rss=YMB, active-by-hoster={dood:.., voe:.., ...} (per-hoster live connection distribution → shows which
hoster is oversubscribed), transient-errs=N (cumulative ECONNRESET-class on the primary path), pending=M.
- lib/upload-manager.js: getDiagnostics() {activeByHoster, transientErrors, pending, active}; activeEntry
now carries hoster; _transientErrorTotal++ in the primary catch when _isTransientNetworkError.
- renderer/app.js: PerformanceObserver('longtask') + a rAF frame-time monitor → logs every 5s WHILE
uploading: `renderer-perf active=N fps=X jankFrames=Y worstFrame=Zms longtasks=W maxTask=Vms`. This is the
DIRECT renderer ground truth (the component-timing harness couldn't capture real frame rate). Low fps /
high jankFrames / longtasks → renderer IS blocked; ~60fps + no jank while it still feels laggy → it's the
main-process/system, and the cpu=/eld= numbers in the same log say CPU-bound (→ workers/cap) vs IO-bound.
Both logs land in the normal debug log (logInfo / window.api.debugLog). 397/397 tests, eslint clean.
NEXT: user runs the 70-load on v3.3.94, shares the `eventloop-delay` + `renderer-perf` log lines (or connects
diagnostics). Those two lines together localize it definitively. Do NOT build workers/cap before that.
---
# v3.3.93 — THE renderer lag knot FOUND + FIXED + MEASURED: formatDateTime per progress event # v3.3.93 — THE renderer lag knot FOUND + FIXED + MEASURED: formatDateTime per progress event
User gave the decisive data: "25 connections okay, 50+61 laggt, EVTL wenn die uploadenden Zeilen nicht im User gave the decisive data: "25 connections okay, 50+61 laggt, EVTL wenn die uploadenden Zeilen nicht im