fix(stability): wrap hot timers/callbacks in try/catch, safeSend, updater waits for batch

This commit is contained in:
Administrator 2026-06-08 21:28:12 +02:00
parent f0608dcda1
commit 0f57aef7c7
4 changed files with 131 additions and 98 deletions

View File

@ -233,7 +233,20 @@ async function installUpdate(onProgress) {
// Stage: done
if (onProgress) onProgress({ stage: 'done', percent: 100 });
setTimeout(() => app.quit(), 900);
const _doQuit = () => setTimeout(() => app.quit(), 900);
const _getActive = () => {
try { return globalThis._mhuUploadManagerRef && globalThis._mhuUploadManagerRef.getActiveJobCount ? globalThis._mhuUploadManagerRef.getActiveJobCount() : 0; }
catch { return 0; }
};
if (_getActive() > 0) {
const POLL_MS = 3000;
const poller = setInterval(() => {
if (_getActive() === 0) { clearInterval(poller); _doQuit(); }
}, POLL_MS);
setTimeout(() => { try { clearInterval(poller); } catch {} _doQuit(); }, 30 * 60 * 1000);
} else {
_doQuit();
}
} catch (err) {
if (onProgress) onProgress({ stage: 'error', error: err.message });

View File

@ -558,14 +558,16 @@ class UploadManager extends EventEmitter {
speedAbort = new AbortController();
uploadSignalBundle = this._combineManySignals([signal, speedAbort.signal]);
speedMonitor = setInterval(() => {
if (currentSpeedKbs > 0 && currentSpeedKbs < settings.restartBelowKbs) {
if (!lowSpeedSince) lowSpeedSince = Date.now();
if (Date.now() - lowSpeedSince > 6000) {
speedAbort.abort();
try {
if (currentSpeedKbs > 0 && currentSpeedKbs < settings.restartBelowKbs) {
if (!lowSpeedSince) lowSpeedSince = Date.now();
if (Date.now() - lowSpeedSince > 6000) {
speedAbort.abort();
}
} else {
lowSpeedSince = 0;
}
} else {
lowSpeedSince = 0;
}
} catch (e) { this._rotLog('speed-monitor-error', { jobId, error: e && e.message }); }
}, 2000);
}
@ -579,41 +581,42 @@ class UploadManager extends EventEmitter {
const PROGRESS_EMIT_INTERVAL = 250; // ms throttle UI updates
const progressCb = (bytesUploaded, bytesTotal) => {
const now = Date.now();
const elapsed = Math.round((now - jobStart) / 1000);
const timeDelta = (now - lastSpeedTime) / 1000;
if (timeDelta >= 1) {
const bytesDelta = bytesUploaded - lastBytes;
currentSpeedKbs = Math.round(bytesDelta / timeDelta / 1024);
lastBytes = bytesUploaded;
lastSpeedTime = now;
}
try {
const now = Date.now();
const elapsed = Math.round((now - jobStart) / 1000);
const timeDelta = (now - lastSpeedTime) / 1000;
if (Number.isFinite(timeDelta) && timeDelta >= 1) {
const bytesDelta = bytesUploaded - lastBytes;
currentSpeedKbs = Math.round(bytesDelta / timeDelta / 1024);
lastBytes = bytesUploaded;
lastSpeedTime = now;
}
activeEntry.speedKbs = currentSpeedKbs;
activeEntry.bytesUploaded = bytesUploaded;
activeEntry.speedKbs = currentSpeedKbs;
activeEntry.bytesUploaded = bytesUploaded;
// Throttle progress emissions to reduce IPC + rendering overhead
if (now - lastEmitTime < PROGRESS_EMIT_INTERVAL) return;
lastEmitTime = now;
if (now - lastEmitTime < PROGRESS_EMIT_INTERVAL) return;
lastEmitTime = now;
const remaining = currentSpeedKbs > 0
? Math.round((bytesTotal - bytesUploaded) / (currentSpeedKbs * 1024))
: 0;
const remaining = currentSpeedKbs > 0
? Math.round((bytesTotal - bytesUploaded) / (currentSpeedKbs * 1024))
: 0;
this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId,
jobId,
status: 'uploading',
progress: bytesTotal > 0 ? Math.min(1, bytesUploaded / bytesTotal) : 0,
bytesUploaded,
bytesTotal,
speedKbs: currentSpeedKbs,
elapsed,
remaining,
error: null,
result: null,
attempt,
maxAttempts
});
this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId,
jobId,
status: 'uploading',
progress: bytesTotal > 0 ? Math.min(1, bytesUploaded / bytesTotal) : 0,
bytesUploaded,
bytesTotal,
speedKbs: currentSpeedKbs,
elapsed,
remaining,
error: null,
result: null,
attempt,
maxAttempts
});
} catch { /* progress callbacks must never throw — swallowing is correct, the stream keeps going */ }
};
const result = await this._executeUpload(task, progressCb, uploadSignalBundle.signal, throttle);
@ -995,7 +998,7 @@ class UploadManager extends EventEmitter {
_startStatsTimer() {
if (this.statsInterval) clearInterval(this.statsInterval);
this.statsInterval = setInterval(() => {
// Single pass over active jobs instead of two.
try {
let globalSpeedKbs = 0;
let activeCount = 0;
let inProgressBytes = 0;
@ -1015,6 +1018,7 @@ class UploadManager extends EventEmitter {
activeJobs: activeCount,
pendingJobs: Object.values(this.semaphores).reduce((sum, semaphore) => sum + semaphore.pending, 0)
});
} catch { /* never let a stats tick crash the timer + caller */ }
}, 1000);
}

