diff --git a/main.js b/main.js index 496630c..c0e099e 100644 --- a/main.js +++ b/main.js @@ -112,9 +112,10 @@ let closePreparationAttempt = 0; let closeQuiesceOwnerAttempt = null; let lastRestoredCloseAttempt = null; let closeFolderMonitorWasRunning = false; -let folderMonitorRendererReady = false; -let folderMonitorStartupReconcilePending = false; -let folderMonitorStartupReconcileStarted = false; +let folderMonitorLifecycleGeneration = 0; +let folderMonitorRendererGeneration = 0; +let folderMonitorRendererReadyGeneration = null; +let folderMonitorStartupReconcile = null; let preparedUpdate = null; let updatePreparationPromise = null; let updateQuitPending = false; @@ -315,7 +316,7 @@ function requestClosePreparation() { const attempt = ++closePreparationAttempt; closeFlushRequested = true; closeFolderMonitorWasRunning = !!folderMonitor.running; - try { folderMonitor.stop(); } catch {} + try { stopFolderMonitor(); } catch {} try { if (uploadManager) uploadManager.cancel(); } catch {} (async () => { try { @@ -1521,9 +1522,10 @@ function createWindow() { closeFlushApproved = false; closeFlushRequested = false; closeHandshakeReady = false; - folderMonitorRendererReady = false; - folderMonitorStartupReconcilePending = false; - folderMonitorStartupReconcileStarted = false; + folderMonitorLifecycleGeneration = 0; + folderMonitorRendererGeneration = 0; + folderMonitorRendererReadyGeneration = null; + folderMonitorStartupReconcile = null; closeQuiesceOwnerAttempt = null; lastRestoredCloseAttempt = null; configStore.setWritesQuiesced(false); @@ -1536,8 +1538,8 @@ function createWindow() { }); mainWindow.on('closed', () => { - folderMonitorRendererReady = false; - folderMonitorStartupReconcilePending = false; + folderMonitorRendererReadyGeneration = null; + invalidateFolderMonitorLifecycle(); }); mainWindow.webContents.setBackgroundThrottling(false); @@ -1545,13 +1547,18 @@ function createWindow() { mainWindow.webContents.on('did-start-navigation', (_event, _url, isInPlace, isMainFrame) => { if (isInPlace || !isMainFrame) return; closeHandshakeReady = false; - folderMonitorRendererReady = false; + folderMonitorRendererGeneration++; + folderMonitorRendererReadyGeneration = null; updateAnnouncementState.reset(); restoreClosePreparation(closePreparationAttempt); }); + mainWindow.webContents.on('did-finish-load', () => { + safeSend('folder-monitor:renderer-generation', folderMonitorRendererGeneration); + }); + mainWindow.webContents.on('render-process-gone', (_event, details) => { - folderMonitorRendererReady = false; + folderMonitorRendererReadyGeneration = null; _writeCrashLog('RENDER PROCESS GONE', new Error(details.reason || 'unknown'), details); debugLog(`RENDER PROCESS GONE: reason=${details.reason} exitCode=${details.exitCode}`); if (mainWindow && !mainWindow.isDestroyed()) { @@ -1753,8 +1760,7 @@ app.on('before-quit', (event) => { app.on('will-quit', () => { if (quitTeardownStarted) return; quitTeardownStarted = true; - folderMonitorRendererReady = false; - folderMonitorStartupReconcilePending = false; + folderMonitorRendererReadyGeneration = null; if (updateCheckInterval) clearInterval(updateCheckInterval); updateCheckInterval = null; if (preparedUpdate && updateQuitPending && closeFlushApproved && !preparedUpdateLaunchStarted) { @@ -1769,7 +1775,7 @@ app.on('will-quit', () => { updateQuitPending = false; if (restartAfterClosePreparation) app.relaunch(); if (uploadManager) try { uploadManager.cancel(); } catch {} - try { folderMonitor.stop(); } catch {} + try { stopFolderMonitor(); } catch {} try { if (remoteServer) { remoteServer.stop(); remoteServer = null; } destroyCaptureWindow(); @@ -2808,7 +2814,7 @@ async function syncImportedRuntime(config) { warnings.push('allgemeine Laufzeiteinstellungen'); } try { - folderMonitor.stop(); + stopFolderMonitor(); const folderSettings = config.globalSettings.folderMonitor; if (folderSettings && folderSettings.enabled && folderSettings.folderPath) await startFolderMonitor(folderSettings); } catch (error) { @@ -3118,9 +3124,9 @@ ipcMain.on('app:close-handshake-ready', (event) => { } }); -ipcMain.on('folder-monitor:renderer-ready', (event) => { - if (mainWindow && !mainWindow.isDestroyed() && event.sender === mainWindow.webContents) { - folderMonitorRendererReady = true; +ipcMain.on('folder-monitor:renderer-ready', (event, generation) => { + if (mainWindow && !mainWindow.isDestroyed() && event.sender === mainWindow.webContents && Number.isSafeInteger(generation) && generation === folderMonitorRendererGeneration) { + folderMonitorRendererReadyGeneration = generation; releaseFolderMonitorStartupReconcile(); } }); @@ -3334,10 +3340,26 @@ function bindFolderMonitorEvents(settings) { }); } +function invalidateFolderMonitorLifecycle() { + folderMonitorLifecycleGeneration++; + folderMonitorStartupReconcile = null; + return folderMonitorLifecycleGeneration; +} + +function stopFolderMonitor() { + invalidateFolderMonitorLifecycle(); + return folderMonitor.stop(); +} + function releaseFolderMonitorStartupReconcile() { - if (!folderMonitorRendererReady || !folderMonitorStartupReconcilePending || folderMonitorStartupReconcileStarted || quitTeardownStarted || !folderMonitor.running) return false; - folderMonitorStartupReconcilePending = false; - folderMonitorStartupReconcileStarted = true; + const pending = folderMonitorStartupReconcile; + if (!pending || pending.monitorGeneration !== folderMonitorLifecycleGeneration) return false; + if (folderMonitorRendererReadyGeneration !== folderMonitorRendererGeneration) return false; + if (quitTeardownStarted || !folderMonitor.running) { + folderMonitorStartupReconcile = null; + return false; + } + folderMonitorStartupReconcile = null; try { void folderMonitor.scan({ emitFiles: true, trigger: 'startup' }).catch(error => { debugLog(`folder-monitor startup scan failed: ${error.message}`); @@ -3348,15 +3370,16 @@ function releaseFolderMonitorStartupReconcile() { return true; } -function deferFolderMonitorStartupReconcile() { - if (folderMonitorStartupReconcilePending || folderMonitorStartupReconcileStarted) return false; - folderMonitorStartupReconcilePending = true; +function deferFolderMonitorStartupReconcile(monitorGeneration) { + if (monitorGeneration !== folderMonitorLifecycleGeneration || folderMonitorStartupReconcile) return false; + folderMonitorStartupReconcile = Object.freeze({ monitorGeneration }); releaseFolderMonitorStartupReconcile(); return true; } async function startFolderMonitor(settings, options = {}) { try { + const monitorGeneration = invalidateFolderMonitorLifecycle(); const persisted = configStore.load().globalSettings?.folderMonitor || {}; const normalized = normalizeAutomationSettings(settings); const effectiveSettings = { @@ -3373,7 +3396,7 @@ async function startFolderMonitor(settings, options = {}) { return { includesExisting: false, paused: true }; } const result = folderMonitor.start(effectiveSettings); - if (options.deferStartupReconcile === true) deferFolderMonitorStartupReconcile(); + if (options.deferStartupReconcile === true) deferFolderMonitorStartupReconcile(monitorGeneration); else await folderMonitor.scan({ emitFiles: true, trigger: 'startup' }); debugLog(`folder-monitor started: ${settings.folderPath}`); return result; @@ -3384,6 +3407,7 @@ async function startFolderMonitor(settings, options = {}) { } async function resumeFolderMonitor(settings) { + invalidateFolderMonitorLifecycle(); bindFolderMonitorEvents(settings); const result = await folderMonitor.resume(settings, { reconcile: false }); debugLog(`folder-monitor resumed: ${settings.folderPath}`); @@ -3397,7 +3421,7 @@ ipcMain.handle('folder-monitor:start', async (_event, settings) => { }); ipcMain.handle('folder-monitor:stop', () => { - folderMonitor.stop(); + stopFolderMonitor(); debugLog('folder-monitor stopped'); return { ok: true }; }); @@ -3421,6 +3445,7 @@ ipcMain.handle('automation:pause-after-active', () => enqueueAutomationLifecycle folderMonitor: { ...settings, paused: true, pausedAt: Date.now() } } }); + invalidateFolderMonitorLifecycle(); try { await folderMonitor.pause(); } catch { @@ -3460,7 +3485,7 @@ ipcMain.handle('automation:resume', () => enqueueAutomationLifecycle(async gener await folderMonitor.scan({ emitFiles: true, trigger: 'resume' }); } } catch { - folderMonitor.stop(); + stopFolderMonitor(); if (pausedSettings.enabled && pausedSettings.folderPath) { bindFolderMonitorEvents(pausedSettings); folderMonitor.configure(pausedSettings); diff --git a/preload.js b/preload.js index 71da4a6..4355fcc 100644 --- a/preload.js +++ b/preload.js @@ -1,7 +1,21 @@ const { contextBridge, ipcRenderer, webUtils } = require('electron'); let folderMonitorCandidateListenerReady = false; -let folderMonitorReadySent = false; +let folderMonitorRendererGeneration = null; +let folderMonitorReadySentGeneration = null; + +function signalFolderMonitorReady() { + if (!folderMonitorCandidateListenerReady || !Number.isSafeInteger(folderMonitorRendererGeneration) || folderMonitorRendererGeneration <= 0 || folderMonitorReadySentGeneration === folderMonitorRendererGeneration) return false; + folderMonitorReadySentGeneration = folderMonitorRendererGeneration; + ipcRenderer.send('folder-monitor:renderer-ready', folderMonitorRendererGeneration); + return true; +} + +ipcRenderer.on('folder-monitor:renderer-generation', (_event, generation) => { + if (!Number.isSafeInteger(generation) || generation <= 0 || (folderMonitorRendererGeneration !== null && generation <= folderMonitorRendererGeneration)) return; + folderMonitorRendererGeneration = generation; + signalFolderMonitorReady(); +}); contextBridge.exposeInMainWorld('api', { // Config @@ -102,12 +116,7 @@ contextBridge.exposeInMainWorld('api', { 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; - }, + signalFolderMonitorReady, onAutomationStatus: (callback) => { ipcRenderer.on('automation:status', (_event, data) => callback(data)); }, @@ -187,7 +196,9 @@ contextBridge.exposeInMainWorld('api', { getPathForFile: (file) => webUtils.getPathForFile(file), removeAllListeners: () => { folderMonitorCandidateListenerReady = false; - folderMonitorReadySent = false; + folderMonitorRendererGeneration = null; + folderMonitorReadySentGeneration = null; + ipcRenderer.removeAllListeners('folder-monitor:renderer-generation'); ipcRenderer.removeAllListeners('upload-progress'); ipcRenderer.removeAllListeners('upload-batch-done'); ipcRenderer.removeAllListeners('upload-stats'); diff --git a/tests/package-build-files.test.js b/tests/package-build-files.test.js index a8630dc..4847c32 100644 --- a/tests/package-build-files.test.js +++ b/tests/package-build-files.test.js @@ -40,6 +40,7 @@ function createAutomationLifecycleHarness(mainSource) { assert.notEqual(blockStart, -1, 'automation lifecycle block missing'); assert.notEqual(blockEnd, -1, 'automation lifecycle block boundary missing'); const handlers = new Map(); + const onHandlers = new Map(); const order = []; const sent = []; const saves = []; @@ -113,24 +114,43 @@ function createAutomationLifecycleHarness(mainSource) { finishAfterActive: () => order.push('finish'), startBatch: () => order.push('startBatch') }; + const webContents = {}; const context = { configStore, debugLog: () => {}, dialog: { showOpenDialog: async () => ({ canceled: true, filePaths: [] }) }, folderMonitor, - ipcMain: { handle: (channel, handler) => handlers.set(channel, handler) }, + folderMonitorLifecycleGeneration: 0, + folderMonitorRendererGeneration: 1, + folderMonitorRendererReady: false, + folderMonitorRendererReadyGeneration: null, + folderMonitorStartupReconcile: null, + folderMonitorStartupReconcilePending: false, + folderMonitorStartupReconcileStarted: false, + ipcMain: { + handle: (channel, handler) => handlers.set(channel, handler), + on: (channel, handler) => onHandlers.set(channel, handler) + }, + mainWindow: { isDestroyed: () => false, webContents }, normalizeAutomationSettings: require('../lib/automation-control').normalizeAutomationSettings, path, + quitTeardownStarted: false, safeSend: (channel, snapshot) => { sent.push([channel, snapshot]); return true; }, uploadManager }; vm.runInNewContext(mainSource.slice(blockStart, blockEnd), context); + const readyStart = mainSource.indexOf("ipcMain.on('folder-monitor:renderer-ready'"); + const readyEnd = mainSource.indexOf("\nipcMain.on('app:close-preparation-started'", readyStart); + assert.notEqual(readyStart, -1, 'folder monitor renderer ready handler missing'); + assert.notEqual(readyEnd, -1, 'folder monitor renderer ready handler boundary missing'); + vm.runInNewContext(mainSource.slice(readyStart, readyEnd), context); publishStatus = () => context.publishAutomationStatus(); return { handlers, configuredSettings, context, order, + onHandlers, pauseDeferred, resumeDeferred, saves, @@ -140,6 +160,7 @@ function createAutomationLifecycleHarness(mainSource) { }, startedSettings, state: () => structuredClone(state), + webContents, publishStatus }; } @@ -357,9 +378,9 @@ test('folder monitor readiness is emitted once and only after the candidate list invoke: () => Promise.resolve(), on: (channel, listener) => { listeners.set(channel, listener); - order.push(`listen:${channel}`); + order.push(['listen', channel]); }, - send: channel => { order.push(`send:${channel}`); }, + send: (...args) => { order.push(['send', ...args]); }, removeAllListeners: () => {} }, webUtils: { @@ -383,16 +404,87 @@ test('folder monitor readiness is emitted once and only after the candidate list let received = null; exposedApi.onFolderMonitorNewFiles(files => { received = files; }); exposedApi.signalFolderMonitorReady(); + listeners.get('folder-monitor:renderer-generation')({}, 4); exposedApi.signalFolderMonitorReady(); + listeners.get('folder-monitor:renderer-generation')({}, 4); + listeners.get('folder-monitor:renderer-generation')({}, 3); + listeners.get('folder-monitor:renderer-generation')({}, 5); listeners.get('folder-monitor:new-files')({}, [{ path: 'C:\\watch\\ready.mkv' }]); assert.deepEqual(order, [ - 'listen:folder-monitor:new-files', - 'send:folder-monitor:renderer-ready' + ['listen', 'folder-monitor:renderer-generation'], + ['listen', 'folder-monitor:new-files'], + ['send', 'folder-monitor:renderer-ready', 4], + ['send', 'folder-monitor:renderer-ready', 5] ]); assert.deepEqual(received, [{ path: 'C:\\watch\\ready.mkv' }]); }); +test('stopped and resumed monitor generations cannot release a deferred startup reconcile', async () => { + const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8'); + const harness = createAutomationLifecycleHarness(mainSource); + const settings = { + enabled: true, + folderPath: 'C:\\watch', + paused: false, + pausedAt: null, + queueLimitJobs: 15000, + reconcileIntervalMinutes: 5 + }; + harness.setFolderMonitorState({ paused: false, pausedAt: null }); + + await harness.context.startFolderMonitor(settings, { deferStartupReconcile: true }); + const pause = harness.handlers.get('automation:pause-after-active')(); + await waitForCondition(() => harness.saves.length === 1); + harness.saves[0].deferred.resolve(); + await waitForCondition(() => harness.order.includes('pause')); + harness.pauseDeferred.resolve(); + await pause; + harness.handlers.get('folder-monitor:stop')(); + harness.onHandlers.get('folder-monitor:renderer-ready')({ sender: harness.webContents }, 1); + + harness.resumeDeferred.resolve(); + const resume = harness.handlers.get('automation:resume')(); + await waitForCondition(() => harness.saves.length === 2); + harness.saves[1].deferred.resolve(); + await resume; + harness.context.folderMonitorRendererGeneration = 2; + harness.context.folderMonitorRendererReadyGeneration = null; + harness.context.folderMonitorRendererReady = false; + harness.onHandlers.get('folder-monitor:renderer-ready')({ sender: harness.webContents }, 1); + harness.onHandlers.get('folder-monitor:renderer-ready')({ sender: harness.webContents }, 2); + + assert.deepEqual(harness.order.filter(entry => entry.startsWith('scan:')), ['scan:resume:true']); +}); + +test('a nondeferred monitor restart supersedes pending startup work and each generation releases at most once', async () => { + const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8'); + const settings = { + enabled: true, + folderPath: 'C:\\watch', + paused: false, + pausedAt: null, + queueLimitJobs: 15000, + reconcileIntervalMinutes: 5 + }; + const restarted = createAutomationLifecycleHarness(mainSource); + restarted.setFolderMonitorState({ paused: false, pausedAt: null }); + await restarted.context.startFolderMonitor(settings, { deferStartupReconcile: true }); + await restarted.context.startFolderMonitor(settings); + restarted.onHandlers.get('folder-monitor:renderer-ready')({ sender: restarted.webContents }, 1); + restarted.onHandlers.get('folder-monitor:renderer-ready')({ sender: restarted.webContents }, 1); + assert.deepEqual(restarted.order.filter(entry => entry.startsWith('scan:')), ['scan:startup:true']); + + const deferred = createAutomationLifecycleHarness(mainSource); + deferred.setFolderMonitorState({ paused: false, pausedAt: null }); + await deferred.context.startFolderMonitor(settings, { deferStartupReconcile: true }); + deferred.onHandlers.get('folder-monitor:renderer-ready')({ sender: {} }, 1); + assert.deepEqual(deferred.order.filter(entry => entry.startsWith('scan:')), []); + deferred.onHandlers.get('folder-monitor:renderer-ready')({ sender: deferred.webContents }, 1); + deferred.onHandlers.get('folder-monitor:renderer-ready')({ sender: deferred.webContents }, 1); + assert.deepEqual(deferred.order.filter(entry => entry.startsWith('scan:')), ['scan:startup:true']); +}); + test('preload exposes account cooldown snapshots and removes their listener during cleanup', async () => { const listeners = new Map(); const invocations = []; @@ -531,9 +623,14 @@ test('automation pause save commits before lifecycle effects and save failure is debugLog: () => {}, dialog: { showOpenDialog: async () => ({ canceled: true, filePaths: [] }) }, folderMonitor, + folderMonitorLifecycleGeneration: 0, + folderMonitorRendererGeneration: 1, + folderMonitorRendererReadyGeneration: null, + folderMonitorStartupReconcile: null, ipcMain: { handle: (channel, handler) => handlers.set(channel, handler) }, normalizeAutomationSettings: require('../lib/automation-control').normalizeAutomationSettings, path, + quitTeardownStarted: false, safeSend: (channel, snapshot) => { sent.push([channel, snapshot]); return true; }, uploadManager }); diff --git a/tests/startup-renderer.test.js b/tests/startup-renderer.test.js index 9105ed7..ba065af 100644 --- a/tests/startup-renderer.test.js +++ b/tests/startup-renderer.test.js @@ -3655,6 +3655,11 @@ const watchPath = process.env.MHU_AUTOMATION_WATCH; app.setPath('userData', process.env.MHU_AUTOMATION_USER_DATA); let configStore; let closeFlushRequested = false; +let folderMonitorLifecycleGeneration = 0; +let folderMonitorRendererGeneration = 1; +let folderMonitorRendererReadyGeneration = null; +let folderMonitorStartupReconcile = null; +let quitTeardownStarted = false; const settingsImportGate = { canStartUpload: () => true }; const sentEvents = []; const intervalCallbacks = new Set(); @@ -3868,6 +3873,234 @@ ${startupAutomation} } }); +function runStartupHandshakeScenario(scenario) { + const projectRoot = path.join(__dirname, '..'); + const probeRoot = fs.mkdtempSync(path.join(os.tmpdir(), `mhu-startup-${scenario}-`)); + 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, 'scenario-ready.mkv'), Buffer.alloc(23, 6)); + 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 }); + } + if (scenario === 'init-recovery') { + const rendererAppPath = path.join(appRoot, 'renderer', 'app.js'); + const rendererSource = fs.readFileSync(rendererAppPath, 'utf8'); + fs.writeFileSync(rendererAppPath, rendererSource.replace( + 'async function init() {', + "async function init() {\n if (sessionStorage.getItem('startup-init-failed') !== 'true') { sessionStorage.setItem('startup-init-failed', 'true'); throw new Error('forced renderer init failure'); }" + )); + } + fs.symlinkSync(path.join(projectRoot, 'node_modules'), path.join(appRoot, 'node_modules'), 'junction'); + const probePath = path.join(appRoot, 'scenario-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 scenario = process.env.MHU_STARTUP_SCENARIO; +const outputPath = process.env.MHU_STARTUP_SCENARIO_OUTPUT; +const userDataPath = process.env.MHU_STARTUP_SCENARIO_USER_DATA; +const watchPath = process.env.MHU_STARTUP_SCENARIO_WATCH; +app.setPath('userData', userDataPath); +BrowserWindow.prototype.show = function () {}; +const rendererGenerations = []; +const originalLoadFile = BrowserWindow.prototype.loadFile; +BrowserWindow.prototype.loadFile = function (...args) { + const originalSend = this.webContents.send.bind(this.webContents); + this.webContents.send = (channel, ...payload) => { + if (channel === 'folder-monitor:renderer-generation') rendererGenerations.push(payload[0]); + return originalSend(channel, ...payload); + }; + return originalLoadFile.apply(this, args); +}; +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 startupScanCalls = 0; +let getConfigCalls = 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 = {}) { + if (options.trigger === 'startup' && options.emitFiles === true) startupScanCalls++; + return originalScan.call(this, options); +}; +const readySignals = { folder: 0, close: 0 }; +let folderReadyHandler = null; +const originalOn = ipcMain.on.bind(ipcMain); +ipcMain.on = (channel, handler) => { + if (channel === 'folder-monitor:renderer-ready') folderReadyHandler = handler; + return 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') { + getConfigCalls++; + getConfigStartedResolve(); + if (scenario !== 'init-recovery') 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 scenario 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 monitorStarted; + if (scenario !== 'init-recovery') await getConfigStarted; + let initFailureVisible = false; + if (scenario === 'init-recovery') { + initFailureVisible = await waitFor(async () => { + try { + return await window.webContents.executeJavaScript("sessionStorage.getItem('startup-init-failed') === 'true'"); + } catch { + return false; + } + }); + window.webContents.reload(); + await waitFor(async () => readySignals.folder === 1 && readySignals.close === 1 && startupScanCalls === 1); + } else { + if (scenario === 'pending-close') window.emit('closed'); + if (scenario === 'pending-shutdown') app.emit('will-quit'); + folderReadyHandler({ sender: window.webContents }, rendererGenerations[0]); + releaseConfig(); + await waitFor(async () => readySignals.close === 1); + } + let rendererState = null; + if (scenario === 'init-recovery') { + rendererState = await waitFor(async () => { + try { + const state = await window.webContents.executeJavaScript("(() => { if (typeof queueJobs === 'undefined' || typeof automationEventQueue === 'undefined') return null; return { candidateNames: queueJobs.filter(job => job.fileName === 'scenario-ready.mkv').map(job => job.fileName), draining: Boolean(automationEventDrainPromise), pendingCandidates: automationEventQueue.size }; })()"); + return state && !state.draining && state.pendingCandidates === 0 ? state : null; + } catch { + return null; + } + }); + } + fs.writeFileSync(outputPath, JSON.stringify({ + hidden: window.isVisible() === false, + scenario, + initFailureVisible, + readySignals, + startupScanCalls, + getConfigCalls, + rendererGenerations, + rendererState + }), 'utf8'); + window.destroy(); + app.exit(0); +})().catch(error => { + fs.writeFileSync(outputPath, JSON.stringify({ error: error.stack || String(error), scenario, readySignals, startupScanCalls, getConfigCalls, rendererGenerations }), '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_SCENARIO: scenario, + MHU_STARTUP_SCENARIO_OUTPUT: outputPath, + MHU_STARTUP_SCENARIO_USER_DATA: userDataPath, + MHU_STARTUP_SCENARIO_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}`); + return JSON.parse(probeOutput); + } finally { + fs.rmSync(probeRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); + assert.equal(fs.existsSync(probeRoot), false); + } +} + +test('hidden renderer init failure leaves startup pending for one successful reload generation', { skip: process.platform !== 'win32' }, () => { + const outcome = runStartupHandshakeScenario('init-recovery'); + assert.equal(outcome.error, undefined); + assert.equal(outcome.hidden, true); + assert.equal(outcome.initFailureVisible, true); + assert.deepEqual(outcome.readySignals, { folder: 1, close: 1 }); + assert.equal(outcome.startupScanCalls, 1); + assert.equal(outcome.getConfigCalls, 1); + assert.deepEqual(outcome.rendererGenerations, [1, 2]); + assert.deepEqual(outcome.rendererState.candidateNames, ['scenario-ready.mkv']); +}); + +for (const scenario of ['pending-close', 'pending-shutdown']) { + test(`hidden ${scenario} invalidates startup pending before renderer readiness`, { skip: process.platform !== 'win32' }, () => { + const outcome = runStartupHandshakeScenario(scenario); + assert.equal(outcome.error, undefined); + assert.equal(outcome.hidden, true); + assert.deepEqual(outcome.readySignals, { folder: 1, close: 1 }); + assert.equal(outcome.startupScanCalls, 0); + assert.equal(outcome.getConfigCalls, 1); + assert.deepEqual(outcome.rendererGenerations, [1]); + }); +} + 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-')); @@ -3898,10 +4131,21 @@ 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 () {}; +const rendererGenerations = []; +const originalLoadFile = BrowserWindow.prototype.loadFile; +BrowserWindow.prototype.loadFile = function (...args) { + const originalSend = this.webContents.send.bind(this.webContents); + this.webContents.send = (channel, ...payload) => { + if (channel === 'folder-monitor:renderer-generation') rendererGenerations.push(payload[0]); + return originalSend(channel, ...payload); + }; + return originalLoadFile.apply(this, args); +}; let monitorStartedResolve; const monitorStarted = new Promise(resolve => { monitorStartedResolve = resolve; }); let getConfigStartedResolve; const getConfigStarted = new Promise(resolve => { getConfigStartedResolve = resolve; }); +let getConfigCalls = 0; let releaseConfig; const configRelease = new Promise(resolve => { releaseConfig = resolve; }); let startupScanSettledResolve; @@ -3927,15 +4171,20 @@ FolderMonitor.prototype.scan = function (options = {}) { return result; }; const readySignals = { folder: 0, close: 0 }; +let folderReadyHandler = null; 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); -}); +ipcMain.on = (channel, handler) => { + if (channel === 'folder-monitor:renderer-ready') folderReadyHandler = handler; + return 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') { + getConfigCalls++; getConfigStartedResolve(); await configRelease; } @@ -3988,7 +4237,15 @@ async function waitFor(read, timeoutMs = 20000) { startupScanSettledCalls, folderReadySignals: readySignals.folder }; - if (startupScanCalls > 0) await startupScanSettled; + const foreign = new BrowserWindow({ show: false }); + await foreign.loadURL('data:text/html,'); + folderReadyHandler({ sender: foreign.webContents }, rendererGenerations[0]); + const afterForeignSignal = startupScanCalls; + window.webContents.reload(); + await waitFor(async () => getConfigCalls === 2); + const beforeStaleSignal = startupScanCalls; + folderReadyHandler({ sender: window.webContents }, rendererGenerations[0]); + const afterStaleSignal = startupScanCalls; releaseConfig(); await waitFor(async () => readySignals.close === 1); await waitFor(async () => startupScanCalls === 1); @@ -4001,18 +4258,26 @@ async function waitFor(read, timeoutMs = 20000) { return null; } }); + window.webContents.reload(); + await waitFor(async () => readySignals.folder === 2 && readySignals.close === 2); fs.writeFileSync(outputPath, JSON.stringify({ hidden: window.isVisible() === false, beforeReady, readySignals, startupScanCalls, startupScanSettledCalls, + getConfigCalls, + rendererGenerations, + afterForeignSignal, + beforeStaleSignal, + afterStaleSignal, rendererState }), 'utf8'); + foreign.destroy(); window.destroy(); app.exit(0); })().catch(error => { - fs.writeFileSync(outputPath, JSON.stringify({ error: error.stack || String(error), readySignals, startupScanCalls, startupScanSettledCalls }), 'utf8'); + fs.writeFileSync(outputPath, JSON.stringify({ error: error.stack || String(error), readySignals, startupScanCalls, startupScanSettledCalls, getConfigCalls, rendererGenerations }), 'utf8'); app.exit(1); }); `; @@ -4043,6 +4308,11 @@ async function waitFor(read, timeoutMs = 20000) { readySignals: outcome.readySignals, startupScanCalls: outcome.startupScanCalls, startupScanSettledCalls: outcome.startupScanSettledCalls, + getConfigCalls: outcome.getConfigCalls, + rendererGenerations: outcome.rendererGenerations, + afterForeignSignal: outcome.afterForeignSignal, + beforeStaleSignal: outcome.beforeStaleSignal, + afterStaleSignal: outcome.afterStaleSignal, lastScanTrigger: outcome.rendererState.monitor.lastScanTrigger, candidateNames: outcome.rendererState.candidateJobs.map(job => job.fileName) }, { @@ -4051,9 +4321,14 @@ async function waitFor(read, timeoutMs = 20000) { startupScanSettledCalls: 0, folderReadySignals: 0 }, - readySignals: { folder: 1, close: 1 }, + readySignals: { folder: 2, close: 2 }, startupScanCalls: 1, startupScanSettledCalls: 1, + getConfigCalls: 3, + rendererGenerations: [1, 2, 3], + afterForeignSignal: 0, + beforeStaleSignal: 0, + afterStaleSignal: 0, lastScanTrigger: 'startup', candidateNames: ['startup-ready.mkv'] });