Multi-Hoster-Upload/tasks/todo.md
Administrator d5bb97aefe perf(history): virtualize the History table — kill the last tab-switch layout cost (v3.3.102)
v3.3.101's gate removed the get-history parse on tab switch, but the log still
showed tab clicks at ~216ms with a ~197ms renderer long-task and NO get-history
(proc=0ms → pure browser layout, not JS). Cause: `.view{display:none}` ->
`.active{display:flex}` and the History table built up to 2000 non-virtualized
<tr>, so making the view visible laid out 2000 rows (~197ms on the RDP VM). The
queue was already virtualized; the History table was the only large non-virtual
one.

Measured the options with Playwright on the real DOM (this machine; the user's VM
is ~1.7x slower):
- current 2000 rows (auto layout): 118ms
- content-visibility + table-layout:fixed: 117ms (no help — rows still laid out)
- cap to 300 rows: 16ms, but rejected: History rows are per-file-per-hoster, so a
  single 1280-file batch is ~3840 rows and a small cap would hide a recent batch's
  links
- virtualize (40 visible of 2000): 2.3ms

Fix — virtualize renderHistoryTable, mirroring the queue's _renderVirtualRows:
- The header is always rendered; tbody#historyBody gets only the visible rows plus
  top/bottom spacer <tr> sized from VIRTUAL_ROW_HEIGHT. A rAF-coalesced scroll
  handler and a ResizeObserver on #historyContainer re-render the visible window;
  the ResizeObserver also serves as the show-trigger (a hidden 0x0 container that
  gains size on tab activation re-renders at the correct height). Sorting resets
  scrollTop and re-renders; the copy-link / sort-header click delegation is
  unchanged. _historyWorking holds the sorted working set.
- styles.css: .history-table gets table-layout:fixed plus scoped column widths so
  columns do not jump as rows scroll in and out. This is scoped to .history-table
  and does not touch the .col-* classes the queue shares.

Verified end-to-end with Playwright at 6000 rows: show cost 1.1ms (was 118ms), DOM
stays 32-42 rows, scroll maps to the right rows (top/middle/bottom), scrollbar
height exact, column widths stable, rows update on scroll. All rows remain
scrollable (no UX loss); the show cost drops ~100x.

407 tests pass; clean Electron boot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:04:10 +02:00

29 KiB
Raw Blame History

v3.3.102 — virtualize the History table (the last tab-switch layout cost)

v3.3.101's gate killed the get-history PARSE on tab switch, but the v3.3.101 log showed a RESIDUAL: tab clicks still 216ms with a ~197ms renderer-longtask and NO get-history (gate worked). proc=0ms → pure browser layout, not JS. Cause: .view{display:none}.active{display:flex} + the History table builds up to 2000 <tr> NON-virtualized (renderHistoryTable), so showing the view lays out 2000 rows (~197ms on the RDP VM). The queue was already virtualized; history was the only non-virtual large table.

