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 <noreply@anthropic.com>
This commit is contained in:
Administrator 2026-03-10 12:30:06 +01:00
parent 61681de9a3
commit 9c56fabce1
4 changed files with 40 additions and 28 deletions

View File

@ -264,7 +264,8 @@ function createUploadBody(filePath, formFields, onProgress, throttle, signal) {
async function apiGet(url, signal) { async function apiGet(url, signal) {
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), API_TIMEOUT); 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 { try {
const res = await fetch(url, { const res = await fetch(url, {
@ -280,6 +281,7 @@ async function apiGet(url, signal) {
return data; return data;
} finally { } finally {
clearTimeout(timeout); clearTimeout(timeout);
if (signal) signal.removeEventListener('abort', onAbort);
} }
} }

View File

@ -2,15 +2,12 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const crypto = require('crypto'); const crypto = require('crypto');
const { app } = require('electron'); const { app } = require('electron');
const { request } = require('undici');
const UPDATE_REPO = 'Administrator/Multi-Hoster-Upload'; const UPDATE_REPO = 'Administrator/Multi-Hoster-Upload';
const GITEA_BASE = 'https://git.24-music.de'; const GITEA_BASE = 'https://git.24-music.de';
const API_URL = `${GITEA_BASE}/api/v1/repos/${UPDATE_REPO}/releases?limit=1`; const API_URL = `${GITEA_BASE}/api/v1/repos/${UPDATE_REPO}/releases?limit=1`;
const CHECK_TIMEOUT = 15000; const CHECK_TIMEOUT = 15000;
const DOWNLOAD_TIMEOUT = 600000; // 10 min
const IDLE_TIMEOUT = 45000;
let cachedCheck = null; let cachedCheck = null;
let cachedCheckTs = 0; let cachedCheckTs = 0;
@ -158,26 +155,27 @@ async function installUpdate(onProgress) {
const tmpDir = app.getPath('temp'); const tmpDir = app.getPath('temp');
const installerPath = path.join(tmpDir, check.assetName); const installerPath = path.join(tmpDir, check.assetName);
const { body, statusCode } = await request(check.assetUrl, { const res = await fetch(check.assetUrl, {
method: 'GET', method: 'GET',
signal, signal,
maxRedirections: 5, redirect: 'follow'
headersTimeout: IDLE_TIMEOUT,
bodyTimeout: DOWNLOAD_TIMEOUT
}); });
if (statusCode < 200 || statusCode >= 300) { if (!res.ok) {
throw new Error(`Download fehlgeschlagen: HTTP ${statusCode}`); throw new Error(`Download fehlgeschlagen: HTTP ${res.status}`);
} }
const totalBytes = check.assetSize || 0; const totalBytes = check.assetSize || 0;
let downloadedBytes = 0; let downloadedBytes = 0;
const chunks = []; const chunks = [];
for await (const chunk of body) { const reader = res.body.getReader();
while (true) {
if (signal.aborted) throw new Error('Abgebrochen'); if (signal.aborted) throw new Error('Abgebrochen');
chunks.push(chunk); const { done, value } = await reader.read();
downloadedBytes += chunk.length; if (done) break;
chunks.push(value);
downloadedBytes += value.length;
if (onProgress) { if (onProgress) {
onProgress({ onProgress({
stage: 'downloading', stage: 'downloading',

View File

@ -171,8 +171,9 @@ class UploadManager extends EventEmitter {
// Register active job for global stats // Register active job for global stats
this.activeJobs.set(uploadId, { speedKbs: 0, bytesUploaded: 0 }); 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 speedMonitor = null;
let signalCleanup = null;
try { try {
// Getting server // Getting server
@ -194,9 +195,12 @@ class UploadManager extends EventEmitter {
} }
// Combined signal // Combined signal
const jobSignal = speedAbort let jobSignal = signal;
? this._combineSignals(signal, speedAbort.signal) if (speedAbort) {
: signal; const combined = this._combineSignals(signal, speedAbort.signal);
jobSignal = combined.signal;
signalCleanup = combined.cleanup;
}
if (settings.restartBelowKbs > 0) { if (settings.restartBelowKbs > 0) {
speedMonitor = setInterval(() => { speedMonitor = setInterval(() => {
if (currentSpeedKbs > 0 && currentSpeedKbs < settings.restartBelowKbs) { 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); 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 (speedMonitor) clearInterval(speedMonitor);
if (signalCleanup) signalCleanup();
// Track session bytes // Track session bytes
this.sessionBytes += fileSize; this.sessionBytes += fileSize;
@ -273,8 +278,9 @@ class UploadManager extends EventEmitter {
return; // Success — exit retry loop return; // Success — exit retry loop
} catch (err) { } catch (err) {
// Clear speed monitor interval on error // Clear speed monitor interval and signal listeners on error
if (speedMonitor) { clearInterval(speedMonitor); speedMonitor = null; } if (speedMonitor) { clearInterval(speedMonitor); speedMonitor = null; }
if (signalCleanup) { signalCleanup(); signalCleanup = null; }
if (speedAbort) { if (speedAbort) {
// Check if this was a speed restart // Check if this was a speed restart
try { speedAbort.abort(); } catch {} try { speedAbort.abort(); } catch {}
@ -361,7 +367,7 @@ class UploadManager extends EventEmitter {
_combineSignals(signal1, signal2) { _combineSignals(signal1, signal2) {
const controller = new AbortController(); 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 = () => { const cleanup = () => {
signal1.removeEventListener('abort', onAbort); signal1.removeEventListener('abort', onAbort);
signal2.removeEventListener('abort', onAbort); signal2.removeEventListener('abort', onAbort);
@ -369,15 +375,19 @@ class UploadManager extends EventEmitter {
const onAbort = () => { controller.abort(); cleanup(); }; const onAbort = () => { controller.abort(); cleanup(); };
signal1.addEventListener('abort', onAbort, { once: true }); signal1.addEventListener('abort', onAbort, { once: true });
signal2.addEventListener('abort', onAbort, { once: true }); signal2.addEventListener('abort', onAbort, { once: true });
return controller.signal; return { signal: controller.signal, cleanup };
} }
_sleep(ms, signal) { _sleep(ms, signal) {
return new Promise((resolve, reject) => { 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) {
if (signal.aborted) { clearTimeout(timer); reject(new Error('Aborted')); return; } 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 });
} }
}); });
} }

View File

@ -186,11 +186,12 @@ describe('UploadManager', () => {
const ac1 = new AbortController(); const ac1 = new AbortController();
const ac2 = new AbortController(); const ac2 = new AbortController();
const combined = mgr._combineSignals(ac1.signal, ac2.signal); const { signal, cleanup } = mgr._combineSignals(ac1.signal, ac2.signal);
assert.equal(combined.aborted, false); assert.equal(signal.aborted, false);
ac2.abort(); ac2.abort();
assert.equal(combined.aborted, true); assert.equal(signal.aborted, true);
cleanup();
}); });
it('_combineSignals returns aborted signal if input already aborted', () => { it('_combineSignals returns aborted signal if input already aborted', () => {
@ -199,8 +200,9 @@ describe('UploadManager', () => {
ac1.abort(); ac1.abort();
const ac2 = new AbortController(); const ac2 = new AbortController();
const combined = mgr._combineSignals(ac1.signal, ac2.signal); const { signal, cleanup } = mgr._combineSignals(ac1.signal, ac2.signal);
assert.equal(combined.aborted, true); assert.equal(signal.aborted, true);
cleanup();
}); });
it('_sleep resolves after delay', async () => { it('_sleep resolves after delay', async () => {