116
main.js
View File

@ -208,6 +208,7 @@ function getAllLogPaths() {
debug: debugPath,
accountRotation: rot,
doodstreamDebug: path.join(dir, 'doodstream-debug.log'),
crashLog: path.join(dir, 'crash.log'),
logDir: dir
};
}
@ -227,6 +228,17 @@ function rotLog(msg, ts) {
} catch {}
}
function safeSend(channel, data) {
if (!mainWindow || mainWindow.isDestroyed()) return false;
try {
safeSend(channel, data);
return true;
} catch (err) {
debugLog(`safeSend(${channel}) failed: ${err && err.message ? err.message : err}`);
return false;
}
}
function _writeCrashLog(prefix, err, extra) {
try {
const ts = new Date().toISOString();
@ -471,9 +483,7 @@ function _flushUploadLog() {
// next session writes here directly (no more fallback ladder) and
// the Settings input reflects reality.
_persistFallbackLogPath(target.path);
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('upload-log-fallback', { fallbackPath: target.path });
}
safeSend('upload-log-fallback', { fallbackPath: target.path });
}
if (_uploadLogBuffer.length && !_uploadLogFlushTimer) setImmediate(_flushUploadLog);
});
@ -501,9 +511,7 @@ function _persistFallbackLogPath(workingPath) {
cfg.globalSettings = gs;
configStore.save({ globalSettings: gs }).catch(() => {});
_invalidateUploadLogTargetCache();
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('log-path-auto-updated', { logFilePath: toSave });
}
safeSend('log-path-auto-updated', { logFilePath: toSave });
} catch (err) {
debugLog(`persist fallback logpath failed: ${err.message}`);
}
@ -1125,7 +1133,7 @@ app.whenReady().then(() => {
logInfo(`update-check: available=${result && result.available}, remote=${result && result.remoteVersion}`);
logDebug(`update-check result: ${JSON.stringify(result)}`);
if (result && result.available && mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('app:update-available', result);
safeSend('app:update-available', result);
}
} catch (err) {
logError('update-check failed', err);
@ -1208,11 +1216,9 @@ ipcMain.handle('save-config', async (_event, config) => {
rotLog(`main: config-updated → late fallback ${fallback.id} for ${hoster} (was stuck on ${failedAccountId})`);
uploadManager.switchAccount(hoster, fallback);
_sessionAccountOverrides.set(hoster, fallback);
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('account-switched', {
safeSend('account-switched', {
hoster, fromAccountId: failedAccountId, toAccountId: fallback.id
});
}
}
}
} catch (err) {
@ -1456,6 +1462,7 @@ ipcMain.handle('start-upload', (_event, payload) => {
// Pass hoster settings to the upload manager
uploadManager = new UploadManager(config.hosterSettings || {}, config.globalSettings || {});
globalThis._mhuUploadManagerRef = uploadManager;
const _progressByJob = new Map();
const _progressTerminalQueue = [];
@ -1473,7 +1480,7 @@ ipcMain.handle('start-upload', (_event, payload) => {
const batch = _progressTerminalQueue.splice(0);
for (const v of _progressByJob.values()) batch.push(v);
_progressByJob.clear();
if (batch.length) mainWindow.webContents.send('upload-progress-batch', batch);
if (batch.length) safeSend('upload-progress-batch', batch);
}, PROGRESS_BATCH_INTERVAL_MS);
}
@ -1511,16 +1518,16 @@ ipcMain.handle('start-upload', (_event, payload) => {
});
uploadManager.on('stats', (data) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('upload-stats', data);
}
// Update tray tooltip with upload progress
if (data.state === 'uploading' && data.activeJobs > 0) {
const speedMb = ((data.globalSpeedKbs || 0) / 1024).toFixed(1);
updateTrayTooltip(`Upload: ${data.activeJobs} aktiv - ${speedMb} MB/s`);
} else {
updateTrayTooltip('Multi-Hoster-Upload');
}
try {
if (!data || typeof data !== 'object') return;
safeSend('upload-stats', data);
if (data.state === 'uploading' && data.activeJobs > 0) {
const speedMb = ((Number(data.globalSpeedKbs) || 0) / 1024).toFixed(1);
updateTrayTooltip(`Upload: ${data.activeJobs} aktiv - ${speedMb} MB/s`);
} else {
updateTrayTooltip('Multi-Hoster-Upload');
}
} catch (e) { debugLog(`stats listener error: ${e && e.message}`); }
});
uploadManager.on('account-failed', ({ hoster, accountId }) => {
@ -1533,9 +1540,7 @@ ipcMain.handle('start-upload', (_event, payload) => {
rotLog(`main: account-failed ${hoster} ${accountId} → resolved fallback ${fallback.id}`);
uploadManager.switchAccount(hoster, fallback);
_sessionAccountOverrides.set(hoster, fallback);
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('account-switched', { hoster, fromAccountId: accountId, toAccountId: fallback.id });
}
safeSend('account-switched', { hoster, fromAccountId: accountId, toAccountId: fallback.id });
} else {
rotLog(`main: account-failed ${hoster} ${accountId} → NO fallback available (end of chain)`);
}
@ -1551,17 +1556,25 @@ ipcMain.handle('start-upload', (_event, payload) => {
'doodstream-via-web'
]);
uploadManager.on('rot-log', (entry) => {
const { ts, event, ...rest } = entry;
const pairs = Object.entries(rest)
.map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`)
.join(' ');
rotLog(`[${event}] ${pairs}`, ts);
if (entry.jobId) {
_appendJobLog(entry.jobId, { ts: ts || Date.now(), kind: 'rot', event, ...rest });
}
if (mainWindow && !mainWindow.isDestroyed() && ROT_LOG_RENDERER_EVENTS.has(event)) {
mainWindow.webContents.send('account-rotation-log', entry);
}
try {
if (!entry || typeof entry !== 'object') return;
const { ts, event, ...rest } = entry;
const pairs = Object.entries(rest)
.map(([k, v]) => {
let sv;
try { sv = typeof v === 'string' ? v : JSON.stringify(v); }
catch { sv = '<unserializable>'; }
return `${k}=${sv}`;
})
.join(' ');
rotLog(`[${event}] ${pairs}`, ts);
if (entry.jobId) {
_appendJobLog(entry.jobId, { ts: ts || Date.now(), kind: 'rot', event, ...rest });
}
if (ROT_LOG_RENDERER_EVENTS.has(event)) {
safeSend('account-rotation-log', entry);
}
} catch (e) { debugLog(`rot-log listener error: ${e && e.message}`); }
});
// Capture the manager identity at listener-registration time so the post-
@ -1578,13 +1591,11 @@ ipcMain.handle('start-upload', (_event, payload) => {
try { await configStore.appendHistory(summary); } catch (err) {
debugLog(`appendHistory failed: ${err.message}`);
}
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('upload-batch-done', summary);
}
safeSend('upload-batch-done', summary);
// Shutdown after finish
handleShutdownAfterFinish();
if (uploadManager === _thisManager) uploadManager = null;
if (uploadManager === _thisManager) { uploadManager = null; globalThis._mhuUploadManagerRef = null; }
else debugLog('batch-done: skipping uploadManager null-out — a newer manager replaced this one mid-await');
});
@ -1600,8 +1611,7 @@ ipcMain.handle('start-upload', (_event, payload) => {
}).catch((err) => {
debugLog(`startBatch REJECTED: ${err && err.stack ? err.stack : err}`);
// Forward error to renderer as batch-done with failure
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('upload-batch-done', {
safeSend('upload-batch-done', {
id: 'error',
timestamp: new Date().toISOString(),
total: tasks.length,
@ -1610,7 +1620,6 @@ ipcMain.handle('start-upload', (_event, payload) => {
files: [],
error: err ? err.message : 'Unbekannter Fehler'
});
}
});
});
@ -1797,6 +1806,7 @@ ipcMain.handle('create-support-bundle', async () => {
{ label: 'debug.log (last 5 MB)', path: paths.debug, maxBytes: 5 * 1024 * 1024 },
{ label: 'account-rotation.log (last 2 MB)', path: paths.accountRotation, maxBytes: 2 * 1024 * 1024 },
{ label: 'doodstream-debug.log (last 2 MB)', path: paths.doodstreamDebug, maxBytes: 2 * 1024 * 1024 },
{ label: 'crash.log', path: path.join(paths.logDir || path.dirname(paths.debug), 'crash.log'), maxBytes: 1 * 1024 * 1024 },
{ label: 'fileuploader.log (last 1 MB)', path: paths.fileuploader, maxBytes: 1 * 1024 * 1024 }
]
});
@ -2011,13 +2021,9 @@ ipcMain.handle('app:check-updates', async () => {
ipcMain.handle('app:install-update', () => {
installUpdate((progress) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('app:update-progress', progress);
}
safeSend('app:update-progress', progress);
}).catch((err) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('app:update-progress', { stage: 'error', error: err.message });
}
safeSend('app:update-progress', { stage: 'error', error: err.message });
});
return { started: true };
});
@ -2096,9 +2102,7 @@ function startFolderMonitor(settings) {
folderMonitor.removeAllListeners();
folderMonitor.on('new-files', (files) => {
debugLog(`folder-monitor: ${files.length} new file(s)`);
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('folder-monitor:new-files', files);
}
safeSend('folder-monitor:new-files', files);
});
folderMonitor.on('error', (err) => {
debugLog(`folder-monitor error: ${err.message}`);
@ -2342,9 +2346,7 @@ ipcMain.handle('remote:get-capture-source-id', async () => {
// IPC: Client count updates from capture window
ipcMain.on('remote:client-count', (_event, count) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('remote:client-count', count);
}
safeSend('remote:client-count', count);
});
// IPC: Remote settings
@ -2449,7 +2451,7 @@ ipcMain.on('drop-target:files', (_event, paths) => {
mainWindow.show();
mainWindow.focus();
}
mainWindow.webContents.send('drop-target:files', paths);
safeSend('drop-target:files', paths);
}
});
@ -2486,9 +2488,7 @@ function handleShutdownAfterFinish() {
const { exec } = require('child_process');
// Notify renderer
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('shutdown-countdown', { mode: shutdownMode, seconds: 60 });
}
safeSend('shutdown-countdown', { mode: shutdownMode, seconds: 60 });
// Clear any previous countdown to prevent orphaned timers
if (shutdownTimer) clearTimeout(shutdownTimer);

View File

@ -1896,6 +1896,14 @@ async function cancelUpload() {
// --- Progress handling ---
function handleProgress(data) {
try {
if (!data || typeof data !== 'object') return;
_handleProgressImpl(data);
} catch (err) {
if (window.api && window.api.debugLog) window.api.debugLog(`handleProgress error: ${err && err.stack ? err.stack : err}`);
}
}
function _handleProgressImpl(data) {
let job = data.jobId ? _jobIndexById.get(data.jobId) : null;
if (!job && data.uploadId) job = _jobIndexByUploadId.get(data.uploadId);
if (!job) {
@ -2157,6 +2165,14 @@ function _retryFailedFromBuckets(buckets, transientOnly) {
}
function handleStats(data) {
try {
if (!data || typeof data !== 'object') return;
_handleStatsImpl(data);
} catch (err) {
if (window.api && window.api.debugLog) window.api.debugLog(`handleStats error: ${err && err.stack ? err.stack : err}`);
}
}
function _handleStatsImpl(data) {
lastUploadStats = {
state: data.state || 'idle',
globalSpeedKbs: data.globalSpeedKbs || 0,