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) {
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);
}
}

View File

@ -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',

View File

@ -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 });
}
});
}

View File

@ -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 () => {