From a0041e9cda5a198524039a45d8a45919549de87d Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:03:13 +0200 Subject: [PATCH] fix: gate startup reconcile on renderer readiness Defer the productive FolderMonitor startup scan until the renderer has registered its candidate listener and acknowledged readiness. Keep the handshake idempotent across duplicate signals, reloads, window closure, shutdown, paused startup, and renderer initialization failure without delaying unrelated startup work. Preserve the real over-limit queue count while clamping finite progressbar ARIA values, and cover both regressions with hidden Electron and preload ordering tests. --- main.js | 50 ++++++- preload.js | 12 ++ renderer/app.js | 3 +- tests/package-build-files.test.js | 50 ++++++- tests/startup-renderer.test.js | 218 +++++++++++++++++++++++++++++- 5 files changed, 327 insertions(+), 6 deletions(-) diff --git a/main.js b/main.js index a9e8a07..496630c 100644 --- a/main.js +++ b/main.js @@ -112,6 +112,9 @@ let closePreparationAttempt = 0; let closeQuiesceOwnerAttempt = null; let lastRestoredCloseAttempt = null; let closeFolderMonitorWasRunning = false; +let folderMonitorRendererReady = false; +let folderMonitorStartupReconcilePending = false; +let folderMonitorStartupReconcileStarted = false; let preparedUpdate = null; let updatePreparationPromise = null; let updateQuitPending = false; @@ -1518,6 +1521,9 @@ function createWindow() { closeFlushApproved = false; closeFlushRequested = false; closeHandshakeReady = false; + folderMonitorRendererReady = false; + folderMonitorStartupReconcilePending = false; + folderMonitorStartupReconcileStarted = false; closeQuiesceOwnerAttempt = null; lastRestoredCloseAttempt = null; configStore.setWritesQuiesced(false); @@ -1529,16 +1535,23 @@ function createWindow() { requestClosePreparation(); }); + mainWindow.on('closed', () => { + folderMonitorRendererReady = false; + folderMonitorStartupReconcilePending = false; + }); + mainWindow.webContents.setBackgroundThrottling(false); mainWindow.webContents.on('did-start-navigation', (_event, _url, isInPlace, isMainFrame) => { if (isInPlace || !isMainFrame) return; closeHandshakeReady = false; + folderMonitorRendererReady = false; updateAnnouncementState.reset(); restoreClosePreparation(closePreparationAttempt); }); mainWindow.webContents.on('render-process-gone', (_event, details) => { + folderMonitorRendererReady = false; _writeCrashLog('RENDER PROCESS GONE', new Error(details.reason || 'unknown'), details); debugLog(`RENDER PROCESS GONE: reason=${details.reason} exitCode=${details.exitCode}`); if (mainWindow && !mainWindow.isDestroyed()) { @@ -1685,7 +1698,7 @@ app.whenReady().then(async () => { const launchConfig = configStore.load(); const fm = launchConfig.globalSettings && launchConfig.globalSettings.folderMonitor; if (fm && fm.enabled && fm.folderPath) { - await startFolderMonitor(fm); + await startFolderMonitor(fm, { deferStartupReconcile: true }); } } catch (err) { debugLog(`folder-monitor auto-start failed: ${err.message}`); @@ -1740,6 +1753,8 @@ app.on('before-quit', (event) => { app.on('will-quit', () => { if (quitTeardownStarted) return; quitTeardownStarted = true; + folderMonitorRendererReady = false; + folderMonitorStartupReconcilePending = false; if (updateCheckInterval) clearInterval(updateCheckInterval); updateCheckInterval = null; if (preparedUpdate && updateQuitPending && closeFlushApproved && !preparedUpdateLaunchStarted) { @@ -3103,6 +3118,13 @@ ipcMain.on('app:close-handshake-ready', (event) => { } }); +ipcMain.on('folder-monitor:renderer-ready', (event) => { + if (mainWindow && !mainWindow.isDestroyed() && event.sender === mainWindow.webContents) { + folderMonitorRendererReady = true; + releaseFolderMonitorStartupReconcile(); + } +}); + ipcMain.on('app:close-preparation-started', (event, attempt) => { if (mainWindow && !mainWindow.isDestroyed() && event.sender === mainWindow.webContents && isClosePreparationActive(attempt)) { armCloseFlushTimer(attempt, 3000); @@ -3312,7 +3334,28 @@ function bindFolderMonitorEvents(settings) { }); } -async function startFolderMonitor(settings) { +function releaseFolderMonitorStartupReconcile() { + if (!folderMonitorRendererReady || !folderMonitorStartupReconcilePending || folderMonitorStartupReconcileStarted || quitTeardownStarted || !folderMonitor.running) return false; + folderMonitorStartupReconcilePending = false; + folderMonitorStartupReconcileStarted = true; + try { + void folderMonitor.scan({ emitFiles: true, trigger: 'startup' }).catch(error => { + debugLog(`folder-monitor startup scan failed: ${error.message}`); + }); + } catch (error) { + debugLog(`folder-monitor startup scan failed: ${error.message}`); + } + return true; +} + +function deferFolderMonitorStartupReconcile() { + if (folderMonitorStartupReconcilePending || folderMonitorStartupReconcileStarted) return false; + folderMonitorStartupReconcilePending = true; + releaseFolderMonitorStartupReconcile(); + return true; +} + +async function startFolderMonitor(settings, options = {}) { try { const persisted = configStore.load().globalSettings?.folderMonitor || {}; const normalized = normalizeAutomationSettings(settings); @@ -3330,7 +3373,8 @@ async function startFolderMonitor(settings) { return { includesExisting: false, paused: true }; } const result = folderMonitor.start(effectiveSettings); - await folderMonitor.scan({ emitFiles: true, trigger: 'startup' }); + if (options.deferStartupReconcile === true) deferFolderMonitorStartupReconcile(); + else await folderMonitor.scan({ emitFiles: true, trigger: 'startup' }); debugLog(`folder-monitor started: ${settings.folderPath}`); return result; } catch (err) { diff --git a/preload.js b/preload.js index 6fae9a3..71da4a6 100644 --- a/preload.js +++ b/preload.js @@ -1,5 +1,8 @@ const { contextBridge, ipcRenderer, webUtils } = require('electron'); +let folderMonitorCandidateListenerReady = false; +let folderMonitorReadySent = false; + contextBridge.exposeInMainWorld('api', { // Config getConfig: () => ipcRenderer.invoke('get-config'), @@ -97,6 +100,13 @@ contextBridge.exposeInMainWorld('api', { automationResume: () => ipcRenderer.invoke('automation:resume'), onFolderMonitorNewFiles: (callback) => { ipcRenderer.on('folder-monitor:new-files', (_event, data) => callback(data)); + folderMonitorCandidateListenerReady = true; + }, + signalFolderMonitorReady: () => { + if (!folderMonitorCandidateListenerReady || folderMonitorReadySent) return false; + folderMonitorReadySent = true; + ipcRenderer.send('folder-monitor:renderer-ready'); + return true; }, onAutomationStatus: (callback) => { ipcRenderer.on('automation:status', (_event, data) => callback(data)); @@ -176,6 +186,8 @@ contextBridge.exposeInMainWorld('api', { // File path from drag & drop (Electron 33+ compatible) getPathForFile: (file) => webUtils.getPathForFile(file), removeAllListeners: () => { + folderMonitorCandidateListenerReady = false; + folderMonitorReadySent = false; ipcRenderer.removeAllListeners('upload-progress'); ipcRenderer.removeAllListeners('upload-batch-done'); ipcRenderer.removeAllListeners('upload-stats'); diff --git a/renderer/app.js b/renderer/app.js index c6d566c..c989cbb 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -940,7 +940,7 @@ function renderAutomationStatusSnapshot(snapshot) { queueTrack.removeAttribute('aria-valuenow'); queueTrack.removeAttribute('aria-valuemax'); } else { - queueTrack.setAttribute('aria-valuenow', String(snapshot.currentJobCount)); + queueTrack.setAttribute('aria-valuenow', String(Math.min(snapshot.currentJobCount, snapshot.queueLimitJobs))); queueTrack.setAttribute('aria-valuemax', String(snapshot.queueLimitJobs)); } queueTrack.setAttribute('aria-valuetext', queueText); @@ -1343,6 +1343,7 @@ async function init() { window.api.onFolderMonitorNewFiles(files => { handleFolderMonitorFiles(files).catch(error => window.api.debugLog(`folder-monitor renderer evaluation failed: ${error.message || String(error)}`)); }); + window.api.signalFolderMonitorReady(); if (typeof window.api.onAutomationStatus === 'function') { window.api.onAutomationStatus(status => { applyAutomationRuntimeStatus(status); diff --git a/tests/package-build-files.test.js b/tests/package-build-files.test.js index b02cdff..a8630dc 100644 --- a/tests/package-build-files.test.js +++ b/tests/package-build-files.test.js @@ -345,6 +345,54 @@ test('close readiness is signaled only after the renderer explicitly finishes in ]); }); +test('folder monitor readiness is emitted once and only after the candidate listener exists', () => { + const order = []; + const listeners = new Map(); + let exposedApi = null; + const electronMock = { + contextBridge: { + exposeInMainWorld: (_name, api) => { exposedApi = api; } + }, + ipcRenderer: { + invoke: () => Promise.resolve(), + on: (channel, listener) => { + listeners.set(channel, listener); + order.push(`listen:${channel}`); + }, + send: channel => { order.push(`send:${channel}`); }, + removeAllListeners: () => {} + }, + webUtils: { + getPathForFile: () => '' + } + }; + const originalLoad = Module._load; + const preloadPath = require.resolve('../preload'); + delete require.cache[preloadPath]; + Module._load = function (request, parent, isMain) { + if (request === 'electron') return electronMock; + return originalLoad.call(this, request, parent, isMain); + }; + try { + require(preloadPath); + } finally { + Module._load = originalLoad; + } + + exposedApi.signalFolderMonitorReady(); + let received = null; + exposedApi.onFolderMonitorNewFiles(files => { received = files; }); + exposedApi.signalFolderMonitorReady(); + exposedApi.signalFolderMonitorReady(); + listeners.get('folder-monitor:new-files')({}, [{ path: 'C:\\watch\\ready.mkv' }]); + + assert.deepEqual(order, [ + 'listen:folder-monitor:new-files', + 'send:folder-monitor:renderer-ready' + ]); + assert.deepEqual(received, [{ path: 'C:\\watch\\ready.mkv' }]); +}); + test('preload exposes account cooldown snapshots and removes their listener during cleanup', async () => { const listeners = new Map(); const invocations = []; @@ -812,7 +860,7 @@ test('startup keeps a missing configured folder disconnected without disabling a assert.notEqual(startupEnd, -1); const startup = mainSource.slice(startupStart, startupEnd); - assert.match(startup, /fm\s*&&\s*fm\.enabled\s*&&\s*fm\.folderPath[\s\S]*?await startFolderMonitor\(fm\)/u); + assert.match(startup, /fm\s*&&\s*fm\.enabled\s*&&\s*fm\.folderPath[\s\S]*?await startFolderMonitor\(fm,\s*\{\s*deferStartupReconcile:\s*true\s*\}\)/u); assert.doesNotMatch(startup, /fm\.paused\s*!==\s*true/u); assert.doesNotMatch(startup, /folderMonitor\.scan\(\{\s*emitFiles:\s*true,\s*trigger:\s*'startup'\s*\}\)/u); assert.doesNotMatch(startup, /folderMonitor:\s*\{\s*\.\.\.fm,\s*enabled:\s*false\s*\}/u); diff --git a/tests/startup-renderer.test.js b/tests/startup-renderer.test.js index c57b872..9105ed7 100644 --- a/tests/startup-renderer.test.js +++ b/tests/startup-renderer.test.js @@ -2008,6 +2008,26 @@ contextBridge.exposeInMainWorld('api', { max: document.getElementById('automationQueueMeterTrack')?.getAttribute('aria-valuemax'), text: document.getElementById('automationQueueMeterTrack')?.getAttribute('aria-valuetext') }; + const finiteQueueJobs = queueJobs; + queueJobs = Array.from({ length: 15001 }, (_, index) => ({ + id: 'manual-over-limit-' + index, + file: 'C:\\\\manual\\\\' + index + '.mkv', + fileName: index + '.mkv', + hoster: 'doodstream.com', + status: 'preview', + bytesTotal: 1 + })); + rebuildJobIndex(); + renderAutomationStatusSnapshot(originalSnapshotFactory()); + const overLimitQueueAria = { + meter: document.getElementById('automationQueueMeter')?.textContent.trim() || null, + now: document.getElementById('automationQueueMeterTrack')?.getAttribute('aria-valuenow'), + max: document.getElementById('automationQueueMeterTrack')?.getAttribute('aria-valuemax'), + text: document.getElementById('automationQueueMeterTrack')?.getAttribute('aria-valuetext') + }; + queueJobs = finiteQueueJobs; + rebuildJobIndex(); + renderAutomationStatusSnapshot(finiteQueueSnapshot); setUiLanguage('en'); renderAutomationStatusSnapshot(Object.freeze({ ...finiteQueueSnapshot, state: 'error', error: 'Ordnerscan fehlgeschlagen' })); const localizedStatusError = document.getElementById('automationLastError')?.textContent.trim() || ''; @@ -2194,7 +2214,7 @@ contextBridge.exposeInMainWorld('api', { enabledAfterCancel, lateResultStayedClosed: document.getElementById('automationTestOverlay')?.style.display === 'none' }; - return { initial, noRendererTimeEstimate, states, unlimitedQueueAria, finiteQueueAria, localizedStatusError, pausedControls, pauseResumeActions, loading, completed, english, closed, errorState, cancelLoading }; + return { initial, noRendererTimeEstimate, states, unlimitedQueueAria, finiteQueueAria, overLimitQueueAria, localizedStatusError, pausedControls, pauseResumeActions, loading, completed, english, closed, errorState, cancelLoading }; })()`; const automationControlCenterLayoutScript = `(() => { const card = document.getElementById('automationStatusCard'); @@ -3082,6 +3102,7 @@ app.whenReady().then(async () => { ]); assert.deepEqual(result.automationControlCenter.unlimitedQueueAria, { now: null, max: null, text: '8.420 / Unbegrenzt' }); assert.deepEqual(result.automationControlCenter.finiteQueueAria, { now: '8420', max: '15000', text: '8.420 / 15.000' }); + assert.deepEqual(result.automationControlCenter.overLimitQueueAria, { meter: '15.001 / 15.000', now: '15000', max: '15000', text: '15.001 / 15.000' }); assert.equal(result.automationControlCenter.localizedStatusError, 'Folder scan failed'); assert.deepEqual(result.automationControlCenter.pausedControls, { snapshotCalls: 1, @@ -3847,6 +3868,201 @@ ${startupAutomation} } }); +test('real startup releases one productive reconcile only after the folder candidate listener is ready', { skip: process.platform !== 'win32' }, () => { + const projectRoot = path.join(__dirname, '..'); + const probeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-startup-ready-e2e-')); + try { + const appRoot = path.join(probeRoot, 'app'); + const userDataPath = path.join(probeRoot, 'user-data'); + const outputPath = path.join(probeRoot, 'result.json'); + const watchPath = path.join(probeRoot, 'watch'); + fs.mkdirSync(appRoot, { recursive: true }); + fs.mkdirSync(watchPath, { recursive: true }); + fs.writeFileSync(path.join(watchPath, 'startup-ready.mkv'), Buffer.alloc(19, 5)); + for (const relativePath of ['main.js', 'preload.js', 'preload-drop-target.js', 'package.json']) { + fs.copyFileSync(path.join(projectRoot, relativePath), path.join(appRoot, relativePath)); + } + for (const relativePath of ['lib', 'renderer', 'assets']) { + fs.cpSync(path.join(projectRoot, relativePath), path.join(appRoot, relativePath), { recursive: true }); + } + fs.symlinkSync(path.join(projectRoot, 'node_modules'), path.join(appRoot, 'node_modules'), 'junction'); + const probePath = path.join(appRoot, 'startup-ready-probe.cjs'); + const probeSource = ` +const { app, BrowserWindow, ipcMain } = require('electron'); +const fs = require('node:fs'); +const path = require('node:path'); +const ConfigStore = require('./lib/config-store'); +const FolderMonitor = require('./lib/folder-monitor'); +const outputPath = process.env.MHU_STARTUP_READY_OUTPUT; +const userDataPath = process.env.MHU_STARTUP_READY_USER_DATA; +const watchPath = process.env.MHU_STARTUP_READY_WATCH; +app.setPath('userData', userDataPath); +BrowserWindow.prototype.show = function () {}; +let monitorStartedResolve; +const monitorStarted = new Promise(resolve => { monitorStartedResolve = resolve; }); +let getConfigStartedResolve; +const getConfigStarted = new Promise(resolve => { getConfigStartedResolve = resolve; }); +let releaseConfig; +const configRelease = new Promise(resolve => { releaseConfig = resolve; }); +let startupScanSettledResolve; +const startupScanSettled = new Promise(resolve => { startupScanSettledResolve = resolve; }); +let startupScanCalls = 0; +let startupScanSettledCalls = 0; +const originalStart = FolderMonitor.prototype.start; +FolderMonitor.prototype.start = function (...args) { + const result = originalStart.apply(this, args); + monitorStartedResolve(); + return result; +}; +const originalScan = FolderMonitor.prototype.scan; +FolderMonitor.prototype.scan = function (options = {}) { + const result = originalScan.call(this, options); + if (options.trigger === 'startup' && options.emitFiles === true) { + startupScanCalls++; + Promise.resolve(result).finally(() => { + startupScanSettledCalls++; + startupScanSettledResolve(); + }); + } + return result; +}; +const readySignals = { folder: 0, close: 0 }; +const originalOn = ipcMain.on.bind(ipcMain); +ipcMain.on = (channel, handler) => originalOn(channel, function (event, ...args) { + if (channel === 'folder-monitor:renderer-ready') readySignals.folder++; + if (channel === 'app:close-handshake-ready') readySignals.close++; + return handler.call(this, event, ...args); +}); +const originalHandle = ipcMain.handle.bind(ipcMain); +ipcMain.handle = (channel, handler) => originalHandle(channel, async function (event, ...args) { + if (channel === 'get-config') { + getConfigStartedResolve(); + await configRelease; + } + return handler.call(this, event, ...args); +}); + +async function waitFor(read, timeoutMs = 20000) { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + const value = await read(); + if (value) return value; + await new Promise(resolve => setTimeout(resolve, 25)); + } + throw new Error('startup ready probe timed out'); +} + +(async () => { + const store = new ConfigStore(app); + const current = store.load(); + await store.save({ + globalSettings: { + ...current.globalSettings, + language: 'en', + logVerbose: false, + logFilePath: path.join(userDataPath, 'fileuploader.log'), + folderMonitor: { + ...current.globalSettings.folderMonitor, + enabled: true, + folderPath: watchPath, + recursive: true, + extensions: 'mkv', + filterMode: 'include', + skipDuplicates: true, + includeExisting: false, + autoStart: false, + hosters: ['doodstream.com'], + queueLimitJobs: 15000, + reconcileIntervalMinutes: 60, + paused: false, + pausedAt: null + } + } + }); + require('./main.js'); + await app.whenReady(); + const window = await waitFor(async () => BrowserWindow.getAllWindows().find(candidate => !candidate.isDestroyed()) || null); + await Promise.all([monitorStarted, getConfigStarted]); + const beforeReady = { + startupScanCalls, + startupScanSettledCalls, + folderReadySignals: readySignals.folder + }; + if (startupScanCalls > 0) await startupScanSettled; + releaseConfig(); + await waitFor(async () => readySignals.close === 1); + await waitFor(async () => startupScanCalls === 1); + await startupScanSettled; + const rendererState = await waitFor(async () => { + try { + const state = await window.webContents.executeJavaScript("(async () => { if (typeof queueJobs === 'undefined' || typeof automationEventQueue === 'undefined') return null; const monitor = await window.api.folderMonitorStatus(); return { candidateJobs: queueJobs.filter(job => job.fileName === 'startup-ready.mkv'), draining: Boolean(automationEventDrainPromise), pendingCandidates: automationEventQueue.size, monitor }; })()"); + return state && !state.draining && state.pendingCandidates === 0 ? state : null; + } catch { + return null; + } + }); + fs.writeFileSync(outputPath, JSON.stringify({ + hidden: window.isVisible() === false, + beforeReady, + readySignals, + startupScanCalls, + startupScanSettledCalls, + rendererState + }), 'utf8'); + window.destroy(); + app.exit(0); +})().catch(error => { + fs.writeFileSync(outputPath, JSON.stringify({ error: error.stack || String(error), readySignals, startupScanCalls, startupScanSettledCalls }), 'utf8'); + app.exit(1); +}); +`; + fs.writeFileSync(probePath, probeSource, 'utf8'); + const electronPath = path.join(projectRoot, 'node_modules', 'electron', 'dist', 'electron.exe'); + const probeEnvironment = { + ...process.env, + MHU_PERF: '0', + MHU_STARTUP_READY_OUTPUT: outputPath, + MHU_STARTUP_READY_USER_DATA: userDataPath, + MHU_STARTUP_READY_WATCH: watchPath + }; + delete probeEnvironment.RUN_UI_SMOKE; + const execution = spawnSync(electronPath, [probePath, `--user-data-dir=${userDataPath}`], { + cwd: appRoot, + env: probeEnvironment, + encoding: 'utf8', + windowsHide: true, + timeout: 60000 + }); + const probeOutput = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, 'utf8') : ''; + assert.equal(execution.status, 0, `${execution.stdout}\n${execution.stderr}\n${probeOutput}`); + const outcome = JSON.parse(probeOutput); + assert.equal(outcome.error, undefined); + assert.equal(outcome.hidden, true); + assert.deepEqual({ + beforeReady: outcome.beforeReady, + readySignals: outcome.readySignals, + startupScanCalls: outcome.startupScanCalls, + startupScanSettledCalls: outcome.startupScanSettledCalls, + lastScanTrigger: outcome.rendererState.monitor.lastScanTrigger, + candidateNames: outcome.rendererState.candidateJobs.map(job => job.fileName) + }, { + beforeReady: { + startupScanCalls: 0, + startupScanSettledCalls: 0, + folderReadySignals: 0 + }, + readySignals: { folder: 1, close: 1 }, + startupScanCalls: 1, + startupScanSettledCalls: 1, + lastScanTrigger: 'startup', + candidateNames: ['startup-ready.mkv'] + }); + } finally { + fs.rmSync(probeRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); + assert.equal(fs.existsSync(probeRoot), false); + } +}); + test('real app resume keeps the ConfigStore-restored manual preview byte-identical without starting work', { skip: process.platform !== 'win32' }, () => { const projectRoot = path.join(__dirname, '..'); const probeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-real-resume-e2e-'));