From 2d8b3f1bf90ad30422438350d3f8b9e8e149f7c2 Mon Sep 17 00:00:00 2001 From: Administrator Date: Sun, 19 Apr 2026 14:02:34 +0200 Subject: [PATCH] =?UTF-8?q?perf:=20final=20sweep=20=E2=80=94=20hot-path=20?= =?UTF-8?q?allocation,=20cached=20log=20target,=20sort-header=20skip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Last round of targeted wins: - upload-manager progress callback was allocating a fresh { jobId, speedKbs, bytesUploaded } object on every fs stream chunk (hundreds of times per second per active job). Now a single entry is created at job start and mutated in place — zero allocations on the steady-state progress tick. - upload-manager stats timer's two separate activeJobs.values() scans (globalSpeedKbs + inProgressBytes) merged into one pass. - clouddrop-upload.js reuses a single Buffer.allocUnsafe(chunkSize) across all chunks, taking subarray() only for the tail chunk. A 1 GB upload no longer allocates 64× 16 MB = 1 GB of short-lived buffers — real GC relief during many-file batches. - _resolveUploadLogTarget is now cached; the fallback ladder runs once per session (or when the user changes the log path / daily-log date rolls), not on every 500ms flush. - renderRecentUploadsPanel skips updateRecentSortHeaders on the append-only fast path — sort state hasn't changed, headers don't need recomputing. --- lib/clouddrop-upload.js | 14 ++++++++++---- lib/upload-manager.js | 16 ++++++++++------ main.js | 29 +++++++++++++++++++++++++---- renderer/app.js | 5 ++++- 4 files changed, 49 insertions(+), 15 deletions(-) diff --git a/lib/clouddrop-upload.js b/lib/clouddrop-upload.js index 16a57c0..76e7ff9 100644 --- a/lib/clouddrop-upload.js +++ b/lib/clouddrop-upload.js @@ -155,9 +155,13 @@ class ClouddropUploader { const totalChunks = initPayload.totalChunks || Math.ceil(fileSize / chunkSize); if (!sessionId) throw new Error('Clouddrop: Keine sessionId von /upload/init'); - // 2. Read file and PUT chunks sequentially + // 2. Read file and PUT chunks sequentially. + // Reuse a single buffer for all chunks (only the last chunk may be smaller, + // in which case we slice a view). Avoids 64× 16 MB allocations on a 1 GB + // file — real GC pressure during busy uploads. const fd = fs.openSync(filePath, 'r'); let bytesSent = 0; + const reusableBuf = Buffer.allocUnsafe(chunkSize); try { for (let i = 0; i < totalChunks; i++) { if (signal && signal.aborted) throw new Error('Aborted'); @@ -165,8 +169,10 @@ class ClouddropUploader { const offset = i * chunkSize; const remaining = fileSize - offset; const thisChunkSize = Math.min(chunkSize, remaining); - const buf = Buffer.alloc(thisChunkSize); - fs.readSync(fd, buf, 0, thisChunkSize, offset); + fs.readSync(fd, reusableBuf, 0, thisChunkSize, offset); + const body = thisChunkSize === chunkSize + ? reusableBuf + : reusableBuf.subarray(0, thisChunkSize); if (throttle) await throttle.consume(thisChunkSize, signal); @@ -174,7 +180,7 @@ class ClouddropUploader { method: 'PUT', dispatcher: clouddropAgent, signal, - body: buf, + body, headers: this._headers({ 'Content-Type': 'application/octet-stream', 'Content-Length': String(thisChunkSize) diff --git a/lib/upload-manager.js b/lib/upload-manager.js index faec593..5b4b6f3 100644 --- a/lib/upload-manager.js +++ b/lib/upload-manager.js @@ -361,7 +361,11 @@ class UploadManager extends EventEmitter { }, 2000); } - this.activeJobs.set(uploadId, { jobId, speedKbs: 0, bytesUploaded: 0 }); + // Mutate this single object on each progress callback instead of + // allocating a fresh one — callback fires on every stream chunk + // (hundreds/sec per active job). + const activeEntry = { jobId, speedKbs: 0, bytesUploaded: 0 }; + this.activeJobs.set(uploadId, activeEntry); let lastEmitTime = 0; const PROGRESS_EMIT_INTERVAL = 250; // ms – throttle UI updates @@ -377,7 +381,8 @@ class UploadManager extends EventEmitter { lastSpeedTime = now; } - this.activeJobs.set(uploadId, { jobId, speedKbs: currentSpeedKbs, bytesUploaded }); + activeEntry.speedKbs = currentSpeedKbs; + activeEntry.bytesUploaded = bytesUploaded; // Throttle progress emissions to reduce IPC + rendering overhead if (now - lastEmitTime < PROGRESS_EMIT_INTERVAL) return; @@ -582,18 +587,17 @@ class UploadManager extends EventEmitter { _startStatsTimer() { if (this.statsInterval) clearInterval(this.statsInterval); this.statsInterval = setInterval(() => { + // Single pass over active jobs instead of two. let globalSpeedKbs = 0; let activeCount = 0; + let inProgressBytes = 0; for (const job of this.activeJobs.values()) { globalSpeedKbs += job.speedKbs || 0; + inProgressBytes += job.bytesUploaded || 0; activeCount++; } const elapsed = Math.round((Date.now() - this.startTime) / 1000); - let inProgressBytes = 0; - for (const job of this.activeJobs.values()) { - inProgressBytes += job.bytesUploaded || 0; - } this.emit('stats', { state: this.running ? (this.stopAfterActive ? 'stopping' : 'uploading') : 'idle', diff --git a/main.js b/main.js index fd62423..814a16e 100644 --- a/main.js +++ b/main.js @@ -165,12 +165,33 @@ const _uploadLogBuffer = []; let _uploadLogFlushTimer = null; let _uploadLogWriting = false; +// Cache the resolved upload-log target across flushes — mkdirSync + path +// assembly on every 500ms flush during uploads is wasted work once we've +// confirmed a writable directory. Invalidated when the user changes the log +// path or when the daily-log date rolls over. +let _cachedUploadLogTarget = null; +let _cachedUploadLogKey = ''; + +function _invalidateUploadLogTargetCache() { + _cachedUploadLogTarget = null; + _cachedUploadLogKey = ''; +} + function _resolveUploadLogTarget() { - // Try primary → desktop → userData, mirror the original fallback ladder. const primary = getLogFilePath(); + const key = `${primary}|${_dailyLogDate || ''}`; + if (_cachedUploadLogKey === key && _cachedUploadLogTarget) return _cachedUploadLogTarget; + + const commit = (t) => { + _cachedUploadLogTarget = t; + _cachedUploadLogKey = key; + return t; + }; + + // Try primary → desktop → userData, mirror the original fallback ladder. try { fs.mkdirSync(path.dirname(primary), { recursive: true }); - return { path: primary, isFallback: false }; + return commit({ path: primary, isFallback: false }); } catch (err) { debugLog(`uploadLog primary dir unavailable (${err.message})`); } @@ -179,13 +200,13 @@ function _resolveUploadLogTarget() { try { const p = buildFallbackLogName(desktop); fs.mkdirSync(path.dirname(p), { recursive: true }); - return { path: p, isFallback: true }; + return commit({ path: p, isFallback: true }); } catch {} } try { const p = buildFallbackLogName(app.getPath('userData')); fs.mkdirSync(path.dirname(p), { recursive: true }); - return { path: p, isFallback: true }; + return commit({ path: p, isFallback: true }); } catch (err) { debugLog(`uploadLog: no writable target (${err.message})`); return null; diff --git a/renderer/app.js b/renderer/app.js index 581c8af..5ae6cbc 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -3264,6 +3264,7 @@ function renderRecentUploadsPanel() { && rows.length > _recentLastRenderedLen && tbody.querySelectorAll('.recent-file-row').length === _recentLastRenderedLen; + let wasAppendOnly = false; if (dateDescAppendOnly) { // Fast path: only new rows (date desc puts newest on top) — insert them // at the top without rebuilding the 5000-row tbody below. @@ -3271,6 +3272,7 @@ function renderRecentUploadsPanel() { let html = ''; for (let i = 0; i < added; i++) html += _buildRecentRowHtml(rows[i]); tbody.insertAdjacentHTML('afterbegin', html); + wasAppendOnly = true; } else { tbody.innerHTML = rows.map(_buildRecentRowHtml).join(''); } @@ -3316,7 +3318,8 @@ function renderRecentUploadsPanel() { }); } - updateRecentSortHeaders(); + // Sort headers only change when the sort state changes — skip on appends. + if (!wasAppendOnly) updateRecentSortHeaders(); } function renderHistoryTable(container) {