MEASURED with Playwright (real DOM, this machine; VM ≈1.7×):

  • current 2000 rows auto-layout: 118ms ; content-visibility+fixed: 117ms (USELESS — rows still laid out)
  • cap 300: 16ms (but rejected: history rows are per-file×hoster, a single 1280-file batch ≈3840 rows, so a small cap would HIDE a recent batch's links)
  • virtualize (40 visible of 2000): 2.3ms ✓ Verified the virtualization end-to-end @6000 rows: showCost 1.1ms, DOM stays 32-42 rows, scroll maps correctly (top=row0/mid=row2990/bottom=row5999), scrollHeight exact, columns STABLE (table-layout:fixed), rows update on scroll.

SHIPPED v3.3.102 (renderer/app.js + styles.css):

  • Virtualized renderHistoryTable mirroring the queue's _renderVirtualRows: header always rendered, tbody#historyBody gets only visible rows + top/bottom spacer <tr> (VIRTUAL_ROW_HEIGHT=28, OVERSCAN=10). Scroll handler (_onHistoryScroll, rAF-coalesced) + ResizeObserver on #historyContainer — the ResizeObserver doubles as the show-trigger (hidden 0×0 container → visible size → re-render at correct height). Sort resets scrollTop=0 + re-renders. Click delegation (copy-link / sort) unchanged. _historyWorking holds the sorted working set.
  • styles.css: .history-table{table-layout:fixed} + scoped col widths (16/34/12/38%) so columns don't jump as rows scroll in/out (does NOT touch the shared .col-* used by the queue). Measured: content-visibility was a no-op, so NOT used. All rows stay scrollable (no UX loss); show cost ~100× lower. 407 tests pass, clean boot. Playwright-verified.

DEFERRED still (only if needed): the FIRST get-history after a new batch parses 185MB once (~450ms) — needs the ConfigStore parse-cache (+185MB RAM, guarded) or JSONL. appendHistory still rewrites 185MB per batch-done (JSONL fixes that). The ~625ms batch-start spin-up + 107ms debug-log residuals.


v3.3.101 — History-tab lag: gate the unconditional reload + fix the diagnostics history regression

v3.3.100's interaction instrument named the residual exactly: EVERY slow click was button.tab/nav.tab-bar (200840ms), each coupled to ipc get-history wall=150-200ms sync + main-longtask blocked=242-289ms lastIpc=get-history + renderer-longtask 200-248ms. Uploads themselves pristine (ELD mean 11.7ms; the only spikes line up with the get-history tab-switches). Workflow wgxr06myb (4 agents + adversarial verify):

  • get-history fires ONLY entering the History tab (not every tab) — but the handler called loadHistory() UNCONDITIONALLY (app.js:362-365) despite tracking _historyDirty and never checking it. Each call: synchronous readFileSync+JSON.parse of the ~185MB / 30000-entry electron-history.json (no cache), ships all 30000 over IPC, renderer flattens ~120000 row objects then .slice(-2000) for the DOM (DOM already capped 2000).
  • Adversary safe subset = STEP 1 ALONE (gate the load, dirty-coverage verified complete: every append routes batch-done→appendHistory + upload-batch-done→handleBatchDone sets _historyDirty=true). Zero risk.

SHIPPED v3.3.101 (STEP 1 + a regression fix, both safe):

  • renderer/app.js: gate if (tab.dataset.view==='history' && (_historyDirty || !_historyEverLoaded)) loadHistory(); added _historyEverLoaded, set both flags inside loadHistory() AFTER the await succeeds (retry on failure). → REPEAT History tab-switches (no new uploads) now do ZERO ipc/parse/flatten = instant. (Honest limit: the FIRST History open after a new batch still parses 185MB once ~450ms — needs the parse-cache, see below.)
  • lib/diagnostics-collectors.js + main.js: getHistory now reads loadHistory() not load().history — fixes a CORRECTNESS regression I introduced in v3.3.99 (migrated mode → load().history is [] → remote diagnostics reported totalBatches:0 despite 30000 real batches). Backward-compatible fallback kept. +2 regression tests.

407 tests pass, clean boot.

DEFERRED (adversary-flagged, by design — do only if the next log/user still shows pain):

  • STEP 2(1) ConfigStore parse-cache for history (mtime+size key, invalidate BOTH _writeHistoryFileAtomic AND _writeHistoryFileDurable, slice-before-push for 'all'-retention same-ref aliasing). Makes first-after-upload switch instant + appendHistory read-half free, but ADDS ~185MB resident in main (NOT a relocation — adversary corrected the design's false RAM claim).
  • STEP 2(2) slice get-history to last-N-batches: REGRESSION VECTOR (breaks browse/sort-all 30000), needs a net-new paging/search-in-main IPC + UI that doesn't exist. Defer.
  • JSONL append-only storage: the ONLY thing that kills appendHistory's 185MB-rewrite-per-batch-done AND the parse entirely (tail-readable). On-disk format migration → own careful build.
  • Two minor independent residuals from the sweep: ~625ms batch-start spin-up (synchronous 22-job build/prime burst in one tick) + 107ms debug-log block mid-batch. Separate, low priority.

v3.3.100 — close the LAST measurement gap: renderer interaction timing (switches/clicks)

User asked "haben wir wirklich ALLES gemessen, auch switches/wechsel?". Audit: main-side was already fully covered (IPC wrapper ≥50ms on every handler, main-longtask >100ms with lastIpc, config instrument); account switchAccount is a trivial sync Map-set + the rotation work is async (can't block) → already covered. The REAL gap was RENDERER-side: renderer-perf was upload-gated (idle clicks unmeasured) and only aggregate (no per-interaction latency, no element attribution). Closed it (renderer/app.js, additive, self-silencing):

  • Event Timing API observer (type:'event', durationThreshold:50, buffered) → renderer-interaction <type> dur=Xms proc=Yms target=<el> for EVERY UI interaction ≥50ms (switch/sort-header/tab/button), always-on, names the element (id/class/data-action/aria-label). The direct "click→reaction" latency.
  • Idle renderer-longtask logging: any longtask ≥100ms logged immediately (renderer-longtask dur=Xms), not just during uploads. 405 tests pass, clean boot. Now EVERY action — main or renderer, idle or under load — names itself if slow.

v3.3.99 — THE KILL: 38.5MB config-thrash → history split out of the hot config + full instrumentation

THE ROOT CAUSE (from v3.3.98's instrument, the real "bread"): electron-config.json was 38.5MB and got loaded/cloned/serialized 137× in 73s on the main thread (140-592ms each) = ~47% main-thread occupancy → that IS the 1-2s button lag. The 1MB read-ahead was irrelevant against it. NOT the queue (queue=undefined was a LOGGING BUG: read .length on the pendingQueue OBJECT); the bulk is HISTORY — each batch-done appended the full per-file result list (summary.files w/ per-hoster URLs), 75 batches, default historyRetention='all' never prunes → unbounded. (5-agent workflow wz2g4bwka + adversarial verify; bench fixture confirmed 185MB = 100MB history/30000 entries.)

Why writes drove the storm: save-global-settings (queue-persist) did 2 loads + 1 serialize per call, and _atomicWrite NULLS the cache → next load is a full 38.5MB reparse. Even cache HITS structuredCloned 38.5MB. Per-job upload path makes ZERO config calls (selectUploadAuth/rotation take config by param) — pending=1280 is NOT the driver.

THE FIX (history split — kills clone AND serialize AND reparse at once; advisor-gated, adversarially verified):

  • History moved to its OWN file electron-history.json (lib/config-store.js). _migrateHistory() runs ONCE at init (packaged only), fail-safe: write history.json + fsync + verify count BEFORE the config is ever allowed to drop history, keep a permanent electron-config.json.pre-history-split.bak. _loadImpl returns history:[] when migrated → cached result is tiny → clones cheap; config file shrinks to ~KB on the first save() → reparse cheap; _serializeForDisk writes ~KB → serialize cheap. loadHistory/appendHistory/ pruneHistory/clearHistory redirected to history.json (own write-queue, no-clobber guard); legacy config path kept as fallback when migration fails. get-history/export-history go through loadHistory.
  • REAL 194MB-FIXTURE VALIDATION: migrate 1.5s (1×), all 30000 entries preserved + .bak; load() 631ms cold (1×) → 0.1ms after strip; save() strips config 185MB→2.1KB; loadHistory() still 30000. RESULT: data-preserved=true, hotpath-fast=true.
  • DROPPED per advisor (one-variable + risk): loadShallow (moot after split), Fix#2 cache-repopulate (dead gate), Fix#4 resolution-cache (stale-cache → rotation/byse failover-regression class — the one thing that could SILENTLY corrupt uploads). Fix#3 (this split) was the only complete fix.

"MEASURE EVERYTHING" instrumentation (user demand) — all additive, threshold-gated, MHU_PERF=0 to disable:

  • IPC handler wrapper (monkey-patch ipcMain.handle/.on) → ipc <channel> wall=Xms sync=Yms ≥50ms = the button-press→response latency, hardened so a logging throw can never break IPC (Promise.resolve(p).finally).
  • Main-process long-task drift monitor (setInterval 100ms) → main-longtask blocked=Xms lastIpc=… gc=… gcMax=… for any single main-thread turn >100ms (catches GC, fs scans, serialize the IPC wrapper structurally can't see).
  • config-store: caller attribution via=<stack> + wqDepth= on config-load/config-serialize lines; FIXED the queue= logging bug (now reads pendingQueue.queueJobs.length).

405 tests pass (9 new migration tests covering preserve-count, round-trip, save()-never-loses-history, crash-window fallback, idempotency). Clean Electron boot (9s, no errors). No repo pollution (migration packaged-only). NEXT LOG must show: config-load/config-serialize wall= drop to single digits (or vanish), main-longtask rare, ipc lines name any residual. If a residual remains it's pendingQueue (own follow-up, not a regression).


v3.3.98 — read-burst absorption (1MB hwm) + persist/load instrument; B (renderer) DEFERRED

v3.3.97 (threadpool 64→8) was a DECISIVE win: mean ELD 200ms→~11ms at 70 active (18×), renderer healthy 14/15 windows. User: "ganz flüssig isses noch nicht". A 5-agent ultracode workflow + adversarial verify localized the RESIDUAL to TWO distinct, measured spike sources (full data: subagents output wjskjo1xk):

  1. READ-BURSTS (tail W13/14/15, 15:18:53-19:04): FSReqCallback 66/70/46 vs threadpool=8 (~8.75× queue depth), SimpleWriteWrap collapses to 7/4/24, mean climbs 12.9→30.3→41.9ms. GC EXCLUDED (gcMax ≤27ms always). The FSReq↔SimpleWrite inversion at stable active=70/pending=1287 proves reads are CAUSAL, not a symptom of a block elsewhere.
  2. SYNC CONFIG PERSIST (suspected): save()→load() reparses the WHOLE electron-config.json (1287-job pendingQueue nested in globalSettings + full history) on every persist because _atomicWrite nulls the cache; _serializeForDisk JSON.stringify(...,null,2) of all of it. W13's single 1021ms max with heap→142MB fits a big synchronous structuredClone+stringify. CAVEAT (advisor): W13 is ONE confounded sample (also FSReq=66) and the ONLY heap-spike window; W4(415ms,heap41) & W10(852ms,heap18) are LOW-heap → NOT persist clones → likely the SECONDARY suspect: account-failed's synchronous configStore.load() per failure near connection churn (W6 teardown had doodstream connect-timeouts). So: INSTRUMENT, don't claim "found a 1s freeze".

SHIPPED v3.3.98 (one-variable discipline — advisor cut B to keep the next measurement clean):

  • A: highWaterMark 256KB→1MB in all 5 streaming read loops (hosters.js:291, doodstream:342, voe:245, vidmoly:190 CHUNK_SIZE consts; clouddrop:108 inline — NOT clouddrop:12's 16MB server chunk). Keep tp=8. Deepens per-stream read-ahead 0.43s→~1.7s (absorbs threadpool-queue latency so writes don't starve), 4× fewer read completions + allocs. Zero multipart byte-risk (Content-Length=preamble+fileSize+epilogue, independent of chunk size). REVERSIBLE PROBE; read-semaphore held in reserve (trigger: FSReq still ~70 + writes starved + mean elevated after 1MB).
  • C-instrument (BROADENED per advisor): config-store.js times load() (full reparse, incl. account-failed path) AND _commit serialize; logs config-load wall=Xms cache=hit/miss hist=N queue=M and config-serialize wall=Xms bytes=Y hist=N queue=M when ≥20ms (perfLog hook set in main.js via configStore.setPerfLog→logInfo). load() split into wrapper + _loadImpl. 397 tests pass.
  • B (renderer chunked rAF batch drain, app.js:188-193 — the 243ms longtask at W14) DEFERRED: renderer was healthy 14/15 windows and the one longtask is DOWNSTREAM of the main-thread read-burst flooding IPC. Fix A should make it self-heal. Bundling B would confound attribution + touches the progress hot path that bit before (formatDateTime burst, ghost-fix). Add B next round ONLY if renderer still janks after A.

NEXT LOG answers 3 things cleanly: (1) did A kill the read-bursts (FSReq per-window + tail mean drop)? (2) is the persist/load actually heavy (new config-load/config-serialize lines + their wall/queue/hist)? (3) did the renderer self-heal from A alone (longtasks back to 0)? Then decide: persist refactor for v3.3.99 (queue-out-of-config OR cache-repopulation — latter lower-risk but renderer's incoming globalSettings isn't default-merged like load() produces, so confirm merge-equivalence first), and/or B, and/or read-semaphore.


v3.3.97 — DECISIVE ELD finding: file-read phase-flip + threadpool 64→8 + GC instrument

The v3.3.96 eventloop-delay logs gave the decisive signal. At CONSTANT active-count, the system flips between two regimes:

  • HEALTHY (ELD ~11ms, rss 268308MB): SimpleWriteWrap ≈ active, FSReqCallback ≈ 01 (write/network-bound)
  • BLOCKED (ELD 49217ms, rss 540610MB): FSReqCallback ≈ active (6271 file reads in flight), SimpleWriteWrap ≈ 04 ELD spike + rss balloon both track FSReqCallback → file-read path through the libuv threadpool, NOT crypto, NOT renderer, NOT GC-alone. All 5 uploaders read identically (256KB createReadStream); byse/dood/voe run through the GENERIC uploadFile in hosters.js (no dedicated module).

Advisor caveats baked into the build (do NOT skip on re-measure):

  1. Causation UNPROVEN — high FSReqCallback could be a SYMPTOM (blocked loop can't drain read-completions).
  2. rss math kills "read buffers ballooned": 70×256KB ≈ 18MB, but rss swings ~300MB → heap/object churn (GC).
  3. Cheapest discriminator already wired: UV_THREADPOOL_SIZE 64→8 (1 line, reversible, NOT an upload cap; 8×256KB reads ≈ 100MB/s ≫ 41MB/s aggregate). Suspect tp=64 made it WORSE (removed read-serialization).

Shipped v3.3.97 = candidate-fix + discriminator in one build:

  • main.js:1 UV_THREADPOOL_SIZE 64→8.
  • ELD line now also logs heap=(heapUsed) ext=(external) ab=(arrayBuffers) gc=/gcTotal=/gcMax=ms (PerformanceObserver entryTypes:['gc'], reset per window).

DECISION RULE for the next user log:

  • ELD drops with tp=8 → read over-parallelism confirmed → keep 8 or productionize a DEDICATED read-semaphore.
  • ELD high + gcTotal/gcMax align with spikes → heap churn → hunt the allocator (semaphore would be wasted).
  • ELD high + gc flat → causation reversed (symptom) → pivot. WAIT for the next eventloop-delay log before any read-path refactor. NO upload cap (user rejected it).

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

User gave the decisive data: "25 connections okay, 50+61 laggt, EVTL wenn die uploadenden Zeilen nicht im Bild sind." Two regime facts (asked, not assumed — advisor caught the assume-the-regime trap a 3rd time): queue = 2001000 rows (VIRTUAL mode) + sort = clicked PROGRESS/SPEED (dynamic). This killed the non-virtual reflow theory (off-screen rows aren't in the DOM when virtual) AND pointed at the per-event path.

ROOT CAUSE (renderer process, NOT main — the v3.3.91 ELD log can't see this): maybeAddSessionFile(job) computed const dt = formatDateTime(new Date()) UNCONDITIONALLY at the top, before the status==='done' check that early-returns for everything else. formatDateTime does TWO Intl locale formats (toLocaleDateString + toLocaleTimeString) = ~83µs/call MEASURED. It runs on EVERY progress event (onUploadProgressBatch loops the M-item batch → handleProgress → _handleProgressImpl → maybeAddSessionFile), i.e. 10×M/sec, and THROWS IT AWAY for all non-done events (the overwhelming majority while uploading).

  • Scales exactly with M (active count): 250/sec at M=25 → 610/sec at M=61.
  • Bursts: each progress batch runs M calls back-to-back = a SYNCHRONOUS main-thread block of ~2.4ms (M=25) → ~5ms (M=61) every 100ms, on top of render+sort → blows the 16ms frame budget → scroll stutter. Scroll-independent (per-event, not per-render) → matches "lag when actives off-screen" exactly. This is the 25→50 cliff.

FIX: move const dt = formatDateTime(new Date()) inside the if (!_sessionFileKeys.has(dedupKey)) block, so it runs ONCE per genuinely-new completed upload, never per progress tick.

VERIFIED (faithful Blink benchmark at the CONFIRMED regime: Q=500 virtual, dynamic progress sort, scrolling, M=25/50/61, OLD vs FIXED): per-batch cost 1.7/3.2/4.1 ms (OLD, scales with M) → 0.0/0.0/0.0 ms (FIXED, flat). Frame P95 7.3→4.2 ms at M=61. M-scaling ELIMINATED. The render/scroll path itself is flat ~2.5ms median across all M → NO second knot there. updateStatusBar/StatsPanel = one cached O(Q) arithmetic pass (cheap); updateQueueActionButtons = O(selection) (cheap). No other Intl/Date on any per-event/per-frame hot path (2582 = job-log modal, 4587 = History view — both on-demand/cold). 397/397 tests pass, eslint clean.

NOTE: the v3.3.91/92 main-process event-loop-delay instrument is for the OTHER (CPU-vs-IO) hypothesis and is a separate process — keep it; it still answers whether the main thread also saturates at 50+ TLS streams.


High-concurrency lag audit (v3.3.90) — "lag ist immernoch da, ich vermute ab X gleichzeitig muss er alle Zeilen gebündelt updaten"

Method: 44-agent high-concurrency audit of the full upload→IPC→render path + Blink benchmark of the renderer queue table (Playwright/Chromium = same Blink engine), targeting the user's NEW hypothesis: "with ~100 concurrent uploads the renderer has to update ALL rows bundled rather than cleanly per-row."

The user's hypothesis is MEASURED-REFUTED — the renderer is NOT the bottleneck.

Blink benchmark over scenarios Q=150..1000, M=10 active, 60 ticks each:

  • renderQueueTable virtualizes at ≥200 rows; <200 = change-detecting in-place update.
  • _updateRowInPlace is change-detecting (no forced reflow, no layout reads).
  • median render <1 ms at Q=1000; only ~4/60 renders are full rebuilds even with progress-crossing sorts.
  • progress is coalesced main-side (_progressByJob Map keyed by jobId + 100ms flush → one batch sized by active-job count, ~10/sec); renderer iterates the batch with cheap per-row handleProgress. DOM amplification is ruled out by measurement. "Laggy at ~40% CPU / 8 cores" = ONE core at 100% = main-thread saturation / synchronous blocking, not DOM.

SHIPPED (v3.3.90) — the two real main-thread blockers, both behavior-preserving

  1. lib/clouddrop-upload.js _uploadChunked: was reading each 16 MB chunk with fs.readSync SYNCHRONOUSLY on the main event loop — unique among the 5 uploaders (the other 4 stream async). Each read blocks the WHOLE loop (~59 ms SSD, 30100 ms slow disk) → freezes all progress/IPC/render/other-uploads, scaling with the number of concurrent clouddrop uploads. Fits "laggy when uploading, worse with more concurrent." User uses clouddrop. Fix: fs.openSync/readSync/closeSyncfs.promises.open + await fh.read + await fh.close(). Byte-equivalence verified by SHA-256 over all chunk-boundary cases (full chunk, partial last chunk, 2/3/4-chunk, single byte) before shipping — a chunk-read bug = corrupt upload.
  2. lib/upload-manager.js rotation-retry (944) + suspect-alternate (1075) progressCb: both called _emitProgress (a synchronous emit('progress') + fresh object spread) on EVERY stream chunk (hundreds/sec per job) — they were missing the 250 ms lastEmitTime gate that the primary path (631) has. With many concurrent uploads in rotation/suspect mode that's real main-thread emit amplification. Mirrored the gate exactly: activeEntry mutation stays UNGATED (stats/speed-monitor stay fresh), only the emit is throttled to 4/sec. Behavior-preserving. 397/397 tests pass, eslint clean (1 pre-existing unrelated warning at line 554).

DROPPED (advisor: measured fine, don't chase perception)

  • Lowering the virtual-row threshold below 200: the Blink benchmark shows <200 in-place updates are already sub-ms; no change warranted.

DISCRIMINATOR ANSWERED (user, 2026-06-21)

(a) Lag NOT clouddrop-specific — other hosters. (b) Parallel counts RAISED deliberately (10+). (c) 50+ uploading SIMULTANEOUSLY active. → This is the TRUE high-concurrency main-thread-funnel branch, NOT clouddrop. v3.3.90 stands but does not target this user's case.

v3.3.91 — instrument first, don't refactor the upload core off elimination-reasoning

Advisor reframe: two LIVE hypotheses need OPPOSITE fixes — (A) main thread CPU-blocked (TLS/crypto/sync) → event loop stalls → a cap/workers help; (B) main thread fine but IO-STARVED (libuv threadpool/sockets) → loop stays responsive, uploads just queue → workers are WASTED, config fixes it. A worker/child-process upload refactor touches throttle/rotation/abort/progress/credentials and is hard to reverse — DO NOT ship it off sandbox elimination. One measurement splits the hypotheses and must run in the REAL app.

SHIPPED (both reversible, zero upload-core refactor):

  1. main.js: perf_hooks.monitorEventLoopDelay({resolution:10}) enabled at startup; logged via logInfo every ~5 s WHILE uploading (state==='uploading' && activeJobs>0) as eventloop-delay active=N mean=..ms p99=..ms max=..ms stddev=..ms threadpool=... Pure numbers, no secret → does NOT touch the redaction surface. This is the GROUND TRUTH: high mean/p99 → CPU-blocking → workers justified; low delay while uploads stall → IO-bound → workers wasted, threadpool/sockets is the fix.
  2. main.js (first statement, before require('electron')): UV_THREADPOOL_SIZE = env || '64'. Default is 4; every async uploader feeds undici from fs.createReadStream (+ clouddrop fh.read) and DNS getaddrinfo goes through the same pool → 50 concurrent vs 4 threads = reads/DNS serialize 4-at-a-time = a hard cliff at a small connection count = the "ab X connections" symptom. Threads are created lazily on demand → 64-max costs nothing if unused (zero-risk, reversible). The advisor's prescribed one-env-var hypothesis test.

CAVEAT (honest): synthetic sandbox benches could NOT confirm the threadpool is the bottleneck — pbkdf2 is CPU-core-bound (masks pool size); DNS .invalid returns instantly; real-RTT DNS showed NO pool benefit because WINDOWS serializes getaddrinfo via the OS DNS Client service (so on Windows the DNS half of the cliff is masked by the resolver, though the fs-read half still benefits). This is exactly why the ELD number must come from the user's real load, not the sandbox. Per-uploader undici Agent audit: clouddrop has a shared module-level Agent (connections:50); doodstream/voe/vidmoly use the global dispatcher (pooled per origin, NO per-call agent explosion) — so no agent fix needed.

v3.3.92 — make the single measurement decisive + breadth audit of un-checked main.js hot paths

Enriched the ELD log line with process.getActiveResourcesInfo() as a compact type-histogram: eventloop-delay active=N mean/p99/max/stddev ms threadpool=64 resources=K {TCPSocketWrap:50,FSReqCallback:4,...}. Now ONE run splits all three readings in a single line: high mean/p99 → CPU-blocked (workers/cap); low delay + many TCP/FS/GetAddrInfo resources → IO-bound queueing (threadpool/sockets, NOT workers); low delay + few resources → not saturated (lag elsewhere / perception). Pure numbers, no redaction surface.

Breadth audit this round (4th /goal re-fire, code I wrote, NOT re-measuring cleared render/persist):

  • main.js logging (debug/rot/upload): all buffered + ASYNC fs.appendFile (write-guard flag, 500ms timer, setImmediate re-flush). Sync appendFileSync ONLY in crash/signal/exit handlers (correct there). CLEAN.
  • main.js progress coalescing (_progressByJob Map + 100ms batch → one upload-progress-batch via safeSend): non-terminal = Map.set (keeps latest/job); gated upstream to 4/sec/job. CLEAN at N=50.
  • _appendJobLog: capped in-memory ring buffer (Map, FIFO-evict). CLEAN.
  • All 5 uploaders: doodstream/voe/vidmoly/clouddrop-simple stream via async createReadStream + for-await + async throttle.consume; clouddrop-chunked now async fh.read. NONE block the main loop per chunk. CLEAN (clouddrop's old readSync was the unique outlier, fixed v3.3.90).

NEXT (gated on the real-app ELD number + user's explicit nod)

User runs their 50-concurrent load once; the enriched eventloop-delay log line decides:

  • mean/p99 HIGH (tenshundreds ms) → CPU-blocked → propose worker_threads/child-process upload pool OR a smart concurrency cap (WITH the user's nod — it's hard to reverse and touches credentials/abort/rotation).
  • delay LOW while it still lags → IO-bound → threadpool bump already addresses it; if not, look at socket caps / undici Agent connection limits / per-origin pooling, NOT workers. Do NOT build the worker refactor before this number exists.