Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c58d9203bc | ||
|
|
f0006f8003 | ||
|
|
66ae240794 | ||
|
|
8fed8669f3 | ||
|
|
7a40afbe7e | ||
|
|
335f365497 | ||
|
|
7f636258d4 | ||
|
|
d5bb97aefe | ||
|
|
bad1c665f5 | ||
|
|
003e14dfe9 | ||
|
|
c3381d360f | ||
|
|
121eac5f14 | ||
|
|
a5b835f76a | ||
|
|
59dbd4b41c | ||
|
|
54dbba223d |
3
.gitignore
vendored
3
.gitignore
vendored
@ -6,7 +6,10 @@ __pycache__/
|
||||
electron-config.json
|
||||
electron-config.json.bak
|
||||
electron-config.json.tmp
|
||||
electron-config.json.pre-history-split.bak
|
||||
electron-config.pre-import-*.json
|
||||
electron-history.json
|
||||
electron-history.json.tmp
|
||||
*.log
|
||||
debug.log
|
||||
fileuploader.log
|
||||
|
||||
@ -105,7 +105,7 @@ class ClouddropUploader {
|
||||
let bytesRead = 0;
|
||||
async function* generate() {
|
||||
yield preambleBuf;
|
||||
const fileStream = fs.createReadStream(filePath, { highWaterMark: 256 * 1024 });
|
||||
const fileStream = fs.createReadStream(filePath, { highWaterMark: 1024 * 1024 });
|
||||
for await (const chunk of fileStream) {
|
||||
if (signal && signal.aborted) throw new Error('Aborted');
|
||||
if (throttle) await throttle.consume(chunk.length, signal);
|
||||
|
||||
@ -184,14 +184,87 @@ class ConfigStore {
|
||||
? app.getPath('userData')
|
||||
: path.join(__dirname, '..');
|
||||
this.filePath = path.join(dir, 'electron-config.json');
|
||||
this.historyPath = path.join(dir, 'electron-history.json');
|
||||
this._writeQueue = Promise.resolve(); // Serializes all writes to prevent race conditions
|
||||
this._historyWriteQueue = Promise.resolve();
|
||||
this._historyMigrated = false;
|
||||
this._cache = null;
|
||||
this._cacheKey = '';
|
||||
this._perfLog = null;
|
||||
this._wqDepth = 0;
|
||||
|
||||
// Migrate config from old location if current doesn't exist
|
||||
if (!fs.existsSync(this.filePath) && app && app.isPackaged) {
|
||||
this._migrateFromOldPath(app);
|
||||
}
|
||||
if (app && app.isPackaged) {
|
||||
this._migrateHistory();
|
||||
}
|
||||
}
|
||||
|
||||
_readHistoryFile() {
|
||||
try {
|
||||
const raw = fs.readFileSync(this.historyPath, 'utf-8');
|
||||
if (!raw || raw.trim().length < 2) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
if (parsed && Array.isArray(parsed.history)) return parsed.history;
|
||||
return [];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
_writeHistoryFileDurable(arr) {
|
||||
const tmp = this.historyPath + '.tmp';
|
||||
const fd = fs.openSync(tmp, 'w');
|
||||
try {
|
||||
fs.writeSync(fd, JSON.stringify(arr));
|
||||
fs.fsyncSync(fd);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
fs.renameSync(tmp, this.historyPath);
|
||||
}
|
||||
|
||||
_writeHistoryFileAtomic(arr) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tmp = this.historyPath + '.tmp';
|
||||
fs.writeFile(tmp, JSON.stringify(arr), 'utf-8', (err) => {
|
||||
if (err) return reject(err);
|
||||
try { fs.renameSync(tmp, this.historyPath); } catch (e) { return reject(e); }
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_enqueueHistoryWrite(fn) {
|
||||
this._historyWriteQueue = this._historyWriteQueue.then(fn, fn);
|
||||
return this._historyWriteQueue;
|
||||
}
|
||||
|
||||
_migrateHistory() {
|
||||
try {
|
||||
if (fs.existsSync(this.historyPath)) {
|
||||
this._historyMigrated = Array.isArray(this._readHistoryFile());
|
||||
return;
|
||||
}
|
||||
let cfg = null;
|
||||
try { cfg = this._readAndParse(this.filePath); } catch {}
|
||||
const hist = (cfg && Array.isArray(cfg.history)) ? cfg.history : [];
|
||||
this._writeHistoryFileDurable(hist);
|
||||
const check = this._readHistoryFile();
|
||||
if (Array.isArray(check) && check.length === hist.length) {
|
||||
if (hist.length > 0) {
|
||||
try { fs.copyFileSync(this.filePath, this.filePath + '.pre-history-split.bak'); } catch {}
|
||||
}
|
||||
this._historyMigrated = true;
|
||||
} else {
|
||||
this._historyMigrated = false;
|
||||
}
|
||||
} catch {
|
||||
this._historyMigrated = false;
|
||||
}
|
||||
}
|
||||
|
||||
_migrateFromOldPath(app) {
|
||||
@ -228,7 +301,40 @@ class ConfigStore {
|
||||
catch { return JSON.parse(JSON.stringify(obj)); }
|
||||
}
|
||||
|
||||
setPerfLog(fn) { this._perfLog = typeof fn === 'function' ? fn : null; }
|
||||
|
||||
_pqLen(globalSettings) {
|
||||
const pq = globalSettings && globalSettings.pendingQueue;
|
||||
return pq && Array.isArray(pq.queueJobs) ? pq.queueJobs.length : 0;
|
||||
}
|
||||
|
||||
_callerTag() {
|
||||
const lines = (new Error().stack || '').split('\n');
|
||||
const out = [];
|
||||
for (let i = 2; i < lines.length && out.length < 3; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (/config-store\.js/.test(line)) continue;
|
||||
const m = line.match(/at (?:async )?([^ (]+)/);
|
||||
if (m) out.push(m[1].split('.').pop());
|
||||
}
|
||||
return out.join('<') || '?';
|
||||
}
|
||||
|
||||
load() {
|
||||
if (!this._perfLog) return this._loadImpl();
|
||||
const hadCache = !!this._cache;
|
||||
const t0 = performance.now();
|
||||
const r = this._loadImpl();
|
||||
const dt = performance.now() - t0;
|
||||
if (dt >= 20) {
|
||||
const q = this._pqLen(r && r.globalSettings);
|
||||
const h = (r && r.history || []).length;
|
||||
this._perfLog(`config-load wall=${dt.toFixed(0)}ms cache=${hadCache ? 'hit' : 'miss'} hist=${h} queue=${q} via=${this._callerTag()}`);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
_loadImpl() {
|
||||
try {
|
||||
// In-memory cache keyed on the file's mtime+size. The processed config
|
||||
// (merged + credential-decrypted) is reparsed/re-decrypted from disk ONLY
|
||||
@ -251,8 +357,10 @@ class ConfigStore {
|
||||
try { data = this._readAndParse(this.filePath); } catch {}
|
||||
// Fallback to backup if main is empty/corrupt
|
||||
if (!data) {
|
||||
const backupPath = this.filePath + '.bak';
|
||||
try { data = this._readAndParse(backupPath); } catch {}
|
||||
try { data = this._readAndParse(this.filePath + '.bak'); } catch {}
|
||||
}
|
||||
if (!data) {
|
||||
try { data = this._readAndParse(this.filePath + '.pre-history-split.bak'); } catch {}
|
||||
}
|
||||
if (!data) {
|
||||
const fresh = JSON.parse(JSON.stringify(DEFAULTS));
|
||||
@ -328,7 +436,7 @@ class ConfigStore {
|
||||
const rotationCursors = (data.rotationCursors && typeof data.rotationCursors === 'object' && !Array.isArray(data.rotationCursors))
|
||||
? data.rotationCursors
|
||||
: {};
|
||||
const result = { hosters, hosterSettings, globalSettings, history: data.history || [], rotationCursors };
|
||||
const result = { hosters, hosterSettings, globalSettings, history: this._historyMigrated ? [] : (data.history || []), rotationCursors };
|
||||
// Decrypt credentials stored with safeStorage so the rest of the app
|
||||
// keeps working with plaintext in memory.
|
||||
secretStore.decryptCredentials(result);
|
||||
@ -356,25 +464,68 @@ class ConfigStore {
|
||||
}
|
||||
|
||||
_commit(config) {
|
||||
return this._atomicWrite(this._serializeForDisk(config));
|
||||
if (!this._perfLog) return this._atomicWrite(this._serializeForDisk(config));
|
||||
const t0 = performance.now();
|
||||
const data = this._serializeForDisk(config);
|
||||
const dt = performance.now() - t0;
|
||||
if (dt >= 20) {
|
||||
const q = this._pqLen(config.globalSettings);
|
||||
const h = (config.history || []).length;
|
||||
this._perfLog(`config-serialize wall=${dt.toFixed(0)}ms bytes=${data.length} hist=${h} queue=${q} wqDepth=${this._wqDepth} via=${this._callerTag()}`);
|
||||
}
|
||||
return this._atomicWrite(data);
|
||||
}
|
||||
|
||||
_enqueueWrite(fn) {
|
||||
this._writeQueue = this._writeQueue.then(fn, fn);
|
||||
this._wqDepth++;
|
||||
const done = () => { this._wqDepth--; };
|
||||
this._writeQueue = this._writeQueue.then(fn, fn).then(done, done);
|
||||
return this._writeQueue;
|
||||
}
|
||||
|
||||
_anyHosters(cfg) {
|
||||
const h = cfg && cfg.hosters;
|
||||
return !!h && typeof h === 'object' && Object.values(h).some(a => Array.isArray(a) && a.length > 0);
|
||||
}
|
||||
|
||||
_recoverHostersFromDisk() {
|
||||
for (const p of [this.filePath, this.filePath + '.bak', this.filePath + '.pre-history-split.bak']) {
|
||||
try {
|
||||
const raw = fs.readFileSync(p, 'utf-8');
|
||||
if (!raw || raw.trim().length < 2) continue;
|
||||
const data = JSON.parse(raw);
|
||||
if (this._anyHosters(data)) return data.hosters;
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_guardHosters(current, hostersIntentional) {
|
||||
if (!hostersIntentional && !this._anyHosters(current)) {
|
||||
const recovered = this._recoverHostersFromDisk();
|
||||
if (recovered) {
|
||||
current.hosters = recovered;
|
||||
if (this._perfLog) this._perfLog('config-guard: prevented account wipe — restored hosters from on-disk backup after a corrupt/empty read');
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
save(config) {
|
||||
return this._enqueueWrite(() => {
|
||||
const current = this.load();
|
||||
if (config.hosters) current.hosters = config.hosters;
|
||||
if (config.hosterSettings) current.hosterSettings = config.hosterSettings;
|
||||
if (config.globalSettings) current.globalSettings = config.globalSettings;
|
||||
this._guardHosters(current, !!config.hosters);
|
||||
return this._commit(current);
|
||||
});
|
||||
}
|
||||
|
||||
loadHistory() {
|
||||
if (this._historyMigrated) {
|
||||
return this._readHistoryFile() || [];
|
||||
}
|
||||
const config = this.load();
|
||||
return config.history || [];
|
||||
}
|
||||
@ -383,18 +534,22 @@ class ConfigStore {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tmpPath = this.filePath + '.tmp';
|
||||
const backupPath = this.filePath + '.bak';
|
||||
fs.writeFile(tmpPath, data, 'utf-8', (err) => {
|
||||
if (err) return reject(err);
|
||||
let fd;
|
||||
try {
|
||||
fd = fs.openSync(tmpPath, 'w');
|
||||
fs.writeSync(fd, data);
|
||||
fs.fsyncSync(fd);
|
||||
} catch (e) {
|
||||
try { if (fd !== undefined) fs.closeSync(fd); } catch {}
|
||||
return reject(e);
|
||||
}
|
||||
try { fs.closeSync(fd); } catch {}
|
||||
Promise.resolve().then(() => {
|
||||
try {
|
||||
// Refresh .bak from the previous live file with a raw byte copy —
|
||||
// no read+JSON.parse+write. The live file was itself written through
|
||||
// this atomic path, so re-validating it by parsing the whole (growing)
|
||||
// config on every write was pure waste. Wrapped in try/catch so an
|
||||
// AV/indexer briefly locking the file doesn't fail the save — the
|
||||
// rename to the live path is the part that matters.
|
||||
try {
|
||||
if (fs.existsSync(this.filePath)) {
|
||||
fs.copyFileSync(this.filePath, backupPath);
|
||||
const cur = fs.readFileSync(this.filePath, 'utf-8');
|
||||
if (cur && cur.trim().length > 2) fs.writeFileSync(backupPath, cur, 'utf-8');
|
||||
}
|
||||
} catch {}
|
||||
fs.renameSync(tmpPath, this.filePath);
|
||||
@ -410,6 +565,18 @@ class ConfigStore {
|
||||
}
|
||||
|
||||
appendHistory(entry) {
|
||||
if (this._historyMigrated) {
|
||||
return this._enqueueHistoryWrite(() => {
|
||||
const cur = this._readHistoryFile();
|
||||
if (cur === null && fs.existsSync(this.historyPath)) return;
|
||||
const arr = cur || [];
|
||||
arr.push(entry);
|
||||
const gs = this.load().globalSettings;
|
||||
const retention = (gs && gs.historyRetention) || 'all';
|
||||
const pruned = applyHistoryRetention(arr, retention, Date.now());
|
||||
return this._writeHistoryFileAtomic(pruned);
|
||||
});
|
||||
}
|
||||
return this._enqueueWrite(() => {
|
||||
const config = this.load();
|
||||
config.history.push(entry);
|
||||
@ -421,6 +588,24 @@ class ConfigStore {
|
||||
|
||||
pruneHistory(retention, opts = {}) {
|
||||
const dryRun = !!opts.dryRun;
|
||||
if (this._historyMigrated) {
|
||||
return this._enqueueHistoryWrite(() => {
|
||||
const current = this._readHistoryFile() || [];
|
||||
const beforeBatches = current.length;
|
||||
const beforeRows = countHistoryRows(current);
|
||||
const pruned = applyHistoryRetention(current, retention, Date.now());
|
||||
const result = {
|
||||
removedBatches: beforeBatches - pruned.length,
|
||||
removedRows: beforeRows - countHistoryRows(pruned),
|
||||
keptBatches: pruned.length,
|
||||
keptRows: countHistoryRows(pruned)
|
||||
};
|
||||
if (dryRun) return result;
|
||||
return this._writeHistoryFileAtomic(pruned)
|
||||
.then(() => this.save({ globalSettings: { ...this.load().globalSettings, historyRetention: String(retention || 'all') } }))
|
||||
.then(() => result);
|
||||
});
|
||||
}
|
||||
return this._enqueueWrite(() => {
|
||||
const config = this.load();
|
||||
const beforeBatches = config.history.length;
|
||||
@ -440,6 +625,9 @@ class ConfigStore {
|
||||
}
|
||||
|
||||
clearHistory() {
|
||||
if (this._historyMigrated) {
|
||||
return this._enqueueHistoryWrite(() => this._writeHistoryFileAtomic([]));
|
||||
}
|
||||
return this._enqueueWrite(() => {
|
||||
const config = this.load();
|
||||
config.history = [];
|
||||
@ -451,6 +639,7 @@ class ConfigStore {
|
||||
return this._enqueueWrite(() => {
|
||||
const config = this.load();
|
||||
config.rotationCursors = (cursors && typeof cursors === 'object' && !Array.isArray(cursors)) ? cursors : {};
|
||||
this._guardHosters(config, false);
|
||||
return this._commit(config);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -339,7 +339,7 @@ class DoodstreamUploader {
|
||||
const epilogueBuf = Buffer.from(epilogue, 'utf-8');
|
||||
const totalSize = preambleBuf.length + fileSize + epilogueBuf.length;
|
||||
|
||||
const CHUNK_SIZE = 256 * 1024;
|
||||
const CHUNK_SIZE = 1024 * 1024;
|
||||
let bytesRead = 0;
|
||||
|
||||
async function* generate() {
|
||||
|
||||
@ -288,7 +288,7 @@ function createUploadBody(filePath, formFields, onProgress, throttle, signal) {
|
||||
const { boundary, preambleBuf, epilogueBuf, totalSize, fileSize } = buildMultipart(filePath, formFields);
|
||||
|
||||
let bytesRead = 0;
|
||||
const CHUNK_SIZE = 256 * 1024;
|
||||
const CHUNK_SIZE = 1024 * 1024;
|
||||
|
||||
async function* generate() {
|
||||
yield preambleBuf;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// Log-file mode resolution for fileuploader.log:
|
||||
// - "single" → one file: fileuploader.log
|
||||
// - "daily" → per-day: fileuploader-YYYY-MM-DD.log
|
||||
// - "session" → per-launch: fileuploader-session-YYYY-MM-DD_HH-MM-SS-<pid>.log
|
||||
// - "session" → per-launch: DD-MM-YYYY-mdu-session-HH-MM-NNNNNN.log
|
||||
//
|
||||
// Pure functions only — no fs, no Date.now() at call time — so they unit-test
|
||||
// cleanly and the main.js call sites pass in `new Date()` + the session stamp.
|
||||
@ -38,13 +38,11 @@
|
||||
return `${date.getFullYear()}-${_two(date.getMonth() + 1)}-${_two(date.getDate())}`;
|
||||
}
|
||||
|
||||
function formatSessionStamp(date, pid) {
|
||||
const d = `${date.getFullYear()}-${_two(date.getMonth() + 1)}-${_two(date.getDate())}`;
|
||||
const t = `${_two(date.getHours())}-${_two(date.getMinutes())}-${_two(date.getSeconds())}`;
|
||||
// PID disambiguates a same-second close→reopen — a human can't but two
|
||||
// automated runs might. Cheap belt to a suspenders-not-required problem.
|
||||
const pidStr = pid !== undefined && pid !== null ? `-${pid}` : '';
|
||||
return `${d}_${t}${pidStr}`;
|
||||
function formatSessionStamp(date, rand) {
|
||||
const d = `${_two(date.getDate())}-${_two(date.getMonth() + 1)}-${date.getFullYear()}`;
|
||||
const t = `${_two(date.getHours())}-${_two(date.getMinutes())}`;
|
||||
const r = (rand !== undefined && rand !== null && String(rand).trim()) ? `-${String(rand).trim()}` : '';
|
||||
return `${d}-mdu-session-${t}${r}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -67,9 +65,10 @@
|
||||
const date = a.date instanceof Date ? a.date : new Date();
|
||||
return `${base}-${formatDateStamp(date)}${ext}`;
|
||||
}
|
||||
// session
|
||||
// session — the stamp is the full app-defined stem (DD-MM-YYYY-mdu-session-HH-MM),
|
||||
// independent of baseName.
|
||||
const sid = a.sessionId && String(a.sessionId).trim();
|
||||
if (sid) return `${base}-session-${sid}${ext}`;
|
||||
if (sid) return `${sid}${ext}`;
|
||||
// Defensive: if a session-id wasn't passed, fall back to single rather
|
||||
// than emit a malformed name. main.js always supplies one.
|
||||
return `${base}${ext}`;
|
||||
@ -85,6 +84,9 @@
|
||||
*/
|
||||
function stripModeStampFromFileName(fileName) {
|
||||
if (!fileName || typeof fileName !== 'string') return fileName;
|
||||
const newSessionRe = /^\d{2}-\d{2}-\d{4}-mdu-session-\d{2}-\d{2}(?:-\d+)?(\.[^.]+)?$/;
|
||||
const mNew = fileName.match(newSessionRe);
|
||||
if (mNew) return `fileuploader${mNew[1] || ''}`;
|
||||
// Order matters: session first (longer, more specific) before daily.
|
||||
// Both regexes are anchored to $ with no nested/ambiguous quantifiers, so
|
||||
// matching is linear — the eslint security warning is precautionary.
|
||||
|
||||
@ -364,16 +364,17 @@ class UploadManager extends EventEmitter {
|
||||
for (let i = 0; i < tasks.length; i += DEDUP_CHUNK) {
|
||||
if (signal.aborted) break;
|
||||
const end = Math.min(i + DEDUP_CHUNK, tasks.length);
|
||||
const toStat = [];
|
||||
for (let j = i; j < end; j++) {
|
||||
const task = tasks[j];
|
||||
if (!results.has(task.file)) {
|
||||
const fileName = path.basename(task.file);
|
||||
let size = 0;
|
||||
try { size = fs.statSync(task.file).size; } catch {}
|
||||
results.set(task.file, { name: fileName, size, results: [] });
|
||||
results.set(task.file, { name: path.basename(task.file), size: 0, results: [] });
|
||||
toStat.push(task.file);
|
||||
}
|
||||
}
|
||||
if (end < tasks.length) await new Promise(setImmediate);
|
||||
await Promise.all(toStat.map(async (f) => {
|
||||
try { const st = await fs.promises.stat(f); const e = results.get(f); if (e) e.size = st.size; } catch {}
|
||||
}));
|
||||
}
|
||||
|
||||
this._startStatsTimer();
|
||||
@ -425,7 +426,7 @@ class UploadManager extends EventEmitter {
|
||||
if (cachedResult && typeof cachedResult.size === 'number' && cachedResult.size > 0) {
|
||||
fileSize = cachedResult.size;
|
||||
} else {
|
||||
try { fileSize = fs.statSync(task.file).size; } catch { fileNotFound = true; }
|
||||
try { fileSize = (await fs.promises.stat(task.file)).size; } catch { fileNotFound = true; }
|
||||
}
|
||||
|
||||
const maxAttempts = Math.max(1, (settings.retries || 0) + 1);
|
||||
|
||||
@ -187,7 +187,7 @@ class VidmolyUploader {
|
||||
const totalSize = preambleBuf.length + fileSize + epilogueBuf.length;
|
||||
|
||||
let bytesRead = 0;
|
||||
const CHUNK_SIZE = 256 * 1024;
|
||||
const CHUNK_SIZE = 1024 * 1024;
|
||||
|
||||
async function* generate() {
|
||||
yield preambleBuf;
|
||||
|
||||
@ -242,7 +242,7 @@ class VoeUploader {
|
||||
const totalSize = preambleBuf.length + fileSize + epilogueBuf.length;
|
||||
|
||||
let bytesRead = 0;
|
||||
const CHUNK_SIZE = 256 * 1024;
|
||||
const CHUNK_SIZE = 1024 * 1024;
|
||||
|
||||
async function* generate() {
|
||||
yield preambleBuf;
|
||||
|
||||
144
main.js
144
main.js
@ -1,6 +1,6 @@
|
||||
process.env.UV_THREADPOOL_SIZE = process.env.UV_THREADPOOL_SIZE || '64';
|
||||
const { monitorEventLoopDelay } = require('perf_hooks');
|
||||
const { app, BrowserWindow, ipcMain, dialog, clipboard, nativeTheme, Tray, Menu } = require('electron');
|
||||
process.env.UV_THREADPOOL_SIZE = process.env.UV_THREADPOOL_SIZE || '8';
|
||||
const { monitorEventLoopDelay, PerformanceObserver } = require('perf_hooks');
|
||||
const { app, BrowserWindow, ipcMain, dialog, clipboard, nativeTheme, Tray, Menu, nativeImage } = require('electron');
|
||||
nativeTheme.themeSource = 'dark';
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
@ -27,17 +27,82 @@ const stats = require('./lib/stats');
|
||||
const { createCollectors } = require('./lib/diagnostics-collectors');
|
||||
const { createAgent } = require('./lib/diagnostics-agent');
|
||||
|
||||
function _gpuDisableFlagPath() {
|
||||
try { return path.join(app.getPath('userData'), 'gpu-disabled.flag'); } catch { return null; }
|
||||
}
|
||||
(function maybeDisableHardwareAcceleration() {
|
||||
let disable = false;
|
||||
try { if (/^RDP/i.test(process.env.SESSIONNAME || '')) disable = true; } catch {}
|
||||
if (!disable) { try { const f = _gpuDisableFlagPath(); if (f && fs.existsSync(f)) disable = true; } catch {} }
|
||||
if (disable) { try { app.disableHardwareAcceleration(); } catch {} }
|
||||
})();
|
||||
|
||||
const _eventLoopDelay = monitorEventLoopDelay({ resolution: 10 });
|
||||
_eventLoopDelay.enable();
|
||||
let _eldLastLog = 0;
|
||||
let _lastCpu = process.cpuUsage();
|
||||
let _lastCpuT = Date.now();
|
||||
let _gcCount = 0;
|
||||
let _gcTotalMs = 0;
|
||||
let _gcMaxMs = 0;
|
||||
try {
|
||||
const _gcObserver = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
_gcCount++;
|
||||
_gcTotalMs += entry.duration;
|
||||
if (entry.duration > _gcMaxMs) _gcMaxMs = entry.duration;
|
||||
}
|
||||
});
|
||||
_gcObserver.observe({ entryTypes: ['gc'] });
|
||||
} catch {}
|
||||
|
||||
const _perfOn = process.env.MHU_PERF !== '0';
|
||||
let _lastIpcChannel = '';
|
||||
if (_perfOn) {
|
||||
let _driftTick = Date.now();
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
const drift = now - _driftTick - 100;
|
||||
_driftTick = now;
|
||||
if (drift >= 100) {
|
||||
try { logInfo(`main-longtask blocked=${drift}ms lastIpc=${_lastIpcChannel || '-'} gc=${_gcCount} gcMax=${_gcMaxMs.toFixed(0)}ms`); } catch {}
|
||||
}
|
||||
}, 100).unref();
|
||||
|
||||
const IPC_SLOW_MS = 50;
|
||||
const _ipcLog = (m) => { try { logInfo(m); } catch {} };
|
||||
const _rawHandle = ipcMain.handle.bind(ipcMain);
|
||||
ipcMain.handle = (channel, fn) => _rawHandle(channel, function (evt, ...args) {
|
||||
_lastIpcChannel = channel;
|
||||
const t0 = performance.now();
|
||||
let p;
|
||||
try { p = fn.call(this, evt, ...args); }
|
||||
catch (e) { _ipcLog(`ipc ${channel} sync-throw wall=${(performance.now() - t0).toFixed(0)}ms`); throw e; }
|
||||
const sync = performance.now() - t0;
|
||||
if (p && typeof p.then === 'function') {
|
||||
return Promise.resolve(p).finally(() => {
|
||||
const total = performance.now() - t0;
|
||||
if (total >= IPC_SLOW_MS) _ipcLog(`ipc ${channel} wall=${total.toFixed(0)}ms sync=${sync.toFixed(0)}ms`);
|
||||
});
|
||||
}
|
||||
if (sync >= IPC_SLOW_MS) _ipcLog(`ipc ${channel} wall=${sync.toFixed(0)}ms sync`);
|
||||
return p;
|
||||
});
|
||||
const _rawOn = ipcMain.on.bind(ipcMain);
|
||||
ipcMain.on = (channel, fn) => _rawOn(channel, function (evt, ...args) {
|
||||
_lastIpcChannel = channel;
|
||||
const t0 = performance.now();
|
||||
try { return fn.call(this, evt, ...args); }
|
||||
finally { const dt = performance.now() - t0; if (dt >= IPC_SLOW_MS) _ipcLog(`ipc ${channel} wall=${dt.toFixed(0)}ms sync-on`); }
|
||||
});
|
||||
}
|
||||
|
||||
let mainWindow;
|
||||
let _lastImportPath = null;
|
||||
let dropTargetWindow = null;
|
||||
let tray = null;
|
||||
const configStore = new ConfigStore(app);
|
||||
configStore.setPerfLog((m) => { try { logInfo(m); } catch {} });
|
||||
let uploadManager = null;
|
||||
let diagnosticAgent = null;
|
||||
let _diagHandler = null;
|
||||
@ -212,11 +277,20 @@ function _maybeLogEventLoopDelay(activeJobs) {
|
||||
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`;
|
||||
const mem = process.memoryUsage();
|
||||
const rss = Math.round(mem.rss / 1048576);
|
||||
const heap = Math.round(mem.heapUsed / 1048576);
|
||||
const ext = Math.round(mem.external / 1048576);
|
||||
const ab = Math.round((mem.arrayBuffers || 0) / 1048576);
|
||||
cpuStr = ` cpu=${pct}%core rss=${rss}MB heap=${heap}MB ext=${ext}MB ab=${ab}MB`;
|
||||
_lastCpu = process.cpuUsage();
|
||||
_lastCpuT = now;
|
||||
} catch {}
|
||||
let gcStr = '';
|
||||
try {
|
||||
gcStr = ` gc=${_gcCount} gcTotal=${_gcTotalMs.toFixed(0)}ms gcMax=${_gcMaxMs.toFixed(0)}ms`;
|
||||
_gcCount = 0; _gcTotalMs = 0; _gcMaxMs = 0;
|
||||
} catch {}
|
||||
let upStr = '';
|
||||
try {
|
||||
if (uploadManager && typeof uploadManager.getDiagnostics === 'function') {
|
||||
@ -225,7 +299,7 @@ function _maybeLogEventLoopDelay(activeJobs) {
|
||||
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}`);
|
||||
logInfo(`eventloop-delay active=${activeJobs} mean=${mean}ms p99=${p99}ms max=${max}ms stddev=${stddev}ms threadpool=${process.env.UV_THREADPOOL_SIZE}${cpuStr}${gcStr}${resStr}${upStr}`);
|
||||
_eventLoopDelay.reset();
|
||||
} catch {}
|
||||
}
|
||||
@ -489,10 +563,10 @@ function getBaseLogFilePath() {
|
||||
// Log-mode bookkeeping. Three modes (see lib/log-mode.js): single, daily, session.
|
||||
// The session-id is stamped ONCE at main-process startup so every write of a
|
||||
// given session lands in the same file. A close→reopen of the app starts a new
|
||||
// main process, so a new SESSION_ID, so a new session file. PID is appended as
|
||||
// a cheap hedge against same-second restart collisions.
|
||||
// main process, so a new SESSION_ID, so a new session file. A 6-digit random is
|
||||
// appended as a cheap hedge against same-minute restart collisions.
|
||||
const { resolveLogFileName, formatSessionStamp, formatDateStamp, stripModeStampFromFileName } = require('./lib/log-mode');
|
||||
const SESSION_ID = formatSessionStamp(new Date(), process.pid);
|
||||
const SESSION_ID = formatSessionStamp(new Date(), String(Math.floor(100000 + Math.random() * 900000)));
|
||||
let _activeLogKey = null; // remembers (mode + date-or-session) so cache rolls correctly
|
||||
let _activeLogPath = null;
|
||||
|
||||
@ -1214,26 +1288,45 @@ function createWindow() {
|
||||
app.on('child-process-gone', (_event, details) => {
|
||||
_writeCrashLog('CHILD PROCESS GONE', new Error(details.reason || 'unknown'), details);
|
||||
debugLog(`CHILD PROCESS GONE: type=${details.type} reason=${details.reason} exitCode=${details.exitCode}`);
|
||||
if (details && details.type === 'GPU') {
|
||||
try { const f = _gpuDisableFlagPath(); if (f) fs.writeFileSync(f, new Date().toISOString(), 'utf-8'); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html'));
|
||||
}
|
||||
|
||||
function createTray() {
|
||||
const iconPath = path.join(__dirname, 'assets', 'app_icon.ico');
|
||||
tray = new Tray(iconPath);
|
||||
tray.setToolTip('Multi-Hoster-Upload');
|
||||
try {
|
||||
const candidates = [
|
||||
path.join(process.resourcesPath || __dirname, 'assets', 'app_icon.ico'),
|
||||
path.join(__dirname, 'assets', 'app_icon.ico'),
|
||||
path.join(__dirname, 'assets', 'icon.png')
|
||||
];
|
||||
let icon = null;
|
||||
for (const p of candidates) {
|
||||
try {
|
||||
const img = nativeImage.createFromPath(p);
|
||||
if (img && !img.isEmpty()) { icon = img; break; }
|
||||
} catch {}
|
||||
}
|
||||
tray = new Tray(icon || nativeImage.createEmpty());
|
||||
tray.setToolTip('Multi-Hoster-Upload');
|
||||
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{ label: 'Öffnen', click: () => { if (mainWindow) { mainWindow.show(); mainWindow.focus(); } } },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Beenden', click: () => { app.quit(); } }
|
||||
]);
|
||||
tray.setContextMenu(contextMenu);
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{ label: 'Öffnen', click: () => { if (mainWindow) { mainWindow.show(); mainWindow.focus(); } } },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Beenden', click: () => { app.quit(); } }
|
||||
]);
|
||||
tray.setContextMenu(contextMenu);
|
||||
|
||||
tray.on('click', () => {
|
||||
if (mainWindow) { mainWindow.show(); mainWindow.focus(); }
|
||||
});
|
||||
tray.on('click', () => {
|
||||
if (mainWindow) { mainWindow.show(); mainWindow.focus(); }
|
||||
});
|
||||
} catch (err) {
|
||||
tray = null;
|
||||
debugLog(`createTray failed (non-fatal): ${err && err.message ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
|
||||
function updateTrayTooltip(text) {
|
||||
@ -2123,9 +2216,11 @@ ipcMain.handle('clear-history', async () => {
|
||||
|
||||
// --- Backup export / import ---
|
||||
ipcMain.handle('export-backup', async () => {
|
||||
const _bd = new Date();
|
||||
const _bdate = `${String(_bd.getDate()).padStart(2, '0')}-${String(_bd.getMonth() + 1).padStart(2, '0')}-${_bd.getFullYear()}`;
|
||||
const { canceled, filePath } = await dialog.showSaveDialog(mainWindow, {
|
||||
title: 'Backup exportieren',
|
||||
defaultPath: `multi-hoster-backup-${new Date().toISOString().slice(0, 10)}.mhu`,
|
||||
defaultPath: `${_bdate}-multihoster-backup.mhu`,
|
||||
filters: [
|
||||
{ name: 'Multi-Hoster Backup (verschlüsselt)', extensions: ['mhu'] },
|
||||
{ name: 'Multi-Hoster Backup (Klartext JSON)', extensions: ['json'] }
|
||||
@ -2396,10 +2491,12 @@ ipcMain.on('save-global-settings-sync', (event, globalSettings) => {
|
||||
const _diskDiag = current.globalSettings && current.globalSettings.diagnostics;
|
||||
current.globalSettings = globalSettings;
|
||||
if (_diskDiag) current.globalSettings.diagnostics = _diskDiag;
|
||||
try { configStore._guardHosters(current, false); } catch {}
|
||||
_invalidateLogSettings();
|
||||
const data = configStore._serializeForDisk(current);
|
||||
const backupPath = configStore.filePath + '.bak';
|
||||
fs.writeFileSync(tmpPath, data, 'utf-8');
|
||||
const _fd = fs.openSync(tmpPath, 'w');
|
||||
try { fs.writeSync(_fd, data); fs.fsyncSync(_fd); } finally { fs.closeSync(_fd); }
|
||||
if (fs.existsSync(configStore.filePath)) {
|
||||
// Use try/catch around the read so an AV/lock race doesn't fail the
|
||||
// whole save just because we couldn't refresh the .bak — the write to
|
||||
@ -2537,6 +2634,7 @@ function _diagAgentInfo() {
|
||||
function _buildDiagnosticHandler() {
|
||||
const collectors = createCollectors({
|
||||
loadConfig: () => configStore.load(),
|
||||
loadHistory: () => configStore.loadHistory(),
|
||||
getAllLogPaths,
|
||||
support: { sanitizeConfig, collectSecretValues, redactLogText, valueScrub, collectFile, REDACTED },
|
||||
stats,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "multi-hoster-uploader",
|
||||
"version": "3.3.95",
|
||||
"version": "3.3.108",
|
||||
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
@ -33,7 +33,9 @@
|
||||
"main.js",
|
||||
"preload.js",
|
||||
"lib/**/*",
|
||||
"renderer/**/*"
|
||||
"renderer/**/*",
|
||||
"assets/app_icon.ico",
|
||||
"assets/app_icon.png"
|
||||
],
|
||||
"win": {
|
||||
"target": [
|
||||
|
||||
213
renderer/app.js
213
renderer/app.js
@ -20,13 +20,38 @@ let uploading = false;
|
||||
let healthCheckRunning = false;
|
||||
|
||||
let _rLongTasks = 0, _rLongTaskMax = 0, _rFrameLast = 0, _rFrameWorst = 0, _rFrameCount = 0, _rFrameJank = 0, _rPerfLastLog = 0, _rPerfWindowStart = 0;
|
||||
function _rElLabel(el) {
|
||||
try {
|
||||
if (!el || !el.tagName) return '?';
|
||||
let s = el.tagName.toLowerCase();
|
||||
if (el.id) s += '#' + el.id;
|
||||
else if (el.className && typeof el.className === 'string') { const c = el.className.trim().split(/\s+/)[0]; if (c) s += '.' + c; }
|
||||
const a = el.getAttribute && (el.getAttribute('data-action') || el.getAttribute('data-tab') || el.getAttribute('aria-label') || el.getAttribute('title'));
|
||||
if (a) s += `[${String(a).slice(0, 24)}]`;
|
||||
return s;
|
||||
} catch { return '?'; }
|
||||
}
|
||||
try {
|
||||
if (window.PerformanceObserver) {
|
||||
new window.PerformanceObserver((list) => {
|
||||
for (const e of list.getEntries()) { _rLongTasks++; if (e.duration > _rLongTaskMax) _rLongTaskMax = e.duration; }
|
||||
for (const e of list.getEntries()) {
|
||||
_rLongTasks++;
|
||||
if (e.duration > _rLongTaskMax) _rLongTaskMax = e.duration;
|
||||
if (e.duration >= 100 && window.api && window.api.debugLog) window.api.debugLog(`renderer-longtask dur=${Math.round(e.duration)}ms`);
|
||||
}
|
||||
}).observe({ entryTypes: ['longtask'] });
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
if (window.PerformanceObserver) {
|
||||
new window.PerformanceObserver((list) => {
|
||||
for (const e of list.getEntries()) {
|
||||
const proc = Math.round((e.processingEnd || 0) - (e.processingStart || 0));
|
||||
if (window.api && window.api.debugLog) window.api.debugLog(`renderer-interaction ${e.name} dur=${Math.round(e.duration)}ms proc=${proc}ms target=${_rElLabel(e.target)}`);
|
||||
}
|
||||
}).observe({ type: 'event', durationThreshold: 50, buffered: true });
|
||||
}
|
||||
} catch {}
|
||||
function _rFrameTick(ts) {
|
||||
if (_rFrameLast) { const d = ts - _rFrameLast; _rFrameCount++; if (d > _rFrameWorst) _rFrameWorst = d; if (d > 33) _rFrameJank++; }
|
||||
_rFrameLast = ts;
|
||||
@ -307,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');
|
||||
@ -334,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();
|
||||
}
|
||||
};
|
||||
@ -4605,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';
|
||||
@ -4711,54 +4738,67 @@ function _buildRecentRowHtml(row) {
|
||||
let _recentLastRenderedSig = '';
|
||||
let _recentLastRenderedLen = 0;
|
||||
let _recentPendingAppends = 0;
|
||||
let _recentWorking = [];
|
||||
let _recentLastRange = { start: -1, end: -1 };
|
||||
let _recentScrollQueued = false;
|
||||
|
||||
function _onRecentScroll() {
|
||||
if (_recentScrollQueued) return;
|
||||
_recentScrollQueued = true;
|
||||
requestAnimationFrame(() => { _recentScrollQueued = false; _renderRecentVirtualRows(); });
|
||||
}
|
||||
|
||||
function _renderRecentVirtualRows() {
|
||||
const wrap = document.querySelector('.recent-files-table-wrap');
|
||||
const tbody = document.getElementById('recentFilesBody');
|
||||
if (!wrap || !tbody) return;
|
||||
const total = _recentWorking.length;
|
||||
if (!total) return;
|
||||
const scrollTop = wrap.scrollTop;
|
||||
const viewportHeight = Math.max(wrap.clientHeight, 400);
|
||||
const startIdx = Math.max(0, Math.floor(scrollTop / VIRTUAL_ROW_HEIGHT) - VIRTUAL_OVERSCAN);
|
||||
const endIdx = Math.min(total, Math.ceil((scrollTop + viewportHeight) / VIRTUAL_ROW_HEIGHT) + VIRTUAL_OVERSCAN);
|
||||
if (startIdx === _recentLastRange.start && endIdx === _recentLastRange.end) return;
|
||||
_recentLastRange = { start: startIdx, end: endIdx };
|
||||
const topPad = startIdx * VIRTUAL_ROW_HEIGHT;
|
||||
const bottomPad = Math.max(0, (total - endIdx) * VIRTUAL_ROW_HEIGHT);
|
||||
const parts = [];
|
||||
if (topPad > 0) parts.push(`<tr class="virtual-spacer" style="height:${topPad}px"><td colspan="4"></td></tr>`);
|
||||
for (let i = startIdx; i < endIdx; i++) parts.push(_buildRecentRowHtml(_recentWorking[i]));
|
||||
if (bottomPad > 0) parts.push(`<tr class="virtual-spacer" style="height:${bottomPad}px"><td colspan="4"></td></tr>`);
|
||||
tbody.innerHTML = parts.join('');
|
||||
}
|
||||
|
||||
function renderRecentUploadsPanel(appendOnly = false) {
|
||||
const tbody = document.getElementById('recentFilesBody');
|
||||
if (!tbody) return;
|
||||
const pendingAppends = _recentPendingAppends;
|
||||
_recentPendingAppends = 0;
|
||||
const wrap = tbody.closest('.recent-files-table-wrap');
|
||||
|
||||
if (!sessionFilesData.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="empty-state">Noch keine Uploads in dieser Session.</td></tr>';
|
||||
_recentLastRenderedSig = '';
|
||||
_recentLastRenderedLen = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = sortRecentFiles(sessionFilesData);
|
||||
const sig = `${recentSortState.key}|${recentSortState.direction}`;
|
||||
const dateDescAppendOnly = appendOnly
|
||||
&& pendingAppends > 0
|
||||
&& sig === 'date|desc'
|
||||
&& _recentLastRenderedSig === sig
|
||||
&& tbody.querySelectorAll('.recent-file-row').length === _recentLastRenderedLen;
|
||||
|
||||
const wrap = tbody.closest('.recent-files-table-wrap');
|
||||
const wasAtTop = !wrap || wrap.scrollTop <= 48;
|
||||
|
||||
let wasAppendOnly = false;
|
||||
if (dateDescAppendOnly) {
|
||||
const added = Math.min(pendingAppends, rows.length);
|
||||
let html = '';
|
||||
for (let i = 0; i < added; i++) html += _buildRecentRowHtml(rows[i]);
|
||||
tbody.insertAdjacentHTML('afterbegin', html);
|
||||
let evict = (_recentLastRenderedLen + added) - rows.length;
|
||||
while (evict > 0) {
|
||||
const last = tbody.lastElementChild;
|
||||
if (!last) break;
|
||||
last.remove();
|
||||
evict--;
|
||||
}
|
||||
wasAppendOnly = true;
|
||||
_recentWorking = [];
|
||||
_recentLastRange = { start: -1, end: -1 };
|
||||
} else {
|
||||
tbody.innerHTML = rows.map(_buildRecentRowHtml).join('');
|
||||
const prevLen = _recentWorking.length;
|
||||
_recentWorking = sortRecentFiles(sessionFilesData);
|
||||
_recentLastRange = { start: -1, end: -1 };
|
||||
const sig = `${recentSortState.key}|${recentSortState.direction}`;
|
||||
if (wrap) {
|
||||
const added = _recentWorking.length - prevLen;
|
||||
if (sig === 'date|desc' && wrap.scrollTop <= 48) wrap.scrollTop = 0;
|
||||
else if (sig === 'date|desc' && added > 0) wrap.scrollTop += added * VIRTUAL_ROW_HEIGHT;
|
||||
}
|
||||
_renderRecentVirtualRows();
|
||||
}
|
||||
if (wrap && sig === 'date|desc' && wasAtTop) wrap.scrollTop = 0;
|
||||
_recentLastRenderedSig = sig;
|
||||
_recentLastRenderedLen = rows.length;
|
||||
|
||||
// Event delegation – bind once, not per-row
|
||||
if (!_recentListenersBound) {
|
||||
_recentListenersBound = true;
|
||||
if (wrap) {
|
||||
wrap.addEventListener('scroll', _onRecentScroll, { passive: true });
|
||||
if (typeof window.ResizeObserver !== 'undefined') new window.ResizeObserver(_onRecentScroll).observe(wrap);
|
||||
}
|
||||
tbody.addEventListener('click', (e) => {
|
||||
const tr = e.target.closest('.recent-file-row');
|
||||
if (!tr) return;
|
||||
@ -4797,17 +4837,63 @@ function renderRecentUploadsPanel(appendOnly = false) {
|
||||
});
|
||||
}
|
||||
|
||||
// Sort headers only change when the sort state changes — skip on appends.
|
||||
if (!wasAppendOnly) updateRecentSortHeaders();
|
||||
updateRecentSortHeaders();
|
||||
}
|
||||
|
||||
const HISTORY_RENDER_CAP = 2000;
|
||||
let _historyWorking = [];
|
||||
let _historyLastRange = { start: -1, end: -1 };
|
||||
let _historyListenersBound = false;
|
||||
let _historyScrollQueued = false;
|
||||
|
||||
function _onHistoryScroll() {
|
||||
if (_historyScrollQueued) return;
|
||||
_historyScrollQueued = true;
|
||||
requestAnimationFrame(() => { _historyScrollQueued = false; _renderHistoryVirtualRows(); });
|
||||
}
|
||||
|
||||
function _renderHistoryVirtualRows() {
|
||||
const container = document.getElementById('historyContainer');
|
||||
const tbody = document.getElementById('historyBody');
|
||||
if (!container || !tbody) return;
|
||||
const total = _historyWorking.length;
|
||||
const scrollTop = container.scrollTop;
|
||||
const viewportHeight = Math.max(container.clientHeight, 600);
|
||||
const startIdx = Math.max(0, Math.floor(scrollTop / VIRTUAL_ROW_HEIGHT) - VIRTUAL_OVERSCAN);
|
||||
const endIdx = Math.min(total, Math.ceil((scrollTop + viewportHeight) / VIRTUAL_ROW_HEIGHT) + VIRTUAL_OVERSCAN);
|
||||
if (startIdx === _historyLastRange.start && endIdx === _historyLastRange.end) return;
|
||||
_historyLastRange = { start: startIdx, end: endIdx };
|
||||
const topPad = startIdx * VIRTUAL_ROW_HEIGHT;
|
||||
const bottomPad = Math.max(0, (total - endIdx) * VIRTUAL_ROW_HEIGHT);
|
||||
const parts = [];
|
||||
if (topPad > 0) parts.push(`<tr class="virtual-spacer" style="height:${topPad}px"><td colspan="4"></td></tr>`);
|
||||
for (let i = startIdx; i < endIdx; i++) {
|
||||
const row = _historyWorking[i];
|
||||
const link = row.link || '';
|
||||
parts.push('<tr class="history-row');
|
||||
if (row.isError) parts.push(' error');
|
||||
parts.push('" data-link="');
|
||||
parts.push(escapeAttr(link));
|
||||
parts.push(`" style="height:${VIRTUAL_ROW_HEIGHT}px"><td class="col-date">`);
|
||||
parts.push(escapeHtml(row.date));
|
||||
parts.push('</td><td class="col-filename">');
|
||||
parts.push(escapeHtml(row.filename));
|
||||
parts.push('</td><td class="col-host">');
|
||||
parts.push(escapeHtml(row.host));
|
||||
parts.push('</td><td class="col-link">');
|
||||
parts.push(escapeHtml(link));
|
||||
parts.push('</td></tr>');
|
||||
}
|
||||
if (bottomPad > 0) parts.push(`<tr class="virtual-spacer" style="height:${bottomPad}px"><td colspan="4"></td></tr>`);
|
||||
tbody.innerHTML = parts.join('');
|
||||
}
|
||||
|
||||
function renderHistoryTable(container) {
|
||||
if (!container || !historyRowsData.length) {
|
||||
if (container) container.innerHTML = '<p class="empty-state">Noch keine Uploads.</p>';
|
||||
const emptyNotice = document.getElementById('historyCapNotice');
|
||||
if (emptyNotice) emptyNotice.style.display = 'none';
|
||||
_historyWorking = [];
|
||||
return;
|
||||
}
|
||||
|
||||
@ -4823,50 +4909,22 @@ function renderHistoryTable(container) {
|
||||
}
|
||||
}
|
||||
|
||||
const rows = sortHistoryRows(working);
|
||||
_historyWorking = sortHistoryRows(working);
|
||||
_historyLastRange = { start: -1, end: -1 };
|
||||
const headerCell = (key, label) => {
|
||||
const active = historySortState.key === key;
|
||||
const dir = active ? (historySortState.direction === 'asc' ? '▲' : '▼') : '↕';
|
||||
return `<th class="sortable${active ? ' active' : ''}" data-history-sort="${key}">${label}<span class="sort-indicator">${dir}</span></th>`;
|
||||
};
|
||||
|
||||
let html = `<table class="results-table history-table"><thead><tr>
|
||||
container.innerHTML = `<table class="results-table history-table"><thead><tr>
|
||||
${headerCell('date', 'Date')}${headerCell('filename', 'Filename')}${headerCell('host', 'Host')}${headerCell('link', 'Link')}
|
||||
</tr></thead><tbody>`;
|
||||
</tr></thead><tbody id="historyBody"></tbody></table>`;
|
||||
|
||||
const parts = [html];
|
||||
const len = rows.length;
|
||||
for (let i = 0; i < len; i++) {
|
||||
const row = rows[i];
|
||||
const link = row.link || '';
|
||||
const date = escapeHtml(row.date);
|
||||
const filename = escapeHtml(row.filename);
|
||||
const host = escapeHtml(row.host);
|
||||
const linkHtml = escapeHtml(link);
|
||||
const linkAttr = escapeAttr(link);
|
||||
parts.push('<tr class="history-row');
|
||||
if (row.isError) parts.push(' error');
|
||||
parts.push('" data-link="');
|
||||
parts.push(linkAttr);
|
||||
parts.push('"><td class="col-date">');
|
||||
parts.push(date);
|
||||
parts.push('</td><td class="col-filename">');
|
||||
parts.push(filename);
|
||||
parts.push('</td><td class="col-host">');
|
||||
parts.push(host);
|
||||
parts.push('</td><td class="col-link">');
|
||||
parts.push(linkHtml);
|
||||
parts.push('</td></tr>');
|
||||
}
|
||||
parts.push('</tbody></table>');
|
||||
container.innerHTML = parts.join('');
|
||||
|
||||
// Delegated listeners: bind once per render-target instead of once per
|
||||
// row/header. With a 5000-row history the per-row bind path was a
|
||||
// 5000-iteration synchronous loop on every Verlauf-tab switch — the
|
||||
// dominant cause of "tab switching lags" in the user report.
|
||||
if (!container.dataset.historyListenersBound) {
|
||||
container.dataset.historyListenersBound = '1';
|
||||
if (!_historyListenersBound) {
|
||||
_historyListenersBound = true;
|
||||
container.addEventListener('scroll', _onHistoryScroll, { passive: true });
|
||||
if (typeof window.ResizeObserver !== 'undefined') new window.ResizeObserver(_onHistoryScroll).observe(container);
|
||||
container.addEventListener('click', (e) => {
|
||||
const th = e.target.closest('th.sortable');
|
||||
if (th && container.contains(th)) {
|
||||
@ -4879,6 +4937,7 @@ function renderHistoryTable(container) {
|
||||
} else {
|
||||
historySortState.direction = historySortState.direction === 'asc' ? 'desc' : 'asc';
|
||||
}
|
||||
container.scrollTop = 0;
|
||||
renderHistoryTable(container);
|
||||
return;
|
||||
}
|
||||
@ -4889,6 +4948,8 @@ function renderHistoryTable(container) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_renderHistoryVirtualRows();
|
||||
}
|
||||
|
||||
function sortHistoryRows(rows) {
|
||||
|
||||
@ -657,6 +657,7 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
|
||||
.recent-file-row {
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
height: 28px;
|
||||
}
|
||||
.recent-file-row:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
@ -1231,6 +1232,11 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
|
||||
.results-table th.active, .history-table th.active { color: var(--text); }
|
||||
.sort-indicator { margin-left: 4px; font-size: 10px; }
|
||||
|
||||
.history-table { table-layout: fixed; }
|
||||
.history-table .col-date { width: 16%; }
|
||||
.history-table .col-filename { width: 34%; }
|
||||
.history-table .col-host { width: 12%; }
|
||||
.history-table .col-link { width: 38%; }
|
||||
.history-row {
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
@ -1,5 +1,80 @@
|
||||
# Lessons
|
||||
|
||||
## 2026-06-21 — Das eigene Instrument lügt nicht, aber sein Log-Code kann buggen (`queue=undefined`)
|
||||
**Symptom:** Drei Builds lang jagte ich Read-Bursts (highWaterMark, threadpool), während der WAHRE Treiber
|
||||
eine 38,5-MB-electron-config.json war, die 137×/73s geklont/geparst/serialisiert wurde (~47% Main-Thread).
|
||||
Erst das in v3.3.98 eingebaute `config-load/config-serialize`-Log machte es sichtbar — aber mein eigenes
|
||||
Log-Feld `queue=` las `.length` auf dem pendingQueue-OBJEKT (immer undefined) und hätte mich fast in die
|
||||
falsche Richtung (pendingQueue statt history) geschickt.
|
||||
**Root cause:** (1) Ich hatte die config-Persistenz als „instrumentieren, nicht fixen" zurückgestellt (richtig
|
||||
für die unsichere Migration), aber den Lag-Treiber dort nicht früh genug vermutet. (2) Instrument-Felder selbst
|
||||
müssen verifiziert werden: `(obj || []).length` auf einem Objekt = undefined, still falsch.
|
||||
**Regel:** Wenn der User „es laggt unverändert" sagt obwohl die letzte Messung gut aussah, ist der gemessene
|
||||
Pfad NICHT der Hot-Path — sofort BREITER messen (jeden IPC-Handler, jede periodische Main-Op, Main-Thread-
|
||||
Longtask-Monitor), nicht den schon-gemessenen Pfad weiter optimieren. Und Instrument-Ausgaben gegen ein
|
||||
bekanntes Beispiel prüfen (zeigt `queue=` je eine echte Zahl?).
|
||||
**Wie anwenden:** Bei „Symptom unverändert trotz Fix": Hypothese fallen lassen, Coverage verbreitern. Log-Felder
|
||||
beim Schreiben mit einem realen Wert gegenchecken, nie blind `(x||[]).length` auf unklar getypten Feldern.
|
||||
|
||||
## 2026-06-21 — „Brot finden, nicht Krümel": die EINE Änderung, die alle Kosten killt, schlägt drei sichere Teilfixes
|
||||
**Symptom:** Fix-Design bot loadShallow (Klon vermeiden) + cache-repopulate + resolution-cache. Adversary zeigte:
|
||||
loadShallow killt nur den Klon (~10s von 34s), die 38 Serializes (8,4s) + 38 Post-Write-Reparses (15,2s) bleiben,
|
||||
weil Writes den Cache nullen → loadShallow allein = Krümel.
|
||||
**Root cause:** Alle drei Kosten (parse+clone+serialize) entstehen daraus, dass history IM Hot-Config liegt.
|
||||
Nur history RAUS aus der immer-geladenen Datei (eigene electron-history.json) killt alle drei gleichzeitig.
|
||||
cache-repopulate-Gate feuerte nie (toter Code); resolution-cache hätte stale-Pools → Failover-Regression
|
||||
(rotation/byse) riskiert = die EINE Sache die Uploads STILL korrumpiert, schlimmer als Lag.
|
||||
**Regel:** Wenn der User „komplett wegmachen" fordert und mehrere sichere Teilfixes vs. ein riskanterer
|
||||
Komplettfix zur Wahl stehen: den Komplettfix nehmen, aber RICHTIG absichern (hier: fsync+verify-before-strip,
|
||||
permanenter .pre-history-split.bak, Migration packaged-only + per-Init nicht in load(), Crash-Window-Fallback,
|
||||
Test gegen die ECHTE 194MB-Fixture). Teilfixes die den Treiber nur anknabbern NICHT bündeln (verwässert Messung
|
||||
+ Risiko). Einen Fix der etwas STILL korrumpieren könnte (stale Account-Pools) NIE für Performance einbauen.
|
||||
**Wie anwenden:** Bei mehreren Fix-Optionen fragen: „welche EINE Änderung entfernt die gemeinsame Wurzel ALLER
|
||||
Kostenpfade?" — die nehmen und maximal absichern, statt N sichere Teilfixes die je nur einen Pfad treffen.
|
||||
|
||||
## 2026-06-21 — Ein-Variablen-Disziplin: nicht zwei Fixes bündeln, wenn einer den anderen maskiert
|
||||
**Symptom:** Nach dem tp=8-Win wollte ich in EINEM Build A (1MB highWaterMark, Read-Burst) + B (Renderer
|
||||
chunked rAF Batch-Drain, der 243ms-Longtask) + C-Instrument shippen.
|
||||
**Root cause / Korrektur (Advisor):** Der Renderer war 14/15 Fenstern gesund; der EINE 243ms-Longtask (W14)
|
||||
ist laut beiden Agenten DOWNSTREAM des Main-Thread-Read-Bursts (geflutetes IPC). Fix A reduziert diese
|
||||
Stalls → der Renderer-Longtask verschwindet wahrscheinlich OHNE B. B mitzuliefern (a) verwässert die nächste
|
||||
Messung (war die Besserung A oder B?) und (b) fasst den Progress-Hot-Path an, der hier schon gebissen hat
|
||||
(formatDateTime-Burst, ghost-fix).
|
||||
**Regel:** Wenn Fix A einen vermuteten Symptom-Treiber X reduziert und Fix B genau X behandeln würde —
|
||||
NUR A shippen, messen, B nur nachziehen wenn X überlebt. Sonst kann das nächste Log nicht sauber attribuieren.
|
||||
Bei gekoppelten Symptomen ist die Reihenfolge (Upstream-Fix zuerst, dann messen) wichtiger als „alles auf
|
||||
einmal".
|
||||
**Wie anwenden:** Vor dem Bündeln fragen: „Maskiert Fix A die Wirkung, die Fix B beheben soll?" Wenn ja →
|
||||
entkoppeln, A zuerst, eine Variable pro Build.
|
||||
|
||||
## 2026-06-21 — Nicht aus EINEM konfundierten Sample eine Ursache behaupten
|
||||
**Symptom:** Ich wollte dem User sagen „1-Sekunden-Persist-Freeze gefunden" auf Basis von W13 (max=1021ms,
|
||||
heap→142MB).
|
||||
**Root cause / Korrektur (Advisor):** W13 ist EIN Sample und konfundiert (hat gleichzeitig FSReqCallback=66)
|
||||
und das EINZIGE Heap-Spike-Fenster. Die anderen isolierten Maxes (W4 415ms/heap41, W10 852ms/heap18) haben
|
||||
NIEDRIGEN Heap → sind KEIN 140MB-structuredClone+stringify → eine andere Ursache (account-failed sync load()
|
||||
nahe Connection-Churn). Eine Behauptung aus einem konfundierten Punkt hätte den falschen Fix priorisiert.
|
||||
**Regel:** Bei isolierten Spitzen erst die Co-Signale (heap, FSReq, gc, Nachbarfenster) gegenchecken, ob sie
|
||||
EINE Familie sind. Wenn die Magnitude-Signatur (hier: Heap-Spike) nicht bei allen passt → es sind mehrere
|
||||
Ursachen. „Instrumentieren + bestätigen", nicht „gefunden", solange nur ein konfundierter Punkt existiert.
|
||||
**Wie anwenden:** Vor „Ursache X gefunden": gibt es ≥2 unkonfundierte Samples mit derselben Signatur? Wenn
|
||||
nein → als Hypothese formulieren und messen, nicht als Befund verkaufen.
|
||||
|
||||
## 2026-06-21 — Histogram-Korrelation beweist KEINE Kausalrichtung; rss-Mathe als Sanity-Check
|
||||
**Symptom:** ELD-Spikes korrelierten exakt mit hohem `FSReqCallback` (File-Reads in flight) → ich wollte
|
||||
sofort ein Read-Concurrency-Semaphore über 5 Dateien bauen.
|
||||
**Root cause / Korrektur (Advisor):** (1) Korrelation ≠ Kausalität: hohe in-flight-Reads können auch SYMPTOM
|
||||
sein — ein aus ANDEREM Grund blockierter Loop drained die Read-Completions nicht, also stapeln sie sich im
|
||||
Snapshot. (2) rss-Mathe widerlegte meine „Read-Buffer ballonen den Speicher"-These: 70 Streams × 256KB ≈
|
||||
18MB, aber rss schwang ~300MB → das ist Heap-/Objekt-Churn (GC), nicht die Read-Buffer.
|
||||
**Regel:** Bevor ich auf Basis einer Histogramm-Korrelation einen Multi-File-Refactor baue: (a) Kausalrichtung
|
||||
mit einem BILLIGEN reversiblen 1-Zeilen-Hebel testen (hier UV_THREADPOOL_SIZE 64→8), (b) die Größenordnung
|
||||
gegenrechnen (passt die vermutete Quelle zahlenmäßig zur beobachteten Wirkung?), (c) den fehlenden Co-Faktor
|
||||
(GC) erst MESSEN, bevor ich ihn aus- oder einschließe. Ein Build kann gleichzeitig Kandidaten-Fix UND
|
||||
Diskriminator sein.
|
||||
**Wie anwenden:** Bei „X korreliert mit Y, also fixe X": erst fragen „könnte Y → X statt X → Y?" und „passt
|
||||
die Magnitude?". Wenn nein/unklar → erst der billige reversible Knopf + Messung, dann der teure Refactor.
|
||||
|
||||
## 2026-04-21 — DOM-Doppelrender bei Bulk-State-Changes
|
||||
**Symptom:** User klickt auf "Erneut versuchen" mit 500+ Jobs → App hängt sekundenlang.
|
||||
**Root cause:** `retrySelectedJobs()` ruft `renderQueueTable + updateQueueActionButtons + updateStatusBar` auf, `startSelectedUpload()` ruft direkt danach genau dieselben Funktionen nochmal auf.
|
||||
|
||||
366
tasks/todo.md
366
tasks/todo.md
@ -1,3 +1,369 @@
|
||||
# v3.3.108 — session log filename: 6-digit uniqueness suffix
|
||||
|
||||
User: append a generated 6-digit number to the session log filename, e.g.
|
||||
26-06-2026-mdu-session-06-02-847581.log. Dropping seconds/pid in v3.3.107 reintroduced same-minute
|
||||
collision risk on a fast close/reopen.
|
||||
- formatSessionStamp(date, rand) appends `-${rand}` when rand is supplied (number or string), else unchanged.
|
||||
- main.js stamps SESSION_ID = formatSessionStamp(new Date(), String(Math.floor(100000 + Math.random()*900000))).
|
||||
- stripModeStampFromFileName newSessionRe gains an optional `(?:-\d+)?` so the suffix strips back to the base.
|
||||
- Tests: stamp-with-rand (string + number), strip-with-suffix. 411 pass.
|
||||
Shipped: gitea v3.3.108 (updater latest.yml verified) + GitHub mirror (lib/log-mode.js, main.js, package.json,
|
||||
tests/log-mode.test.js only; tag repointed to the sanitized mirror commit, NOT the gitea history commit).
|
||||
|
||||
---
|
||||
|
||||
# v3.3.107 — session log filename template → DD-MM-YYYY-mdu-session-HH-MM
|
||||
|
||||
User: change the session log filename from fileuploader-session-YYYY-MM-DD_HH-MM-SS-<pid>.log to
|
||||
DD-MM-YYYY-mdu-session-HH-MM.log (hour-minute, no seconds/pid). lib/log-mode.js:
|
||||
- formatSessionStamp(date) now returns `${DD}-${MM}-${YYYY}-mdu-session-${HH}-${MM}` (pid arg dropped; main.js
|
||||
still passes process.pid, harmlessly ignored).
|
||||
- resolveLogFileName session branch returns `${sid}${ext}` (the stamp is the full app-defined stem, baseName
|
||||
ignored — single/daily still use baseName 'fileuploader').
|
||||
- stripModeStampFromFileName recognizes the new format (^DD-MM-YYYY-mdu-session-HH-MM(.ext)$) and resets to the
|
||||
default 'fileuploader' base (the new format embeds no base); the old daily + old-session strip regexes stay
|
||||
for backward-compat with any persisted old paths. The compounding round-trip stays idempotent.
|
||||
Tests updated (formatSessionStamp, resolveLogFileName session, strip new-format, idempotency). 410 pass.
|
||||
|
||||
---
|
||||
|
||||
# v3.3.106 — intermittent white-screen on startup (RDP/VM GPU) + export filename
|
||||
|
||||
Two asks. (A) White screen: user sometimes gets a PURE-WHITE window on start (no error banner, NOT even the
|
||||
static menu bar) on a 12-vCPU Windows VM over RemoteDesktop. Workflow wn2k04x5t (2 agents + adversarial verify)
|
||||
nailed it by one airtight deduction: the BrowserWindow backgroundColor is DARK (#16181c, main.js:1228), so
|
||||
"white" can NEVER be an un-painted/loading/failed state — those all show DARK. Pure-white + no menu bar + no
|
||||
banner + SILENT eliminates every DOM/init/CSS/load mode (each leaves the dark styled shell, unstyled-black-on-
|
||||
white menu text, or the red init().catch banner) → the ONLY match is a GPU/compositor surface failure on the
|
||||
RDP virtual display adapter. Confirmed: NO disableHardwareAcceleration / disable-gpu / appendSwitch ANYWHERE,
|
||||
and child-process-gone (GPU) is log-only (matches the silent symptom) while render-process-gone pops a dialog.
|
||||
Renderer has ZERO WebGL/canvas/video (audited) → software compositing costs ~nothing and doesn't undo the perf
|
||||
work; a webContents.reload() doesn't disrupt uploads (uploadManager lives in main, torn down only on quit).
|
||||
Watchdog REJECTED: the GPU mode lets init complete, so an init-complete signal wouldn't detect the white screen.
|
||||
|
||||
SHIPPED v3.3.106 (main.js — adversary's safe subset):
|
||||
- app.disableHardwareAcceleration() at module top (before app.whenReady) GATED on RDP (process.env.SESSIONNAME
|
||||
matches /^RDP/) OR a persisted gpu-disabled.flag (in userData). Zero-regression for local/console users.
|
||||
- Auto-heal: on a GPU child-process-gone, write gpu-disabled.flag → next launch disables HW accel even if the
|
||||
RDP gate missed (covers VM-via-console / bad virtual GPU). Self-healing after at most one white screen.
|
||||
- Kept the child-process-gone/render-process-gone/did-fail-load instrumentation to CONFIRM on the server log
|
||||
(caveat: root cause is the standard RDP-GPU bet, not yet confirmed on the affected machine — next white-start
|
||||
log shows CHILD PROCESS GONE type=GPU = confirmed).
|
||||
- (B) export-backup defaultPath: multi-hoster-backup-YYYY-MM-DD.mhu → DD-MM-YYYY-multihoster-backup.mhu.
|
||||
409 tests pass, clean boot (guard inert on non-RDP dev machine).
|
||||
|
||||
DEFERRED (perf polish, workflow w5o2rpffx design ready): history JSONL + batch-start residual.
|
||||
|
||||
---
|
||||
|
||||
# v3.3.105 — URGENT data-safety: config write fsync + account-wipe guard
|
||||
|
||||
User report: after a server CRASHED during upload (NOT the v3.3.104 update — the other server updated fine and
|
||||
kept its accounts), the accounts/credentials were gone. Root-cause chain (in code): config writes were atomic
|
||||
(tmp+rename) but had NO fsync — a hard crash can leave electron-config.json truncated/unflushed → on restart
|
||||
load() reads the corrupt/empty file, falls to .bak, and if that's also bad returns empty DEFAULTS → the next
|
||||
settings/queue save persists EMPTY hosters → accounts permanently wiped (and the async _atomicWrite blindly
|
||||
copyFileSync'd the live → .bak, so an empty live could clobber a good .bak).
|
||||
|
||||
SHIPPED v3.3.105 (lib/config-store.js + main.js — data-safety, no behavior change):
|
||||
- fsync before rename in BOTH write paths: _atomicWrite (openSync+writeSync+fsyncSync+closeSync, then
|
||||
guarded-.bak + rename) and main.js save-global-settings-sync (openSync+writeSync+fsyncSync+closeSync). A hard
|
||||
crash can no longer leave a truncated config.
|
||||
- _atomicWrite .bak is now GUARDED: read the live file and only refresh .bak if it's non-trivial (trim>2) —
|
||||
an empty/truncated live can never clobber a good .bak (matches what the sync-save already did).
|
||||
- WIPE-GUARD (_guardHosters): in save()/saveRotationCursors()/the sync-save, when the write does NOT
|
||||
intentionally set hosters (config.hosters absent) AND the resulting hosters are all-empty, recover the
|
||||
hosters from disk (_recoverHostersFromDisk tries live → .bak → .pre-history-split.bak) instead of persisting
|
||||
the wipe. An EXPLICIT save({hosters:{}}) (user deleted all) is still allowed (hostersIntentional=true).
|
||||
- load() gained a 3rd fallback tier: .pre-history-split.bak (the permanent v3.3.99 snapshot with accounts) so
|
||||
load() itself recovers after corruption.
|
||||
2 new tests (post-wipe valid-empty live + .bak → guard restores; explicit empty NOT blocked). 409 tests pass.
|
||||
RECOVERY for the affected server: %APPDATA%\multi-hoster-uploader\electron-config.json.pre-history-split.bak
|
||||
(or .bak) → copy over electron-config.json with the app closed.
|
||||
|
||||
DEFERRED (perf polish, workflow w5o2rpffx designs ready): history JSONL (append-only, kills per-batch 185MB
|
||||
rewrite + first-open parse via loadHistoryRecent tail-read + meta sidecar) and the batch-start one-time
|
||||
render/231ms residual. Do these AFTER the data-safety fix is confirmed stable.
|
||||
|
||||
---
|
||||
|
||||
# v3.3.104 — virtualize the Recent-uploads panel (the last non-virtual table)
|
||||
|
||||
v3.3.103 log (real 2464-job, 4-hoster batch → 95 concurrent): the statSync fix HELD (batch-start main spike
|
||||
336→231ms with 10× more jobs), and the whole 90s ramp to 95 active was PRISTINE (mean ~11ms, fps=32,
|
||||
longtasks=0). Residual: rapidly clicking tabs DURING the 95-active upload → renderer-longtask 210-221ms
|
||||
(proc=0ms = layout). Cause: the Recent-uploads panel (renderRecentUploadsPanel) rendered ALL sessionFilesData
|
||||
rows (≤2000) into the DOM non-virtualized — the exact analog of the History table pre-v3.3.102. Switching to
|
||||
that view laid out ~2000 rows.
|
||||
|
||||
Workflow w23318hm0 hit transient 529 overload (no cached results); did the fix directly using the proven
|
||||
History-virtualization template + Playwright empirical verification (stronger than agent review for layout).
|
||||
|
||||
SHIPPED v3.3.104 (renderer/app.js + styles.css):
|
||||
- Virtualized renderRecentUploadsPanel mirroring History/_renderVirtualRows: tbody#recentFilesBody gets only
|
||||
~visible rows + top/bottom spacer <tr> (VIRTUAL_ROW_HEIGHT=28, OVERSCAN=10). Scroll handler (_onRecentScroll
|
||||
rAF-coalesced) + ResizeObserver on .recent-files-table-wrap (doubles as show-trigger). _recentWorking holds
|
||||
the sorted set. DROPPED the insertAdjacentHTML append-only fast path (a ~40-row window re-render is cheap);
|
||||
every render re-renders the visible window. Scroll-position preserved on prepend (date|desc: scrollTop=0 at
|
||||
top, else += added*ROW_HEIGHT).
|
||||
- SELECTION SAFE: _buildRecentRowHtml already stamps `selected` from selectedRecentIds.has(row.order) per row,
|
||||
so off-screen-selected rows render selected when scrolled in; selectedRecentIds stays the source of truth;
|
||||
shift-select already uses _recentSortCache (not the DOM). applyRecentSelectionClasses toggling only visible
|
||||
rows is correct.
|
||||
- styles.css: .recent-file-row { height: 28px } so the virtualization math is exact (table already had
|
||||
table-layout:fixed, so no column-jump fix needed unlike History).
|
||||
- Empty-state guard: _renderRecentVirtualRows returns early when total=0 so it never wipes the "Noch keine
|
||||
Uploads" message.
|
||||
- PLAYWRIGHT-VERIFIED @2000 rows (bounded container): show-cost 118ms→2.4ms, DOM stays 29-39 rows, scroll maps
|
||||
correctly (row1500→window@1486), scrollHeight exact (56014≈56000), off-screen-selected renders with class,
|
||||
row height exactly 28. 407 tests pass, clean boot.
|
||||
|
||||
Every large table is now virtualized (Queue, History, Recent). DEFERRED still (one-time/minor): batch-start
|
||||
~500ms first-render + residual 231ms main spike for 2464 jobs (one-time per batch); first-get-history-after-
|
||||
batch parse-cache/JSONL.
|
||||
|
||||
---
|
||||
|
||||
# v3.3.103 — kill the batch-start 336ms main stall (synchronous statSync storm)
|
||||
|
||||
v3.3.102 log (real 224-job batch): History virtualization CONFIRMED (no get-history on tab switch),
|
||||
steady-state pristine (fps=32, ELD 11.8ms). Residual = a ~6s BATCH-START spin-up burst: main ELD
|
||||
max=336ms @cpu=0%core + renderer-longtasks 196-391ms; settles to clean by +6s. Workflow w3bzkumo8
|
||||
(4 agents + adversarial verify) CORRECTED my hypothesis:
|
||||
- My "uncapped renderer progress-drain" theory was WRONG: handleProgress only mutates JS + SCHEDULES
|
||||
coalesced renders (rAF/200ms); render is already one-per-frame; main coalesces to ~50 latest-per-job/100ms.
|
||||
Chunking the drain fixes nothing. ADVERSARY FOUND IT WOULD REGRESS: rAF throttles to ~0 when the window is
|
||||
minimized (the common background-uploader state) → unbounded _pBuf backlog AND deferred persistQueueStateSoon
|
||||
(last line of _handleProgressImpl) → terminal 'done' lost on close = the queue-persistence-ghost-fix class.
|
||||
DEFERRED/REJECTED as sketched.
|
||||
- REAL cause (cpu=0%core = blocked on I/O): synchronous fs.statSync storm in UploadManager.startBatch dedup
|
||||
loop (lib/upload-manager.js:363-377): up to DEDUP_CHUNK=200 fs.statSync in ONE tick before yielding. 200 ×
|
||||
~1.68ms (measured on the VM) = the exact 336ms. Plus a per-job statSync (428). On a disk already saturated
|
||||
by the 1MB read-ahead.
|
||||
|
||||
SHIPPED v3.3.103 (lib/upload-manager.js — adversary's zero-risk headline fix, but the more thorough async form):
|
||||
- Dedup loop: dedup synchronously (cheap Map ops), then stat the unique files in PARALLEL via
|
||||
`await Promise.all(toStat.map(f => fs.promises.stat(f)))` per chunk → stats run on the libuv threadpool,
|
||||
main thread NEVER blocks. Preserves the exact results-Map shape {name,size,results:[]} + dedup semantics
|
||||
(size=0 on failure). The 336ms sync block → 0 main-thread block.
|
||||
- Per-job statSync (428) → `await fs.promises.stat` (in an async fn before the first real await; cachedResult
|
||||
fast-path already skips it for ~all jobs — consistency only).
|
||||
- Tests: updated the fs.statSync mocks in upload-manager.test.js (2 sites) + suspect-reject-alternates.test.js
|
||||
to also mock fs.promises.stat (returns the same fake sizes). 407 tests pass, clean boot.
|
||||
|
||||
The renderer-longtasks (391/280/299ms) during the burst were (medium-confidence) the user's OWN tab clicks
|
||||
landing while the main thread was stalled — fixing the main stall frees IPC so those clicks stay responsive.
|
||||
DEFERRED still (only if needed): first-get-history-after-batch parse-cache/JSONL; the renderer chunked drain
|
||||
ONLY if ever needed for interaction-responsiveness AND gated on a high-water-mark sync drain + a
|
||||
document.hidden setTimeout fallback (never rAF-only).
|
||||
|
||||
---
|
||||
|
||||
# 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`
|
||||
(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
|
||||
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 268–308MB): `SimpleWriteWrap ≈ active`, `FSReqCallback ≈ 0–1` (write/network-bound)
|
||||
- BLOCKED (ELD 49–217ms, rss 540–610MB): `FSReqCallback ≈ active` (62–71 file reads in flight), `SimpleWriteWrap ≈ 0–4`
|
||||
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):
|
||||
|
||||
@ -17,6 +17,7 @@ function createStore() {
|
||||
// We override by setting filePath directly
|
||||
store = new ConfigStore(fakeApp);
|
||||
store.filePath = path.join(tmpDir, 'electron-config.json');
|
||||
store.historyPath = path.join(tmpDir, 'electron-history.json');
|
||||
return store;
|
||||
}
|
||||
|
||||
@ -292,4 +293,117 @@ describe('ConfigStore', () => {
|
||||
const config = store.load();
|
||||
assert.equal(config.hosters['doodstream.com'][0].apiKey, 'from-backup');
|
||||
});
|
||||
|
||||
it('wipe-guard: a settings-only save recovers accounts from .bak when the live config validly has none', async () => {
|
||||
// Post-wipe state: live config parses fine but has empty hosters; a backup still holds the accounts.
|
||||
fs.writeFileSync(store.filePath, JSON.stringify({ hosters: {}, hosterSettings: {}, globalSettings: {}, history: [] }), 'utf-8');
|
||||
fs.writeFileSync(store.filePath + '.bak', JSON.stringify({
|
||||
hosters: { 'voe.sx': [{ id: 'v1', authType: 'api', apiKey: 'survive-key' }] },
|
||||
hosterSettings: {}, globalSettings: {}, history: []
|
||||
}), 'utf-8');
|
||||
await store.save({ globalSettings: { alwaysOnTop: true } });
|
||||
const cfg = store.load();
|
||||
assert.ok(cfg.hosters['voe.sx'] && cfg.hosters['voe.sx'].length === 1, 'guard must restore accounts from .bak, not persist the wipe');
|
||||
assert.equal(cfg.hosters['voe.sx'][0].apiKey, 'survive-key');
|
||||
assert.equal(cfg.globalSettings.alwaysOnTop, true);
|
||||
});
|
||||
|
||||
it('wipe-guard: an explicit save({hosters:{}}) (user deleted all) is NOT blocked', async () => {
|
||||
await store.save({ hosters: { 'doodstream.com': [{ id: 'd1', authType: 'api', apiKey: 'k' }] } });
|
||||
await store.save({ hosters: {} });
|
||||
const cfg = store.load();
|
||||
assert.equal((cfg.hosters['doodstream.com'] || []).length, 0, 'an intentional hosters write must be allowed to empty them');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConfigStore history split (electron-history.json)', () => {
|
||||
let dir;
|
||||
let s;
|
||||
|
||||
function makeStore() {
|
||||
const st = new ConfigStore({ isPackaged: false, getPath: () => dir });
|
||||
st.filePath = path.join(dir, 'electron-config.json');
|
||||
st.historyPath = path.join(dir, 'electron-history.json');
|
||||
return st;
|
||||
}
|
||||
|
||||
function writeConfigWithHistory(n) {
|
||||
const history = [];
|
||||
for (let i = 0; i < n; i++) history.push({ id: `batch-${i}`, timestamp: 1750000000000 + i, total: 3, files: [{ name: `f${i}.mkv` }] });
|
||||
fs.writeFileSync(path.join(dir, 'electron-config.json'), JSON.stringify({
|
||||
hosters: { 'byse.sx': [{ id: 'a1', authType: 'api', apiKey: 'k' }] },
|
||||
hosterSettings: {}, globalSettings: { historyRetention: 'all' }, history
|
||||
}), 'utf-8');
|
||||
}
|
||||
|
||||
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cfg-hist-')); s = makeStore(); });
|
||||
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
|
||||
|
||||
it('migration moves history into electron-history.json, preserving every entry', () => {
|
||||
writeConfigWithHistory(50);
|
||||
s._migrateHistory();
|
||||
assert.equal(s._historyMigrated, true);
|
||||
assert.ok(fs.existsSync(s.historyPath));
|
||||
const hist = JSON.parse(fs.readFileSync(s.historyPath, 'utf-8'));
|
||||
assert.equal(hist.length, 50);
|
||||
assert.equal(hist[0].id, 'batch-0');
|
||||
assert.equal(hist[49].id, 'batch-49');
|
||||
assert.ok(fs.existsSync(s.filePath + '.pre-history-split.bak'), 'a permanent pre-split backup is kept');
|
||||
});
|
||||
|
||||
it('after migration load() excludes history (cheap hot path) but loadHistory() returns the real data', () => {
|
||||
writeConfigWithHistory(30);
|
||||
s._migrateHistory();
|
||||
assert.deepEqual(s.load().history, [], 'history is not carried in the always-loaded config');
|
||||
assert.equal(s.loadHistory().length, 30);
|
||||
});
|
||||
|
||||
it('appendHistory writes to history.json; the next config write strips stale history from the config file', async () => {
|
||||
writeConfigWithHistory(10);
|
||||
s._migrateHistory();
|
||||
await s.appendHistory({ id: 'new-batch', timestamp: 1750000099999, total: 1, files: [{ name: 'x.mkv' }] });
|
||||
assert.equal(s.loadHistory().length, 11, 'append goes to history.json');
|
||||
await s.save({ globalSettings: { alwaysOnTop: true } });
|
||||
const onDisk = JSON.parse(fs.readFileSync(s.filePath, 'utf-8'));
|
||||
assert.ok(!onDisk.history || onDisk.history.length === 0, 'a config write strips stale history from the config file');
|
||||
assert.equal(s.loadHistory().length, 11, 'history.json is unaffected by the config write');
|
||||
});
|
||||
|
||||
it('save({globalSettings}) after migration NEVER loses history (data-loss invariant)', async () => {
|
||||
writeConfigWithHistory(40);
|
||||
s._migrateHistory();
|
||||
await s.save({ globalSettings: { alwaysOnTop: true } });
|
||||
assert.equal(s.loadHistory().length, 40, 'a settings write must not touch history');
|
||||
assert.equal(s.load().globalSettings.alwaysOnTop, true);
|
||||
});
|
||||
|
||||
it('clearHistory empties history.json only', async () => {
|
||||
writeConfigWithHistory(20);
|
||||
s._migrateHistory();
|
||||
await s.clearHistory();
|
||||
assert.equal(s.loadHistory().length, 0);
|
||||
});
|
||||
|
||||
it('migration is idempotent — re-running with history.json present does not re-derive or clobber', () => {
|
||||
writeConfigWithHistory(15);
|
||||
s._migrateHistory();
|
||||
const after = makeStore();
|
||||
after._migrateHistory();
|
||||
assert.equal(after._historyMigrated, true);
|
||||
assert.equal(after.loadHistory().length, 15);
|
||||
});
|
||||
|
||||
it('crash-window fallback: not migrated + no history.json → loadHistory reads config.history', () => {
|
||||
writeConfigWithHistory(7);
|
||||
assert.equal(s._historyMigrated, false);
|
||||
assert.equal(s.loadHistory().length, 7, 'legacy path still serves history if migration never ran');
|
||||
});
|
||||
|
||||
it('pruneHistory trims history.json and persists the retention setting', async () => {
|
||||
writeConfigWithHistory(12);
|
||||
s._migrateHistory();
|
||||
const res = await s.pruneHistory('all', { dryRun: false });
|
||||
assert.equal(s.loadHistory().length, 12);
|
||||
assert.ok(res.keptBatches === 12);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -57,13 +57,23 @@ test('resolveLogFileName: daily mode → fileuploader-YYYY-MM-DD.log', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveLogFileName: session mode → fileuploader-session-<id>.log', () => {
|
||||
test('resolveLogFileName: session mode → <sessionId>.log (baseName ignored)', () => {
|
||||
assert.equal(
|
||||
resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode: 'session', sessionId: '2026-05-28_22-44-52-12345' }),
|
||||
'fileuploader-session-2026-05-28_22-44-52-12345.log'
|
||||
resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode: 'session', sessionId: '26-05-2026-mdu-session-22-44' }),
|
||||
'26-05-2026-mdu-session-22-44.log'
|
||||
);
|
||||
});
|
||||
|
||||
test('formatSessionStamp: DD-MM-YYYY-mdu-session-HH-MM', () => {
|
||||
const { formatSessionStamp } = require('../lib/log-mode');
|
||||
assert.equal(formatSessionStamp(new Date(2026, 5, 26, 6, 2, 36)), '26-06-2026-mdu-session-06-02');
|
||||
});
|
||||
|
||||
test('formatSessionStamp: appends a 6-digit suffix when a rand is supplied', () => {
|
||||
assert.equal(formatSessionStamp(new Date(2026, 5, 26, 6, 2, 36), '847581'), '26-06-2026-mdu-session-06-02-847581');
|
||||
assert.equal(formatSessionStamp(new Date(2026, 5, 26, 6, 2, 36), 847581), '26-06-2026-mdu-session-06-02-847581');
|
||||
});
|
||||
|
||||
test('resolveLogFileName: session mode with missing sessionId falls back to single (never emits malformed name)', () => {
|
||||
assert.equal(
|
||||
resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode: 'session' }),
|
||||
@ -102,12 +112,17 @@ test('stripModeStampFromFileName: strips a session-stamp suffix (with and withou
|
||||
);
|
||||
});
|
||||
|
||||
test('stripModeStampFromFileName: new DD-MM-YYYY-mdu-session-HH-MM resets to the default base', () => {
|
||||
assert.equal(stripModeStampFromFileName('26-06-2026-mdu-session-06-02.log'), 'fileuploader.log');
|
||||
assert.equal(stripModeStampFromFileName('26-06-2026-mdu-session-06-02-847581.log'), 'fileuploader.log');
|
||||
});
|
||||
|
||||
test('regression: resolveLogFileName(stripModeStampFromFileName(...)) is idempotent — persisting then re-resolving never compounds stamps', () => {
|
||||
// This is the exact bug shape: persist the resolved path, then on next call
|
||||
// re-resolve from the saved base — must produce the same file, not a doubled
|
||||
// session-stamped one. The fix is the strip; this test guards against
|
||||
// regressing _persistFallbackLogPath into the 3.3.35 bug.
|
||||
const sessionId = '2026-06-03_18-16-20-8132';
|
||||
const sessionId = '03-06-2026-mdu-session-18-16';
|
||||
const dailyDate = new Date(2026, 5, 3);
|
||||
for (const mode of ['daily', 'session']) {
|
||||
const date = mode === 'daily' ? dailyDate : new Date();
|
||||
@ -129,12 +144,7 @@ test('formatDateStamp: zero-pads month and day', () => {
|
||||
assert.equal(formatDateStamp(new Date(2026, 11, 31)), '2026-12-31');
|
||||
});
|
||||
|
||||
test('formatSessionStamp: produces YYYY-MM-DD_HH-MM-SS-pid', () => {
|
||||
const d = new Date(2026, 4, 28, 7, 9, 5);
|
||||
assert.equal(formatSessionStamp(d, 12345), '2026-05-28_07-09-05-12345');
|
||||
});
|
||||
|
||||
test('formatSessionStamp: omits the pid suffix when none provided', () => {
|
||||
const d = new Date(2026, 4, 28, 22, 44, 52);
|
||||
assert.equal(formatSessionStamp(d), '2026-05-28_22-44-52');
|
||||
test('formatSessionStamp: DD-MM-YYYY-mdu-session-HH-MM (no seconds/pid)', () => {
|
||||
assert.equal(formatSessionStamp(new Date(2026, 4, 28, 7, 9, 5)), '28-05-2026-mdu-session-07-09');
|
||||
assert.equal(formatSessionStamp(new Date(2026, 4, 28, 22, 44, 52)), '28-05-2026-mdu-session-22-44');
|
||||
});
|
||||
|
||||
@ -26,14 +26,20 @@ describe('suspect-reject alternate accounts', () => {
|
||||
fileProbe.probeFileHead = (...a) => mockProbe(...a);
|
||||
|
||||
const fs = require('fs');
|
||||
const fakeSize = (p) => {
|
||||
const m = /-(\d+)gb/i.exec(p);
|
||||
return { size: (m ? parseInt(m[1], 10) : 3) * 1024 * 1024 * 1024 };
|
||||
};
|
||||
const origStatSync = fs.statSync;
|
||||
fs.statSync = function (p) {
|
||||
if (typeof p === 'string' && p.startsWith('/test/')) {
|
||||
const m = /-(\d+)gb/i.exec(p);
|
||||
return { size: (m ? parseInt(m[1], 10) : 3) * 1024 * 1024 * 1024 };
|
||||
}
|
||||
if (typeof p === 'string' && p.startsWith('/test/')) return fakeSize(p);
|
||||
return origStatSync.call(this, p);
|
||||
};
|
||||
const origStat = fs.promises.stat;
|
||||
fs.promises.stat = async function (p) {
|
||||
if (typeof p === 'string' && p.startsWith('/test/')) return fakeSize(p);
|
||||
return origStat.call(this, p);
|
||||
};
|
||||
|
||||
UploadManager = require('../lib/upload-manager');
|
||||
});
|
||||
|
||||
@ -33,7 +33,7 @@ describe('UploadManager', () => {
|
||||
hosters.uploadFile = mockUploadFile;
|
||||
hosters.prefetchBaseline = async () => null;
|
||||
|
||||
// Mock fs.statSync for test file paths
|
||||
// Mock fs.statSync + fs.promises.stat for test file paths
|
||||
const fs = require('fs');
|
||||
const origStatSync = fs.statSync;
|
||||
fs.statSync = function(p) {
|
||||
@ -42,6 +42,13 @@ describe('UploadManager', () => {
|
||||
}
|
||||
return origStatSync.call(this, p);
|
||||
};
|
||||
const origStat = fs.promises.stat;
|
||||
fs.promises.stat = async function(p) {
|
||||
if (typeof p === 'string' && p.startsWith('/test/')) {
|
||||
return { size: fakeFileSize };
|
||||
}
|
||||
return origStat.call(this, p);
|
||||
};
|
||||
|
||||
UploadManager = require('../lib/upload-manager');
|
||||
});
|
||||
@ -331,10 +338,10 @@ describe('UploadManager', () => {
|
||||
});
|
||||
|
||||
it('file not found produces descriptive error', async () => {
|
||||
// Override fs.statSync to throw ENOENT for a specific path
|
||||
// Override fs.promises.stat to throw ENOENT for a specific path
|
||||
const fs = require('fs');
|
||||
const origStat = fs.statSync;
|
||||
fs.statSync = function(p) {
|
||||
const origStat = fs.promises.stat;
|
||||
fs.promises.stat = async function(p) {
|
||||
if (p === '/test/deleted.mp4') throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
return origStat.call(this, p);
|
||||
};
|
||||
@ -347,7 +354,7 @@ describe('UploadManager', () => {
|
||||
{ file: '/test/deleted.mp4', hoster: 'doodstream.com', apiKey: 'key1' }
|
||||
]);
|
||||
|
||||
fs.statSync = origStat;
|
||||
fs.promises.stat = origStat;
|
||||
assert.ok(errors.some(e => e.includes('nicht gefunden')), `expected "nicht gefunden" error, got: ${errors.join(', ')}`);
|
||||
});
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user