Compare commits
4 Commits
939d30abfe
...
c37c8e3906
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c37c8e3906 | ||
|
|
9cc8fee02c | ||
|
|
0f9096be3c | ||
|
|
29d1944328 |
@ -54,6 +54,7 @@ const nodeGlobals = {
|
||||
URLSearchParams: 'readonly',
|
||||
fetch: 'readonly',
|
||||
crypto: 'readonly',
|
||||
structuredClone: 'readonly',
|
||||
};
|
||||
|
||||
export default [
|
||||
|
||||
@ -185,6 +185,8 @@ class ConfigStore {
|
||||
: path.join(__dirname, '..');
|
||||
this.filePath = path.join(dir, 'electron-config.json');
|
||||
this._writeQueue = Promise.resolve(); // Serializes all writes to prevent race conditions
|
||||
this._cache = null;
|
||||
this._cacheKey = '';
|
||||
|
||||
// Migrate config from old location if current doesn't exist
|
||||
if (!fs.existsSync(this.filePath) && app && app.isPackaged) {
|
||||
@ -221,8 +223,29 @@ class ConfigStore {
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
_clone(obj) {
|
||||
try { return structuredClone(obj); }
|
||||
catch { return JSON.parse(JSON.stringify(obj)); }
|
||||
}
|
||||
|
||||
load() {
|
||||
try {
|
||||
// In-memory cache keyed on the file's mtime+size. The processed config
|
||||
// (merged + credential-decrypted) is reparsed/re-decrypted from disk ONLY
|
||||
// when the file actually changes. Our own writes refresh the cache (see
|
||||
// _commit), and an external edit changes mtime/size so the cache misses
|
||||
// and we reread. Without this, every one of the ~38 main.js load() call
|
||||
// sites (incl. the per-500ms log-flush path) re-read disk + JSON.parse the
|
||||
// whole growing history + DPAPI-decrypt every credential — the dominant
|
||||
// long-running main-thread drag. load() always returns a CLONE so callers
|
||||
// can mutate the result without corrupting the cache.
|
||||
let stat = null;
|
||||
try { stat = fs.statSync(this.filePath); } catch {}
|
||||
const statKey = stat ? `${stat.mtimeMs}:${stat.size}` : '';
|
||||
if (stat && this._cache && this._cacheKey === statKey) {
|
||||
return this._clone(this._cache);
|
||||
}
|
||||
|
||||
let data = null;
|
||||
// Try main config
|
||||
try { data = this._readAndParse(this.filePath); } catch {}
|
||||
@ -309,7 +332,11 @@ class ConfigStore {
|
||||
// Decrypt credentials stored with safeStorage so the rest of the app
|
||||
// keeps working with plaintext in memory.
|
||||
secretStore.decryptCredentials(result);
|
||||
return result;
|
||||
if (stat) {
|
||||
this._cache = result;
|
||||
this._cacheKey = statKey;
|
||||
}
|
||||
return this._clone(result);
|
||||
} catch {
|
||||
const fresh = JSON.parse(JSON.stringify(DEFAULTS));
|
||||
fresh.globalSettings.logMode = normalizeLogMode(fresh.globalSettings);
|
||||
@ -317,12 +344,19 @@ class ConfigStore {
|
||||
}
|
||||
}
|
||||
|
||||
// Deep-clone a config and encrypt its credential fields. Never mutate the
|
||||
// caller's object — the rest of the app holds plaintext references.
|
||||
// Encrypt credential fields without mutating the caller's plaintext object.
|
||||
// Only `hosters` carries credentials, so we clone ONLY that subtree — the rest
|
||||
// (history, globalSettings, …) is referenced read-only into the stringified
|
||||
// object. Deep-cloning the whole config here (incl. an ever-growing history)
|
||||
// on every write was a primary long-running main-thread stall.
|
||||
_serializeForDisk(config) {
|
||||
const clone = JSON.parse(JSON.stringify(config));
|
||||
secretStore.encryptCredentials(clone);
|
||||
return JSON.stringify(clone, null, 2);
|
||||
const hosters = this._clone(config.hosters || {});
|
||||
secretStore.encryptCredentials({ hosters });
|
||||
return JSON.stringify({ ...config, hosters }, null, 2);
|
||||
}
|
||||
|
||||
_commit(config) {
|
||||
return this._atomicWrite(this._serializeForDisk(config));
|
||||
}
|
||||
|
||||
_enqueueWrite(fn) {
|
||||
@ -336,7 +370,7 @@ class ConfigStore {
|
||||
if (config.hosters) current.hosters = config.hosters;
|
||||
if (config.hosterSettings) current.hosterSettings = config.hosterSettings;
|
||||
if (config.globalSettings) current.globalSettings = config.globalSettings;
|
||||
return this._atomicWrite(this._serializeForDisk(current));
|
||||
return this._commit(current);
|
||||
});
|
||||
}
|
||||
|
||||
@ -352,25 +386,24 @@ class ConfigStore {
|
||||
fs.writeFile(tmpPath, data, 'utf-8', (err) => {
|
||||
if (err) return reject(err);
|
||||
try {
|
||||
// Refresh .bak from the previous live file. Wrapped in try/catch
|
||||
// so an AV/indexer briefly locking the file doesn't fail the whole
|
||||
// save — the rename to the live path is the part that matters,
|
||||
// a stale .bak is preferable to losing the new write entirely.
|
||||
// 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)) {
|
||||
const existing = fs.readFileSync(this.filePath, 'utf-8');
|
||||
if (existing && existing.trim().length > 2) {
|
||||
let isValid = false;
|
||||
try {
|
||||
const parsed = JSON.parse(existing);
|
||||
isValid = parsed && typeof parsed === 'object' && (parsed.hosters || parsed.hosterSettings || parsed.globalSettings);
|
||||
} catch {}
|
||||
if (isValid) fs.writeFileSync(backupPath, existing, 'utf-8');
|
||||
}
|
||||
fs.copyFileSync(this.filePath, backupPath);
|
||||
}
|
||||
} catch {}
|
||||
fs.renameSync(tmpPath, this.filePath);
|
||||
} catch (e) { return reject(e); }
|
||||
// Invalidate the read cache: the next load() re-reads + re-merges the
|
||||
// freshly-written file (the on-disk format is sparse — load() fills
|
||||
// defaults — so we must NOT serve a pre-merge in-memory object).
|
||||
this._cache = null;
|
||||
this._cacheKey = '';
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
@ -382,7 +415,7 @@ class ConfigStore {
|
||||
config.history.push(entry);
|
||||
const retention = (config.globalSettings && config.globalSettings.historyRetention) || 'all';
|
||||
config.history = applyHistoryRetention(config.history, retention, Date.now());
|
||||
return this._atomicWrite(this._serializeForDisk(config));
|
||||
return this._commit(config);
|
||||
});
|
||||
}
|
||||
|
||||
@ -402,7 +435,7 @@ class ConfigStore {
|
||||
if (dryRun) return result;
|
||||
config.history = pruned;
|
||||
if (config.globalSettings) config.globalSettings.historyRetention = String(retention || 'all');
|
||||
return this._atomicWrite(this._serializeForDisk(config)).then(() => result);
|
||||
return this._commit(config).then(() => result);
|
||||
});
|
||||
}
|
||||
|
||||
@ -410,7 +443,7 @@ class ConfigStore {
|
||||
return this._enqueueWrite(() => {
|
||||
const config = this.load();
|
||||
config.history = [];
|
||||
return this._atomicWrite(this._serializeForDisk(config));
|
||||
return this._commit(config);
|
||||
});
|
||||
}
|
||||
|
||||
@ -418,7 +451,7 @@ class ConfigStore {
|
||||
return this._enqueueWrite(() => {
|
||||
const config = this.load();
|
||||
config.rotationCursors = (cursors && typeof cursors === 'object' && !Array.isArray(cursors)) ? cursors : {};
|
||||
return this._atomicWrite(this._serializeForDisk(config));
|
||||
return this._commit(config);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
34
main.js
34
main.js
@ -414,11 +414,26 @@ function getDefaultLogFilePath() {
|
||||
return path.join(__dirname, 'fileuploader.log');
|
||||
}
|
||||
|
||||
// The log flush paths resolve the log file ~8x/second during uploads. Going
|
||||
// through configStore.load() there meant re-reading + cloning the whole config
|
||||
// (incl. an 8 MB+ history) on every flush — a major long-running main-thread
|
||||
// drag. logFilePath/logMode change only when the user saves settings, so cache
|
||||
// the two strings and invalidate on those saves (see _invalidateLogSettings).
|
||||
let _cachedLogSettings = null;
|
||||
function _getLogSettings() {
|
||||
if (!_cachedLogSettings) {
|
||||
const gs = (configStore.load() || {}).globalSettings || {};
|
||||
_cachedLogSettings = {
|
||||
logFilePath: String(gs.logFilePath || '').trim(),
|
||||
logMode: gs.logMode || 'single'
|
||||
};
|
||||
}
|
||||
return _cachedLogSettings;
|
||||
}
|
||||
function _invalidateLogSettings() { _cachedLogSettings = null; }
|
||||
|
||||
function getBaseLogFilePath() {
|
||||
const config = configStore.load();
|
||||
const customPath = config && config.globalSettings
|
||||
? String(config.globalSettings.logFilePath || '').trim()
|
||||
: '';
|
||||
const customPath = _getLogSettings().logFilePath;
|
||||
return customPath || getDefaultLogFilePath();
|
||||
}
|
||||
|
||||
@ -433,8 +448,7 @@ let _activeLogKey = null; // remembers (mode + date-or-session) so cache rolls
|
||||
let _activeLogPath = null;
|
||||
|
||||
function getLogFilePath() {
|
||||
const config = configStore.load();
|
||||
const mode = (config && config.globalSettings && config.globalSettings.logMode) || 'single';
|
||||
const mode = _getLogSettings().logMode;
|
||||
const base = getBaseLogFilePath();
|
||||
const dir = path.dirname(base);
|
||||
const ext = path.extname(base);
|
||||
@ -454,8 +468,7 @@ function getLogFilePath() {
|
||||
function buildFallbackLogName(dir) {
|
||||
// Match the active log-mode's naming so the fallback file is consistent with
|
||||
// what the primary write would have produced.
|
||||
const config = configStore.load();
|
||||
const mode = (config && config.globalSettings && config.globalSettings.logMode) || 'single';
|
||||
const mode = _getLogSettings().logMode;
|
||||
return path.join(dir, resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode, date: new Date(), sessionId: SESSION_ID }));
|
||||
}
|
||||
|
||||
@ -596,6 +609,7 @@ function _persistFallbackLogPath(workingPath) {
|
||||
cfg.globalSettings = gs;
|
||||
configStore.save({ globalSettings: gs }).catch(() => {});
|
||||
_invalidateUploadLogTargetCache();
|
||||
_invalidateLogSettings();
|
||||
safeSend('log-path-auto-updated', { logFilePath: toSave });
|
||||
} catch (err) {
|
||||
debugLog(`persist fallback logpath failed: ${err.message}`);
|
||||
@ -1315,6 +1329,7 @@ ipcMain.handle('get-config', () => {
|
||||
|
||||
ipcMain.handle('save-config', async (_event, config) => {
|
||||
await configStore.save(config);
|
||||
if (config && config.globalSettings) _invalidateLogSettings();
|
||||
try {
|
||||
if (config && config.globalSettings && Object.prototype.hasOwnProperty.call(config.globalSettings, 'logVerbose')) {
|
||||
setLogVerbose(!!config.globalSettings.logVerbose);
|
||||
@ -2152,6 +2167,7 @@ ipcMain.handle('import-backup', async (_event, legacyPassword) => {
|
||||
history: []
|
||||
};
|
||||
await configStore._atomicWrite(configStore._serializeForDisk(merged));
|
||||
_invalidateLogSettings();
|
||||
return { ok: true, config: configStore.load() };
|
||||
});
|
||||
|
||||
@ -2286,6 +2302,7 @@ function _preserveDiagSubtree(globalSettings) {
|
||||
ipcMain.handle('save-global-settings', async (_event, globalSettings) => {
|
||||
globalSettings = _preserveDiagSubtree(globalSettings);
|
||||
await configStore.save({ globalSettings });
|
||||
_invalidateLogSettings();
|
||||
if (uploadManager) uploadManager.updateSettings(null, globalSettings);
|
||||
return true;
|
||||
});
|
||||
@ -2328,6 +2345,7 @@ ipcMain.on('save-global-settings-sync', (event, globalSettings) => {
|
||||
const _diskDiag = current.globalSettings && current.globalSettings.diagnostics;
|
||||
current.globalSettings = globalSettings;
|
||||
if (_diskDiag) current.globalSettings.diagnostics = _diskDiag;
|
||||
_invalidateLogSettings();
|
||||
const data = configStore._serializeForDisk(current);
|
||||
const backupPath = configStore.filePath + '.bak';
|
||||
fs.writeFileSync(tmpPath, data, 'utf-8');
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "multi-hoster-uploader",
|
||||
"version": "3.3.86",
|
||||
"version": "3.3.87",
|
||||
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
||||
@ -1177,7 +1177,7 @@ let _recentRenderQueued = false;
|
||||
function scheduleRecentRender() {
|
||||
if (_recentRenderQueued) return;
|
||||
_recentRenderQueued = true;
|
||||
requestAnimationFrame(() => { _recentRenderQueued = false; renderRecentUploadsPanel(); });
|
||||
requestAnimationFrame(() => { _recentRenderQueued = false; renderRecentUploadsPanel(true); });
|
||||
}
|
||||
|
||||
// Toggle the .selected class on existing rows without rebuilding the table.
|
||||
@ -2768,6 +2768,7 @@ function maybeAddSessionFile(job) {
|
||||
});
|
||||
_recentDataVersion++;
|
||||
_sessionDoneCount++;
|
||||
_recentPendingAppends++;
|
||||
// Drop oldest entries past the cap to keep render cost bounded.
|
||||
// Without this, sessionFilesData grows unbounded across the session
|
||||
// and every renderRecentUploadsPanel call becomes a megabyte-sized
|
||||
@ -4672,10 +4673,13 @@ function _buildRecentRowHtml(row) {
|
||||
// accumulating new uploads (the default case: sort=date desc, rows only grow).
|
||||
let _recentLastRenderedSig = '';
|
||||
let _recentLastRenderedLen = 0;
|
||||
let _recentPendingAppends = 0;
|
||||
|
||||
function renderRecentUploadsPanel() {
|
||||
function renderRecentUploadsPanel(appendOnly = false) {
|
||||
const tbody = document.getElementById('recentFilesBody');
|
||||
if (!tbody) return;
|
||||
const pendingAppends = _recentPendingAppends;
|
||||
_recentPendingAppends = 0;
|
||||
if (!sessionFilesData.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="empty-state">Noch keine Uploads in dieser Session.</td></tr>';
|
||||
_recentLastRenderedSig = '';
|
||||
@ -4685,9 +4689,10 @@ function renderRecentUploadsPanel() {
|
||||
|
||||
const rows = sortRecentFiles(sessionFilesData);
|
||||
const sig = `${recentSortState.key}|${recentSortState.direction}`;
|
||||
const dateDescAppendOnly = sig === 'date|desc'
|
||||
const dateDescAppendOnly = appendOnly
|
||||
&& pendingAppends > 0
|
||||
&& sig === 'date|desc'
|
||||
&& _recentLastRenderedSig === sig
|
||||
&& rows.length > _recentLastRenderedLen
|
||||
&& tbody.querySelectorAll('.recent-file-row').length === _recentLastRenderedLen;
|
||||
|
||||
const wrap = tbody.closest('.recent-files-table-wrap');
|
||||
@ -4695,10 +4700,17 @@ function renderRecentUploadsPanel() {
|
||||
|
||||
let wasAppendOnly = false;
|
||||
if (dateDescAppendOnly) {
|
||||
const added = rows.length - _recentLastRenderedLen;
|
||||
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;
|
||||
} else {
|
||||
tbody.innerHTML = rows.map(_buildRecentRowHtml).join('');
|
||||
|
||||
@ -130,3 +130,12 @@
|
||||
**Was der Downloader anders macht (und warum es Tailscale ermöglicht):** Host steckt IM Code (`{v,h,p,t,n,fp?,s?}`), nicht extern. Zwei Bind-Modi: lokal (127.0.0.1) ODER Netzwerk (0.0.0.0) — letzteres NUR mit nicht-leerer **fail-closed IP-Allowlist**: leere Allowlist = nur Loopback; geprüft am ECHTEN socket.remoteAddress (NIE forwarded-Header), `::ffff:`-normalisiert, CIDR-Matching. Tailscale wird NICHT autodetektiert — es ist nur eine der `os.networkInterfaces()`-IPs, erreicht über den Tunnel; Allowlist (auf den Tailnet, z.B. `100.64.0.0/10`) + Token sind das Gate, WireGuard ist die Verschlüsselung.
|
||||
**Regel:** Bei "mach es wie X": X lokalisieren (grep), die security-kritischen Teile mit einem Subagenten verbatim mappen, dann replizieren. Eine fail-closed Allowlist (Loopback immer erlaubt, leer=loopback-only, real peer IP) ist das richtige Modell für netzwerk-erreichbare read-only Diagnose über einen vertrauten Tunnel — plaintext-Transport ist ok, WENN der Tunnel (Tailscale/WireGuard) verschlüsselt UND die Allowlist+Token den Zugriff gaten. Den HAPPY-Path (allowlisted non-loopback peer über echten 0.0.0.0-Socket) auch LIVE testen, nicht nur per Komposition aus Unit+Wiring.
|
||||
**Prozess-Stolperstein:** Test NACH dem Feature-Commit hinzugefügt → release_gitea.mjs brach ab ("uncommitted tracked changes"). Vor jedem Release: `git status --porcelain | grep -v '^??'` muss leer sein. Tracked-aber-uncommitted (auch ein nachgereichter Test) blockt den Build.
|
||||
|
||||
## 2026-06-21 — "Gefühlt laggy nach Zeit, CPU/RAM normal" = ERST messen, dann in echtem Blink profilen (v3.3.87)
|
||||
**Kontext:** User: Programm fühlt sich nach langer Laufzeit mit vielen Uploads zäh an, CPU ~40%/8 Kerne, RAM 6/32 GB — beide normal/stabil. Erste Hypothese (Hauptprozess-Config-I/O skaliert mit wachsender History) war für DIESEN User FALSCH.
|
||||
**Was es wirklich war (gemessen + profiliert):** `renderRecentUploadsPanel` hatte einen Append-only-Fastpath, gegated auf `rows.length > _recentLastRenderedLen`. `maybeAddSessionFile` capped per push-then-slice (2000→2001→zurück auf 2000). Ab dem Cap ist `rows.length` auf 2000 fixiert → Gate für IMMER false → JEDE Completion fiel in den Full-`innerHTML`-Rebuild von 2000 Zeilen. Blink-Messung (Playwright, table-layout:fixed, gleiche Engine wie Electron): **~80 ms pro Completion** → wiederkehrender 80-ms-Freeze. Fix (append-evict, Gate auf `pendingAppends>0`, Overflow vom DOM-Boden evicten): **80 ms → 7,4 ms** (>10×), DOM bleibt exakt == Daten (Cap/Reihenfolge/keine Dupes), über 5000 Completions verifiziert.
|
||||
**Regel 1 — Magnituden NICHT raten, LESEN:** „wächst über Zeit" ist eine Annahme über GRÖSSE. Die echte electron-config.json war 52 KB (History 23 Zeilen) — ein einziger `node`-Read killte die ganze Config-I/O-Theorie. Bevor man eine „skaliert-mit-X"-Ursache fixt: X am echten Artefakt messen (Dateigröße, Array-Länge, Job-Count im persistierten State).
|
||||
**Regel 2 — Im ECHTEN Renderer-Engine profilen, nicht analytisch raten:** jsdom rendert kein Blink-Layout. Playwright (Chromium = Electron-Blink) mit `performance.now()` um (a) Rebuild und (b) erzwungenes Relayout nach Style-Write liefert die Zahl, die entscheidet: 3 ms = unsichtbar, 80 ms = DIE Ursache. Dieselbe Messung ist Fix-Auswahl UND Vorher/Nachher-Verifikation (das Goal verlangt „verifiziere dass behoben" — ein grüner Test beweist Korrektheit, NICHT dass der Lag weg ist).
|
||||
**Regel 3 — Multi-Agent-Findings gegen primäre Evidenz prüfen (Control-Char-Falschpositiv):** Der Hunt meldete HIGH-ish einen „_sessionFileKeys delete-key separator mismatch". Beim Versuch ihn zu fixen matchte der Edit-`old_string` NICHT. Char-Code-Dump (`HAS_U0001: True`) zeigte: die Zeile hat ECHTE U+0001-Zeichen — die Read-Tools der Verifier-Agenten rendern Steuerzeichen unsichtbar, sie schlossen fälschlich „keine Separatoren". KEIN Bug. **Wenn ein Fix-`old_string` nicht matcht obwohl Grep ihn zeigt: Char-Codes dumpen, bevor man dem Tool misstraut — die Quelle kann unsichtbar von der Read-Anzeige abweichen.**
|
||||
**Regel 4 — Den negligible-aber-realen Befund mit Zahl ABLEHNEN, nicht aus dem Bauch:** queueJobs O(N)-Scan pro Render (wächst unbounded, da removeFromQueueOnDone=false UND Folder-Monitor EINEN Batch via addJobs am Leben hält → 500-Cap-Prune feuert nie) — real, aber Blink-gemessen <0,1 ms bei 3000 Jobs. Den riskanten Inkremental-Counter-Refactor mit DIESER Zahl skippen, nicht mit „fühlt sich klein an".
|
||||
**Wie anwenden:** Append-only-Optimierungen, die auf Längenwachstum gaten, brechen still an JEDEM Cap (push-then-slice fixiert die Länge) — stattdessen die Anzahl NEUER Items zählen und am Boden evicten. „Mach es wie die Queue-Tabelle (virtualisieren)" war hier NICHT nötig: die Messung zeigte stehende 2000 Zeilen kosten median 0,4 ms; nur der Rebuild war teuer. Simplest-Fix der die gemessene Ursache trifft schlägt die größere Architektur-Änderung.
|
||||
|
||||
@ -1,36 +1,52 @@
|
||||
# Tailscale / network-bind diagnostics — match the downloader (rd-diagnostics-mcp)
|
||||
# Long-running lag — root cause + fix (symptom: UI laggy over time, CPU 40%/RAM 6GB both normal)
|
||||
|
||||
Goal: make the MHU read-only diagnostics reachable like the Real-Debrid-Downloader does over
|
||||
Tailscale — host embedded in the connection code, two bind modes (local / network), and a
|
||||
fail-closed IP allowlist as the access gate. No Tailscale auto-detection (the downloader has none);
|
||||
Tailscale is just one of the offered interface IPs reached over the tunnel, gated by the allowlist.
|
||||
## What "laggy after time, low CPU, stable RAM" actually was (MEASURED, not assumed)
|
||||
First instinct (main-process config I/O scaling with history) was WRONG for this user: the
|
||||
real config is tiny (history 23 rows / 4.8 KB, total 52 KB, queue ~153 jobs). Measured with a
|
||||
one-off node read of the live electron-config.json. So serialize-cost theories were dead on arrival.
|
||||
|
||||
## Plan
|
||||
- [ ] lib/ip-allowlist.js — fail-closed allowlist (normalizeIp/::ffff:, isLoopback, ipv4ToInt, matchIpRule exact+CIDR+wildcard, evaluateClientAllowed: loopback always, empty=loopback-only). + unit tests.
|
||||
- [ ] remote-server.js — accept config.allowlist; reject non-allowlisted peers at connection (close 4005). + protocol test.
|
||||
- [ ] config-store.js — diagnostics subtree: bindMode ('local'), publicHost (''), allowlist ([]).
|
||||
- [ ] main.js — bindMode->host (local=127.0.0.1, network=0.0.0.0, network requires non-empty allowlist); buildDiagnosticCode with host (h); getSuggestedRemoteHosts (os.networkInterfaces); pass allowlist; IPC save-settings/status.
|
||||
- [ ] gateway/code.js — decode h/p/t/n/fp/s (tolerant of old port/token/label). + test.
|
||||
- [ ] gateway/index.js — connect_server takes host from the code; host arg optional override.
|
||||
- [ ] renderer/app.js — bind-mode selector, publicHost input + suggested-host chips, allowlist textarea (network), network-requires-allowlist validation.
|
||||
- [ ] docs/remote-diagnostics-setup.md — network mode + allowlist + Tailscale (set allowlist to your tailnet, e.g. 100.64.0.0/10).
|
||||
- [ ] Tests: ip-allowlist unit, remote-server allowlist protocol, gateway decode, integration (network bind + allowlist), adversarial fail-closed (empty allowlist rejects non-loopback).
|
||||
- [ ] Release v3.3.86 (gitea + github mirror).
|
||||
A 13→18-agent leak hunt + adversarial verify + a Playwright/Blink microbenchmark found the
|
||||
真 cause:
|
||||
|
||||
## Review (done)
|
||||
All steps implemented and verified. lib/ip-allowlist.js (fail-closed, ::ffff:, CIDR incl.
|
||||
100.64.0.0/10) + 8 unit tests. remote-server.js rejects non-allowlisted peers (close 4005),
|
||||
opt-in via config.allowlist (existing remote-control unaffected) + 2 protocol tests (fail-closed
|
||||
wiring + loopback-always-allowed). Code now carries the host (mhu1_{v,h,p,t,n,fp?,s?}); gateway
|
||||
decode is tolerant of the legacy long keys; connect_server takes the host from the code (host arg
|
||||
optional override) — proven end-to-end by the integration harness connecting with NO host arg.
|
||||
Renderer: bind-mode selector + public-host input + suggested-host chips + allowlist textarea +
|
||||
network-requires-allowlist validation. Docs rewritten for Tailscale (set allowlist to the tailnet,
|
||||
put the Tailscale IP/MagicDNS in the code address). 393 app tests + 9 gateway tests + e2e +
|
||||
integration + adversarial all green, lint 0 errors.
|
||||
### ROOT CAUSE (confirmed + profiled): recent-uploads panel append-only path defeats itself at the cap
|
||||
- `renderRecentUploadsPanel` had a cheap append-only fast path gated on `rows.length > _recentLastRenderedLen`.
|
||||
- `maybeAddSessionFile` caps `sessionFilesData` by push-then-slice (2000 → 2001 → sliced back to 2000).
|
||||
- So once past SESSION_FILES_CAP, `rows.length` is pinned at 2000 → the gate is FALSE forever →
|
||||
EVERY completion fell through to `tbody.innerHTML = rows.map(...).join('')` — a full ~2000-row
|
||||
rebuild. Cap is per (link × file × hoster), so 4–5 hosters hit 2000 at only ~400–500 files.
|
||||
- Blink measurement (table-layout:fixed, same engine as Electron): full 2000-row rebuild = **~80 ms**
|
||||
on EVERY completion past the cap. At several completions/sec that is a repeating ~80 ms main-thread
|
||||
freeze → exactly "fine fresh, gets laggy after many uploads, CPU/RAM fine."
|
||||
|
||||
## Security model shift
|
||||
v3.3.85 hard-locked loopback. This change replaces that with the downloader's model: network bind
|
||||
(0.0.0.0) is allowed ONLY with a non-empty fail-closed IP allowlist (empty => loopback only). The
|
||||
allowlist (real socket peer, ::ffff: normalized, CIDR) + token are the gate; the tunnel
|
||||
(Tailscale/WireGuard) is the confidentiality layer. Plaintext ws:// — document the trust boundary.
|
||||
## Fix (shipped) — append-evict, keeps the panel append-only past the cap
|
||||
- Track newly-pushed rows in `_recentPendingAppends` (incremented in maybeAddSessionFile), consumed
|
||||
every render. Gate the fast path on `pendingAppends > 0` (not length-delta) so it survives the cap.
|
||||
- Prepend the new rows, then evict the same overflow count from the DOM bottom (oldest, = data front
|
||||
eviction in date-desc) to honour the cap. DOM work back to O(added).
|
||||
- Gate behind an explicit `appendOnly` flag passed ONLY by scheduleRecentRender's rAF, so selection/
|
||||
delete/clear/sort/batch-done renders stay full + correct (no wrong-row eviction, no double-prepend).
|
||||
- VERIFIED in Blink over a simulated 5000-completion session: per-frame render **80 ms → median 7.4 ms**
|
||||
(>10×), DOM stays exactly == data (cap held, newest-on-top, oldest evicted, ZERO duplicates).
|
||||
|
||||
## Also shipped earlier (29d1944) — defensible, but NOT the cause for this user
|
||||
- T1 ConfigStore in-memory cache (mtime/size keyed) + lean _serializeForDisk (clones only hosters) +
|
||||
copyFileSync .bak. T3 cache logMode/logFilePath (drop load() from the 500 ms log flush).
|
||||
|
||||
## Investigated and DISMISSED with evidence
|
||||
- `_sessionFileKeys` "delete-key separator mismatch" (workflow flagged it HIGH-ish): FALSE POSITIVE.
|
||||
Line uses REAL U+0001 chars (char-code dump: `HAS_U0001: True`); the verifier agents' Read rendered
|
||||
the control chars invisibly and wrongly concluded "no separators". Keys match at runtime. No leak.
|
||||
- queueJobs O(N) per-render scans (grows unbounded since removeFromQueueOnDone=false AND folder-monitor
|
||||
keeps ONE batch alive via addJobs so the 500-cap prune never fires): REAL but Blink-measured at
|
||||
**<0.1 ms even at 3000 jobs** → imperceptible. Incremental-counter refactor NOT worth the risk. Skipped.
|
||||
- "Continuous standing relayout of 2000 rows": Blink median 0.4 ms regardless of row count; its spikes
|
||||
were caused by the rebuild (same root cause). Not a standing cost → no need to virtualize/lower cap.
|
||||
|
||||
## Deferred (separate, not this symptom)
|
||||
- doodstream-upload.js `_debugLog`: ungated SYNC fs.appendFileSync + statSync ~10–20×/upload on the
|
||||
main loop. CONSTANT cost (does not grow over a session), only when doodstream is active. Real freeze
|
||||
contributor but NOT the reported progressive lag — convert to buffered-async like main.js, separately.
|
||||
|
||||
## Verification summary
|
||||
- 397/397 tests pass, eslint 0 errors.
|
||||
- Blink before/after: recent-panel render 80 ms → 7.4 ms; correctness asserted (cap/order/no-dupes).
|
||||
|
||||
@ -253,6 +253,32 @@ describe('ConfigStore', () => {
|
||||
assert.equal(config.globalSettings.alwaysOnTop, true);
|
||||
});
|
||||
|
||||
it('load() returns independent clones — mutating one result must not leak into the cache', () => {
|
||||
store.load(); // warm the cache
|
||||
const a = store.load();
|
||||
a.globalSettings.alwaysOnTop = true;
|
||||
a.hosters['voe.sx'].push({ id: 'mutant' });
|
||||
a.history.push({ id: 'ghost' });
|
||||
const b = store.load();
|
||||
assert.equal(b.globalSettings.alwaysOnTop, false, 'mutating a prior load() result must not corrupt the cache');
|
||||
assert.equal(b.hosters['voe.sx'].length, 0);
|
||||
assert.equal(b.history.length, 0);
|
||||
});
|
||||
|
||||
it('load() reflects an external file change (mtime/size cache invalidation)', () => {
|
||||
store.load(); // warm cache on the no-file defaults
|
||||
fs.writeFileSync(store.filePath, JSON.stringify({ globalSettings: { alwaysOnTop: true } }), 'utf-8');
|
||||
assert.equal(store.load().globalSettings.alwaysOnTop, true, 'an external write must invalidate the cache');
|
||||
fs.writeFileSync(store.filePath, JSON.stringify({ globalSettings: { alwaysOnTop: false } }), 'utf-8');
|
||||
assert.equal(store.load().globalSettings.alwaysOnTop, false, 'a second external write must be seen too');
|
||||
});
|
||||
|
||||
it('save() invalidates the cache so the next load() sees the new value', async () => {
|
||||
assert.equal(store.load().globalSettings.alwaysOnTop, false);
|
||||
await store.save({ globalSettings: { alwaysOnTop: true } });
|
||||
assert.equal(store.load().globalSettings.alwaysOnTop, true, 'load() after save() must reflect the write');
|
||||
});
|
||||
|
||||
it('backup recovery when main file is corrupted', () => {
|
||||
// Write valid config first
|
||||
fs.writeFileSync(store.filePath, JSON.stringify({
|
||||
|
||||
Loading…
Reference in New Issue
Block a user