perf(history): gate the unconditional History-tab reload + fix the diagnostics history regression (v3.3.101)

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) <noreply@anthropic.com>
This commit is contained in:
Administrator 2026-06-21 19:45:50 +02:00
parent 003e14dfe9
commit bad1c665f5
6 changed files with 74 additions and 6 deletions

View File

@ -11,7 +11,7 @@ const READABLE_LOGS = {
const QUEUE_STATUSES = ['preview', 'queued', 'getting-server', 'uploading', 'retrying', 'done', 'error', 'aborted', 'skipped']; const QUEUE_STATUSES = ['preview', 'queued', 'getting-server', 'uploading', 'retrying', 'done', 'error', 'aborted', 'skipped'];
function createCollectors(deps) { function createCollectors(deps) {
const { loadConfig, getAllLogPaths, support, stats, appInfo, systemInfo, agentInfo } = deps; const { loadConfig, loadHistory, getAllLogPaths, support, stats, appInfo, systemInfo, agentInfo } = deps;
function _secrets() { function _secrets() {
try { return support.collectSecretValues(loadConfig()); } catch { return []; } try { return support.collectSecretValues(loadConfig()); } catch { return []; }
@ -205,8 +205,9 @@ function createCollectors(deps) {
function getHistory(args) { function getHistory(args) {
const a = args || {}; const a = args || {};
const cfg = loadConfig(); const history = typeof loadHistory === 'function'
const history = Array.isArray(cfg.history) ? cfg.history : []; ? (loadHistory() || [])
: (Array.isArray(loadConfig().history) ? loadConfig().history : []);
const limit = Math.min(Math.max(Number(a.limit) || 20, 1), 200); const limit = Math.min(Math.max(Number(a.limit) || 20, 1), 200);
const perHoster = stats.summarizePerHoster(history); const perHoster = stats.summarizePerHoster(history);
const recent = [...history].slice(-limit).reverse(); const recent = [...history].slice(-limit).reverse();

View File

@ -2617,6 +2617,7 @@ function _diagAgentInfo() {
function _buildDiagnosticHandler() { function _buildDiagnosticHandler() {
const collectors = createCollectors({ const collectors = createCollectors({
loadConfig: () => configStore.load(), loadConfig: () => configStore.load(),
loadHistory: () => configStore.loadHistory(),
getAllLogPaths, getAllLogPaths,
support: { sanitizeConfig, collectSecretValues, redactLogText, valueScrub, collectFile, REDACTED }, support: { sanitizeConfig, collectSecretValues, redactLogText, valueScrub, collectFile, REDACTED },
stats, stats,

View File

@ -1,6 +1,6 @@
{ {
"name": "multi-hoster-uploader", "name": "multi-hoster-uploader",
"version": "3.3.100", "version": "3.3.101",
"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

@ -332,6 +332,7 @@ async function init() {
// --- Tab switching --- // --- Tab switching ---
let _historyDirty = false; let _historyDirty = false;
let _historyEverLoaded = false;
function _isHistoryTabActive() { function _isHistoryTabActive() {
const tab = document.querySelector('.tab.active'); const tab = document.querySelector('.tab.active');
return !!(tab && tab.dataset.view === 'history'); return !!(tab && tab.dataset.view === 'history');
@ -359,8 +360,7 @@ function _isHistoryTabActive() {
const nextView = viewsById[`${tab.dataset.view}-view`]; const nextView = viewsById[`${tab.dataset.view}-view`];
if (nextView) nextView.classList.add('active'); if (nextView) nextView.classList.add('active');
activeTab = tab; activeTab = tab;
if (tab.dataset.view === 'history') { if (tab.dataset.view === 'history' && (_historyDirty || !_historyEverLoaded)) {
_historyDirty = false;
loadHistory(); loadHistory();
} }
}; };
@ -4630,6 +4630,8 @@ function _hideOtpField() {
async function loadHistory() { async function loadHistory() {
const history = await window.api.getHistory(); const history = await window.api.getHistory();
window._historyForStats = history || []; window._historyForStats = history || [];
_historyEverLoaded = true;
_historyDirty = false;
_invalidateHosterLifetimeCache(); _invalidateHosterLifetimeCache();
const retSel = document.getElementById('historyRetentionSelect'); const retSel = document.getElementById('historyRetentionSelect');
if (retSel) retSel.value = (config.globalSettings && config.globalSettings.historyRetention) || 'all'; if (retSel) retSel.value = (config.globalSettings && config.globalSettings.historyRetention) || 'all';

View File

@ -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`
(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) # 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 User asked "haben wir wirklich ALLES gemessen, auch switches/wechsel?". Audit: main-side was already

View File

@ -53,6 +53,32 @@ test('getConfigRedacted strips password/apiKey/token/webhookUrl and value-scrubs
assert.ok(!json.includes('WBHOOKSECRETTOKEN'), 'webhook secret must be redacted'); 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', () => { test('readLog redacts a planted token and a Bearer line; doodstream is NOT readable; unknown name rejected', () => {
const { collectors } = makeFixture(); const { collectors } = makeFixture();
const dbg = collectors.readLog({ name: 'debug', tailKb: 64 }); const dbg = collectors.readLog({ name: 'debug', tailKb: 64 });