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) {
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 () => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user