From bad1c665f51a463eee822995aed83f23c9048ece Mon Sep 17 00:00:00 2001 From: Administrator Date: Sun, 21 Jun 2026 19:45:50 +0200 Subject: [PATCH] perf(history): gate the unconditional History-tab reload + fix the diagnostics history regression (v3.3.101) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v3.3.100's interaction instrument pinpointed the residual UI lag exactly: every slow click was a tab switch into the History view (button.tab / nav.tab-bar, 200-840ms), each coupled to `ipc get-history wall=150-200ms sync` + `main-longtask blocked=242-289ms lastIpc=get-history` + a 200-248ms renderer long task. Uploads themselves are pristine (event-loop mean 11.7ms; the only spikes line up with the get-history tab switches). Root cause: the History tab handler called loadHistory() UNCONDITIONALLY on every activation (renderer/app.js) even though it tracks a `_historyDirty` flag it never checked. Each call synchronously readFileSync + JSON.parse's the ~185MB / 30000-entry electron-history.json (no cache), ships all 30000 batches across IPC, and the renderer flattens ~120000 row objects before .slice(-2000) for the DOM (the DOM is already capped at 2000). Fix (the adversary-verified safe subset): - Gate the History-tab load: only reload when `_historyDirty || !_historyEverLoaded`. Added `_historyEverLoaded`, and both flags are now set inside loadHistory() after the fetch succeeds (so a failed load retries). Dirty-coverage is complete — every history append routes through batch-done -> appendHistory and upload-batch-done -> handleBatchDone which sets _historyDirty=true. Result: repeat History tab switches with no new uploads do zero IPC/parse/flatten and are instant. (The first open after a new batch still parses once ~450ms; removing that needs a parse-cache or JSONL storage, deferred.) - Diagnostics history regression: getHistory() read loadConfig().history, but since the v3.3.99 history split load() returns history:[] in packaged mode, so remote diagnostics reported totalBatches:0 despite 30000 real batches. It now reads loadHistory() (injected via the collector deps), with a backward-compatible fallback to loadConfig().history when not provided. 407 tests pass (2 new diagnostics regression tests); clean Electron boot. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/diagnostics-collectors.js | 7 ++--- main.js | 1 + package.json | 2 +- renderer/app.js | 6 +++-- tasks/todo.md | 38 ++++++++++++++++++++++++++++ tests/diagnostics-collectors.test.js | 26 +++++++++++++++++++ 6 files changed, 74 insertions(+), 6 deletions(-) diff --git a/lib/diagnostics-collectors.js b/lib/diagnostics-collectors.js index de4ecbd..dcfaf23 100644 --- a/lib/diagnostics-collectors.js +++ b/lib/diagnostics-collectors.js @@ -11,7 +11,7 @@ const READABLE_LOGS = { const QUEUE_STATUSES = ['preview', 'queued', 'getting-server', 'uploading', 'retrying', 'done', 'error', 'aborted', 'skipped']; function createCollectors(deps) { - const { loadConfig, getAllLogPaths, support, stats, appInfo, systemInfo, agentInfo } = deps; + const { loadConfig, loadHistory, getAllLogPaths, support, stats, appInfo, systemInfo, agentInfo } = deps; function _secrets() { try { return support.collectSecretValues(loadConfig()); } catch { return []; } @@ -205,8 +205,9 @@ function createCollectors(deps) { function getHistory(args) { const a = args || {}; - const cfg = loadConfig(); - const history = Array.isArray(cfg.history) ? cfg.history : []; + const history = typeof loadHistory === 'function' + ? (loadHistory() || []) + : (Array.isArray(loadConfig().history) ? loadConfig().history : []); const limit = Math.min(Math.max(Number(a.limit) || 20, 1), 200); const perHoster = stats.summarizePerHoster(history); const recent = [...history].slice(-limit).reverse(); diff --git a/main.js b/main.js index cabb3fe..77839b9 100644 --- a/main.js +++ b/main.js @@ -2617,6 +2617,7 @@ function _diagAgentInfo() { function _buildDiagnosticHandler() { const collectors = createCollectors({ loadConfig: () => configStore.load(), + loadHistory: () => configStore.loadHistory(), getAllLogPaths, support: { sanitizeConfig, collectSecretValues, redactLogText, valueScrub, collectFile, REDACTED }, stats, diff --git a/package.json b/package.json index 384b86a..04da9d0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "multi-hoster-uploader", - "version": "3.3.100", + "version": "3.3.101", "description": "Upload files to doodstream, voe, vidmoly, byse simultaneously", "main": "main.js", "scripts": { diff --git a/renderer/app.js b/renderer/app.js index bbba33b..de05fe5 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -332,6 +332,7 @@ async function init() { // --- Tab switching --- let _historyDirty = false; +let _historyEverLoaded = false; function _isHistoryTabActive() { const tab = document.querySelector('.tab.active'); return !!(tab && tab.dataset.view === 'history'); @@ -359,8 +360,7 @@ function _isHistoryTabActive() { const nextView = viewsById[`${tab.dataset.view}-view`]; if (nextView) nextView.classList.add('active'); activeTab = tab; - if (tab.dataset.view === 'history') { - _historyDirty = false; + if (tab.dataset.view === 'history' && (_historyDirty || !_historyEverLoaded)) { loadHistory(); } }; @@ -4630,6 +4630,8 @@ function _hideOtpField() { async function loadHistory() { const history = await window.api.getHistory(); window._historyForStats = history || []; + _historyEverLoaded = true; + _historyDirty = false; _invalidateHosterLifetimeCache(); const retSel = document.getElementById('historyRetentionSelect'); if (retSel) retSel.value = (config.globalSettings && config.globalSettings.historyRetention) || 'all'; diff --git a/tasks/todo.md b/tasks/todo.md index b47bce3..1ecb6de 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -1,3 +1,41 @@ +# 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` +(200–840ms), 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 diff --git a/tests/diagnostics-collectors.test.js b/tests/diagnostics-collectors.test.js index 7fb0d19..f0eaac8 100644 --- a/tests/diagnostics-collectors.test.js +++ b/tests/diagnostics-collectors.test.js @@ -53,6 +53,32 @@ test('getConfigRedacted strips password/apiKey/token/webhookUrl and value-scrubs assert.ok(!json.includes('WBHOOKSECRETTOKEN'), 'webhook secret must be redacted'); }); +test('getHistory reads loadHistory (migrated mode: loadConfig().history is empty)', () => { + const c = createCollectors({ + loadConfig: () => ({ hosters: {}, globalSettings: {}, history: [] }), + loadHistory: () => [ + { timestamp: '2026-01-01T00:00:00.000Z', files: [{ name: 'a.mkv', results: [{ hoster: 'voe.sx', status: 'done', url: 'https://voe.sx/a' }] }] }, + { timestamp: '2026-01-02T00:00:00.000Z', files: [{ name: 'b.mkv', results: [{ hoster: 'byse.sx', status: 'done', url: 'https://byse.sx/b' }] }] } + ], + getAllLogPaths: () => ({ logDir: os.tmpdir() }), + support, stats, + appInfo: () => ({}), systemInfo: () => ({}), agentInfo: () => ({}) + }); + const out = c.getHistory({ limit: 10 }); + assert.equal(out.totalBatches, 2, 'must report real history from loadHistory, not the empty load().history'); + assert.equal(out.returned, 2); +}); + +test('getHistory falls back to loadConfig().history when loadHistory is absent (legacy mode)', () => { + const c = createCollectors({ + loadConfig: () => ({ hosters: {}, globalSettings: {}, history: [{ timestamp: '2026-01-01T00:00:00.000Z', files: [] }] }), + getAllLogPaths: () => ({ logDir: os.tmpdir() }), + support, stats, + appInfo: () => ({}), systemInfo: () => ({}), agentInfo: () => ({}) + }); + assert.equal(c.getHistory({ limit: 10 }).totalBatches, 1, 'legacy path reads load().history when loadHistory not injected'); +}); + test('readLog redacts a planted token and a Bearer line; doodstream is NOT readable; unknown name rejected', () => { const { collectors } = makeFixture(); const dbg = collectors.readLog({ name: 'debug', tailKb: 64 });