From 9c56fabce1f71bd8fde8ff6117ad10104aa1c34a Mon Sep 17 00:00:00 2001 From: Administrator Date: Tue, 10 Mar 2026 12:30:06 +0100 Subject: [PATCH] fix: critical updater and retry bugs, cleanup listener leaks - updater: replace undici.request() with fetch() (fixes maxRedirections error that blocked auto-update from v1.0.0 to v1.1.0) - upload-manager: move signalCleanup declaration outside try block (was causing ReferenceError in catch, silently breaking ALL retries) - upload-manager: _combineSignals now returns cleanup fn to prevent abort listener accumulation over batch lifetime - upload-manager: _sleep removes abort listener on normal timer fire - hosters: apiGet removes abort listener in finally block Co-Authored-By: Claude Opus 4.6 --- lib/hosters.js | 4 +++- lib/updater.js | 22 ++++++++++------------ lib/upload-manager.js | 30 ++++++++++++++++++++---------- tests/upload-manager.test.js | 12 +++++++----- 4 files changed, 40 insertions(+), 28 deletions(-) diff --git a/lib/hosters.js b/lib/hosters.js index c7ae07b..c1d52c6 100644 --- a/lib/hosters.js +++ b/lib/hosters.js @@ -264,7 +264,8 @@ function createUploadBody(filePath, formFields, onProgress, throttle, signal) { async function apiGet(url, signal) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), API_TIMEOUT); - if (signal) signal.addEventListener('abort', () => controller.abort()); + const onAbort = () => controller.abort(); + if (signal) signal.addEventListener('abort', onAbort); try { const res = await fetch(url, { @@ -280,6 +281,7 @@ async function apiGet(url, signal) { return data; } finally { clearTimeout(timeout); + if (signal) signal.removeEventListener('abort', onAbort); } } diff --git a/lib/updater.js b/lib/updater.js index c723be6..40e47d6 100644 --- a/lib/updater.js +++ b/lib/updater.js @@ -2,15 +2,12 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { app } = require('electron'); -const { request } = require('undici'); const UPDATE_REPO = 'Administrator/Multi-Hoster-Upload'; const GITEA_BASE = 'https://git.24-music.de'; const API_URL = `${GITEA_BASE}/api/v1/repos/${UPDATE_REPO}/releases?limit=1`; const CHECK_TIMEOUT = 15000; -const DOWNLOAD_TIMEOUT = 600000; // 10 min -const IDLE_TIMEOUT = 45000; let cachedCheck = null; let cachedCheckTs = 0; @@ -158,26 +155,27 @@ async function installUpdate(onProgress) { const tmpDir = app.getPath('temp'); const installerPath = path.join(tmpDir, check.assetName); - const { body, statusCode } = await request(check.assetUrl, { + const res = await fetch(check.assetUrl, { method: 'GET', signal, - maxRedirections: 5, - headersTimeout: IDLE_TIMEOUT, - bodyTimeout: DOWNLOAD_TIMEOUT + redirect: 'follow' }); - if (statusCode < 200 || statusCode >= 300) { - throw new Error(`Download fehlgeschlagen: HTTP ${statusCode}`); + if (!res.ok) { + throw new Error(`Download fehlgeschlagen: HTTP ${res.status}`); } const totalBytes = check.assetSize || 0; let downloadedBytes = 0; const chunks = []; - for await (const chunk of body) { + const reader = res.body.getReader(); + while (true) { if (signal.aborted) throw new Error('Abgebrochen'); - chunks.push(chunk); - downloadedBytes += chunk.length; + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + downloadedBytes += value.length; if (onProgress) { onProgress({ stage: 'downloading', diff --git a/lib/upload-manager.js b/lib/upload-manager.js index b594754..d0a8bb9 100644 --- a/lib/upload-manager.js +++ b/lib/upload-manager.js @@ -171,8 +171,9 @@ class UploadManager extends EventEmitter { // Register active job for global stats this.activeJobs.set(uploadId, { speedKbs: 0, bytesUploaded: 0 }); - // Speed monitor interval (declared outside try for cleanup in catch) + // Speed monitor and signal cleanup (declared outside try for cleanup in catch) let speedMonitor = null; + let signalCleanup = null; try { // Getting server @@ -194,9 +195,12 @@ class UploadManager extends EventEmitter { } // Combined signal - const jobSignal = speedAbort - ? this._combineSignals(signal, speedAbort.signal) - : signal; + let jobSignal = signal; + if (speedAbort) { + const combined = this._combineSignals(signal, speedAbort.signal); + jobSignal = combined.signal; + signalCleanup = combined.cleanup; + } if (settings.restartBelowKbs > 0) { speedMonitor = setInterval(() => { if (currentSpeedKbs > 0 && currentSpeedKbs < settings.restartBelowKbs) { @@ -249,8 +253,9 @@ class UploadManager extends EventEmitter { result = await uploadFile(task.hoster, task.file, task.apiKey, progressCb, jobSignal, throttle); } - // Clear speed monitor + // Clear speed monitor and signal listeners if (speedMonitor) clearInterval(speedMonitor); + if (signalCleanup) signalCleanup(); // Track session bytes this.sessionBytes += fileSize; @@ -273,8 +278,9 @@ class UploadManager extends EventEmitter { return; // Success — exit retry loop } catch (err) { - // Clear speed monitor interval on error + // Clear speed monitor interval and signal listeners on error if (speedMonitor) { clearInterval(speedMonitor); speedMonitor = null; } + if (signalCleanup) { signalCleanup(); signalCleanup = null; } if (speedAbort) { // Check if this was a speed restart try { speedAbort.abort(); } catch {} @@ -361,7 +367,7 @@ class UploadManager extends EventEmitter { _combineSignals(signal1, signal2) { const controller = new AbortController(); - if (signal1.aborted || signal2.aborted) { controller.abort(); return controller.signal; } + if (signal1.aborted || signal2.aborted) { controller.abort(); return { signal: controller.signal, cleanup() {} }; } const cleanup = () => { signal1.removeEventListener('abort', onAbort); signal2.removeEventListener('abort', onAbort); @@ -369,15 +375,19 @@ class UploadManager extends EventEmitter { const onAbort = () => { controller.abort(); cleanup(); }; signal1.addEventListener('abort', onAbort, { once: true }); signal2.addEventListener('abort', onAbort, { once: true }); - return controller.signal; + return { signal: controller.signal, cleanup }; } _sleep(ms, signal) { return new Promise((resolve, reject) => { - const timer = setTimeout(resolve, ms); + const onAbort = () => { clearTimeout(timer); reject(new Error('Aborted')); }; + const timer = setTimeout(() => { + if (signal) signal.removeEventListener('abort', onAbort); + resolve(); + }, ms); if (signal) { if (signal.aborted) { clearTimeout(timer); reject(new Error('Aborted')); return; } - signal.addEventListener('abort', () => { clearTimeout(timer); reject(new Error('Aborted')); }, { once: true }); + signal.addEventListener('abort', onAbort, { once: true }); } }); } diff --git a/tests/upload-manager.test.js b/tests/upload-manager.test.js index eb817c5..e31c7de 100644 --- a/tests/upload-manager.test.js +++ b/tests/upload-manager.test.js @@ -186,11 +186,12 @@ describe('UploadManager', () => { const ac1 = new AbortController(); const ac2 = new AbortController(); - const combined = mgr._combineSignals(ac1.signal, ac2.signal); - assert.equal(combined.aborted, false); + const { signal, cleanup } = mgr._combineSignals(ac1.signal, ac2.signal); + assert.equal(signal.aborted, false); ac2.abort(); - assert.equal(combined.aborted, true); + assert.equal(signal.aborted, true); + cleanup(); }); it('_combineSignals returns aborted signal if input already aborted', () => { @@ -199,8 +200,9 @@ describe('UploadManager', () => { ac1.abort(); const ac2 = new AbortController(); - const combined = mgr._combineSignals(ac1.signal, ac2.signal); - assert.equal(combined.aborted, true); + const { signal, cleanup } = mgr._combineSignals(ac1.signal, ac2.signal); + assert.equal(signal.aborted, true); + cleanup(); }); it('_sleep resolves after delay', async () => {