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:
parent
61681de9a3
commit
9c56fabce1
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user