fix: invalidate stale startup reconcile generations
Bind deferred startup reconciliation to an explicit FolderMonitor lifecycle generation and invalidate pending work on stop, pause, resume, import replacement, shutdown, and every non-deferred restart. This prevents an old startup request from surviving into a later productive monitor lifecycle. Challenge each renderer document with a monotonic generation before accepting readiness, reject stale or foreign acknowledgements, and preserve exactly-once release across reloads and renderer initialization recovery. Add dynamic hidden Electron and lifecycle harness coverage for stale readiness, restart, close, shutdown, and ARIA regressions.
This commit is contained in:
@@ -112,9 +112,10 @@ let closePreparationAttempt = 0;
|
|||||||
let closeQuiesceOwnerAttempt = null;
|
let closeQuiesceOwnerAttempt = null;
|
||||||
let lastRestoredCloseAttempt = null;
|
let lastRestoredCloseAttempt = null;
|
||||||
let closeFolderMonitorWasRunning = false;
|
let closeFolderMonitorWasRunning = false;
|
||||||
let folderMonitorRendererReady = false;
|
let folderMonitorLifecycleGeneration = 0;
|
||||||
let folderMonitorStartupReconcilePending = false;
|
let folderMonitorRendererGeneration = 0;
|
||||||
let folderMonitorStartupReconcileStarted = false;
|
let folderMonitorRendererReadyGeneration = null;
|
||||||
|
let folderMonitorStartupReconcile = null;
|
||||||
let preparedUpdate = null;
|
let preparedUpdate = null;
|
||||||
let updatePreparationPromise = null;
|
let updatePreparationPromise = null;
|
||||||
let updateQuitPending = false;
|
let updateQuitPending = false;
|
||||||
@@ -315,7 +316,7 @@ function requestClosePreparation() {
|
|||||||
const attempt = ++closePreparationAttempt;
|
const attempt = ++closePreparationAttempt;
|
||||||
closeFlushRequested = true;
|
closeFlushRequested = true;
|
||||||
closeFolderMonitorWasRunning = !!folderMonitor.running;
|
closeFolderMonitorWasRunning = !!folderMonitor.running;
|
||||||
try { folderMonitor.stop(); } catch {}
|
try { stopFolderMonitor(); } catch {}
|
||||||
try { if (uploadManager) uploadManager.cancel(); } catch {}
|
try { if (uploadManager) uploadManager.cancel(); } catch {}
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -1521,9 +1522,10 @@ function createWindow() {
|
|||||||
closeFlushApproved = false;
|
closeFlushApproved = false;
|
||||||
closeFlushRequested = false;
|
closeFlushRequested = false;
|
||||||
closeHandshakeReady = false;
|
closeHandshakeReady = false;
|
||||||
folderMonitorRendererReady = false;
|
folderMonitorLifecycleGeneration = 0;
|
||||||
folderMonitorStartupReconcilePending = false;
|
folderMonitorRendererGeneration = 0;
|
||||||
folderMonitorStartupReconcileStarted = false;
|
folderMonitorRendererReadyGeneration = null;
|
||||||
|
folderMonitorStartupReconcile = null;
|
||||||
closeQuiesceOwnerAttempt = null;
|
closeQuiesceOwnerAttempt = null;
|
||||||
lastRestoredCloseAttempt = null;
|
lastRestoredCloseAttempt = null;
|
||||||
configStore.setWritesQuiesced(false);
|
configStore.setWritesQuiesced(false);
|
||||||
@@ -1536,8 +1538,8 @@ function createWindow() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
mainWindow.on('closed', () => {
|
mainWindow.on('closed', () => {
|
||||||
folderMonitorRendererReady = false;
|
folderMonitorRendererReadyGeneration = null;
|
||||||
folderMonitorStartupReconcilePending = false;
|
invalidateFolderMonitorLifecycle();
|
||||||
});
|
});
|
||||||
|
|
||||||
mainWindow.webContents.setBackgroundThrottling(false);
|
mainWindow.webContents.setBackgroundThrottling(false);
|
||||||
@@ -1545,13 +1547,18 @@ function createWindow() {
|
|||||||
mainWindow.webContents.on('did-start-navigation', (_event, _url, isInPlace, isMainFrame) => {
|
mainWindow.webContents.on('did-start-navigation', (_event, _url, isInPlace, isMainFrame) => {
|
||||||
if (isInPlace || !isMainFrame) return;
|
if (isInPlace || !isMainFrame) return;
|
||||||
closeHandshakeReady = false;
|
closeHandshakeReady = false;
|
||||||
folderMonitorRendererReady = false;
|
folderMonitorRendererGeneration++;
|
||||||
|
folderMonitorRendererReadyGeneration = null;
|
||||||
updateAnnouncementState.reset();
|
updateAnnouncementState.reset();
|
||||||
restoreClosePreparation(closePreparationAttempt);
|
restoreClosePreparation(closePreparationAttempt);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
mainWindow.webContents.on('did-finish-load', () => {
|
||||||
|
safeSend('folder-monitor:renderer-generation', folderMonitorRendererGeneration);
|
||||||
|
});
|
||||||
|
|
||||||
mainWindow.webContents.on('render-process-gone', (_event, details) => {
|
mainWindow.webContents.on('render-process-gone', (_event, details) => {
|
||||||
folderMonitorRendererReady = false;
|
folderMonitorRendererReadyGeneration = null;
|
||||||
_writeCrashLog('RENDER PROCESS GONE', new Error(details.reason || 'unknown'), details);
|
_writeCrashLog('RENDER PROCESS GONE', new Error(details.reason || 'unknown'), details);
|
||||||
debugLog(`RENDER PROCESS GONE: reason=${details.reason} exitCode=${details.exitCode}`);
|
debugLog(`RENDER PROCESS GONE: reason=${details.reason} exitCode=${details.exitCode}`);
|
||||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
@@ -1753,8 +1760,7 @@ app.on('before-quit', (event) => {
|
|||||||
app.on('will-quit', () => {
|
app.on('will-quit', () => {
|
||||||
if (quitTeardownStarted) return;
|
if (quitTeardownStarted) return;
|
||||||
quitTeardownStarted = true;
|
quitTeardownStarted = true;
|
||||||
folderMonitorRendererReady = false;
|
folderMonitorRendererReadyGeneration = null;
|
||||||
folderMonitorStartupReconcilePending = false;
|
|
||||||
if (updateCheckInterval) clearInterval(updateCheckInterval);
|
if (updateCheckInterval) clearInterval(updateCheckInterval);
|
||||||
updateCheckInterval = null;
|
updateCheckInterval = null;
|
||||||
if (preparedUpdate && updateQuitPending && closeFlushApproved && !preparedUpdateLaunchStarted) {
|
if (preparedUpdate && updateQuitPending && closeFlushApproved && !preparedUpdateLaunchStarted) {
|
||||||
@@ -1769,7 +1775,7 @@ app.on('will-quit', () => {
|
|||||||
updateQuitPending = false;
|
updateQuitPending = false;
|
||||||
if (restartAfterClosePreparation) app.relaunch();
|
if (restartAfterClosePreparation) app.relaunch();
|
||||||
if (uploadManager) try { uploadManager.cancel(); } catch {}
|
if (uploadManager) try { uploadManager.cancel(); } catch {}
|
||||||
try { folderMonitor.stop(); } catch {}
|
try { stopFolderMonitor(); } catch {}
|
||||||
try {
|
try {
|
||||||
if (remoteServer) { remoteServer.stop(); remoteServer = null; }
|
if (remoteServer) { remoteServer.stop(); remoteServer = null; }
|
||||||
destroyCaptureWindow();
|
destroyCaptureWindow();
|
||||||
@@ -2808,7 +2814,7 @@ async function syncImportedRuntime(config) {
|
|||||||
warnings.push('allgemeine Laufzeiteinstellungen');
|
warnings.push('allgemeine Laufzeiteinstellungen');
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
folderMonitor.stop();
|
stopFolderMonitor();
|
||||||
const folderSettings = config.globalSettings.folderMonitor;
|
const folderSettings = config.globalSettings.folderMonitor;
|
||||||
if (folderSettings && folderSettings.enabled && folderSettings.folderPath) await startFolderMonitor(folderSettings);
|
if (folderSettings && folderSettings.enabled && folderSettings.folderPath) await startFolderMonitor(folderSettings);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -3118,9 +3124,9 @@ ipcMain.on('app:close-handshake-ready', (event) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on('folder-monitor:renderer-ready', (event) => {
|
ipcMain.on('folder-monitor:renderer-ready', (event, generation) => {
|
||||||
if (mainWindow && !mainWindow.isDestroyed() && event.sender === mainWindow.webContents) {
|
if (mainWindow && !mainWindow.isDestroyed() && event.sender === mainWindow.webContents && Number.isSafeInteger(generation) && generation === folderMonitorRendererGeneration) {
|
||||||
folderMonitorRendererReady = true;
|
folderMonitorRendererReadyGeneration = generation;
|
||||||
releaseFolderMonitorStartupReconcile();
|
releaseFolderMonitorStartupReconcile();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -3334,10 +3340,26 @@ function bindFolderMonitorEvents(settings) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function invalidateFolderMonitorLifecycle() {
|
||||||
|
folderMonitorLifecycleGeneration++;
|
||||||
|
folderMonitorStartupReconcile = null;
|
||||||
|
return folderMonitorLifecycleGeneration;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopFolderMonitor() {
|
||||||
|
invalidateFolderMonitorLifecycle();
|
||||||
|
return folderMonitor.stop();
|
||||||
|
}
|
||||||
|
|
||||||
function releaseFolderMonitorStartupReconcile() {
|
function releaseFolderMonitorStartupReconcile() {
|
||||||
if (!folderMonitorRendererReady || !folderMonitorStartupReconcilePending || folderMonitorStartupReconcileStarted || quitTeardownStarted || !folderMonitor.running) return false;
|
const pending = folderMonitorStartupReconcile;
|
||||||
folderMonitorStartupReconcilePending = false;
|
if (!pending || pending.monitorGeneration !== folderMonitorLifecycleGeneration) return false;
|
||||||
folderMonitorStartupReconcileStarted = true;
|
if (folderMonitorRendererReadyGeneration !== folderMonitorRendererGeneration) return false;
|
||||||
|
if (quitTeardownStarted || !folderMonitor.running) {
|
||||||
|
folderMonitorStartupReconcile = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
folderMonitorStartupReconcile = null;
|
||||||
try {
|
try {
|
||||||
void folderMonitor.scan({ emitFiles: true, trigger: 'startup' }).catch(error => {
|
void folderMonitor.scan({ emitFiles: true, trigger: 'startup' }).catch(error => {
|
||||||
debugLog(`folder-monitor startup scan failed: ${error.message}`);
|
debugLog(`folder-monitor startup scan failed: ${error.message}`);
|
||||||
@@ -3348,15 +3370,16 @@ function releaseFolderMonitorStartupReconcile() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function deferFolderMonitorStartupReconcile() {
|
function deferFolderMonitorStartupReconcile(monitorGeneration) {
|
||||||
if (folderMonitorStartupReconcilePending || folderMonitorStartupReconcileStarted) return false;
|
if (monitorGeneration !== folderMonitorLifecycleGeneration || folderMonitorStartupReconcile) return false;
|
||||||
folderMonitorStartupReconcilePending = true;
|
folderMonitorStartupReconcile = Object.freeze({ monitorGeneration });
|
||||||
releaseFolderMonitorStartupReconcile();
|
releaseFolderMonitorStartupReconcile();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startFolderMonitor(settings, options = {}) {
|
async function startFolderMonitor(settings, options = {}) {
|
||||||
try {
|
try {
|
||||||
|
const monitorGeneration = invalidateFolderMonitorLifecycle();
|
||||||
const persisted = configStore.load().globalSettings?.folderMonitor || {};
|
const persisted = configStore.load().globalSettings?.folderMonitor || {};
|
||||||
const normalized = normalizeAutomationSettings(settings);
|
const normalized = normalizeAutomationSettings(settings);
|
||||||
const effectiveSettings = {
|
const effectiveSettings = {
|
||||||
@@ -3373,7 +3396,7 @@ async function startFolderMonitor(settings, options = {}) {
|
|||||||
return { includesExisting: false, paused: true };
|
return { includesExisting: false, paused: true };
|
||||||
}
|
}
|
||||||
const result = folderMonitor.start(effectiveSettings);
|
const result = folderMonitor.start(effectiveSettings);
|
||||||
if (options.deferStartupReconcile === true) deferFolderMonitorStartupReconcile();
|
if (options.deferStartupReconcile === true) deferFolderMonitorStartupReconcile(monitorGeneration);
|
||||||
else await folderMonitor.scan({ emitFiles: true, trigger: 'startup' });
|
else await folderMonitor.scan({ emitFiles: true, trigger: 'startup' });
|
||||||
debugLog(`folder-monitor started: ${settings.folderPath}`);
|
debugLog(`folder-monitor started: ${settings.folderPath}`);
|
||||||
return result;
|
return result;
|
||||||
@@ -3384,6 +3407,7 @@ async function startFolderMonitor(settings, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resumeFolderMonitor(settings) {
|
async function resumeFolderMonitor(settings) {
|
||||||
|
invalidateFolderMonitorLifecycle();
|
||||||
bindFolderMonitorEvents(settings);
|
bindFolderMonitorEvents(settings);
|
||||||
const result = await folderMonitor.resume(settings, { reconcile: false });
|
const result = await folderMonitor.resume(settings, { reconcile: false });
|
||||||
debugLog(`folder-monitor resumed: ${settings.folderPath}`);
|
debugLog(`folder-monitor resumed: ${settings.folderPath}`);
|
||||||
@@ -3397,7 +3421,7 @@ ipcMain.handle('folder-monitor:start', async (_event, settings) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('folder-monitor:stop', () => {
|
ipcMain.handle('folder-monitor:stop', () => {
|
||||||
folderMonitor.stop();
|
stopFolderMonitor();
|
||||||
debugLog('folder-monitor stopped');
|
debugLog('folder-monitor stopped');
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
@@ -3421,6 +3445,7 @@ ipcMain.handle('automation:pause-after-active', () => enqueueAutomationLifecycle
|
|||||||
folderMonitor: { ...settings, paused: true, pausedAt: Date.now() }
|
folderMonitor: { ...settings, paused: true, pausedAt: Date.now() }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
invalidateFolderMonitorLifecycle();
|
||||||
try {
|
try {
|
||||||
await folderMonitor.pause();
|
await folderMonitor.pause();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -3460,7 +3485,7 @@ ipcMain.handle('automation:resume', () => enqueueAutomationLifecycle(async gener
|
|||||||
await folderMonitor.scan({ emitFiles: true, trigger: 'resume' });
|
await folderMonitor.scan({ emitFiles: true, trigger: 'resume' });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
folderMonitor.stop();
|
stopFolderMonitor();
|
||||||
if (pausedSettings.enabled && pausedSettings.folderPath) {
|
if (pausedSettings.enabled && pausedSettings.folderPath) {
|
||||||
bindFolderMonitorEvents(pausedSettings);
|
bindFolderMonitorEvents(pausedSettings);
|
||||||
folderMonitor.configure(pausedSettings);
|
folderMonitor.configure(pausedSettings);
|
||||||
|
|||||||
+19
-8
@@ -1,7 +1,21 @@
|
|||||||
const { contextBridge, ipcRenderer, webUtils } = require('electron');
|
const { contextBridge, ipcRenderer, webUtils } = require('electron');
|
||||||
|
|
||||||
let folderMonitorCandidateListenerReady = false;
|
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', {
|
contextBridge.exposeInMainWorld('api', {
|
||||||
// Config
|
// Config
|
||||||
@@ -102,12 +116,7 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
ipcRenderer.on('folder-monitor:new-files', (_event, data) => callback(data));
|
ipcRenderer.on('folder-monitor:new-files', (_event, data) => callback(data));
|
||||||
folderMonitorCandidateListenerReady = true;
|
folderMonitorCandidateListenerReady = true;
|
||||||
},
|
},
|
||||||
signalFolderMonitorReady: () => {
|
signalFolderMonitorReady,
|
||||||
if (!folderMonitorCandidateListenerReady || folderMonitorReadySent) return false;
|
|
||||||
folderMonitorReadySent = true;
|
|
||||||
ipcRenderer.send('folder-monitor:renderer-ready');
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
onAutomationStatus: (callback) => {
|
onAutomationStatus: (callback) => {
|
||||||
ipcRenderer.on('automation:status', (_event, data) => callback(data));
|
ipcRenderer.on('automation:status', (_event, data) => callback(data));
|
||||||
},
|
},
|
||||||
@@ -187,7 +196,9 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
getPathForFile: (file) => webUtils.getPathForFile(file),
|
getPathForFile: (file) => webUtils.getPathForFile(file),
|
||||||
removeAllListeners: () => {
|
removeAllListeners: () => {
|
||||||
folderMonitorCandidateListenerReady = false;
|
folderMonitorCandidateListenerReady = false;
|
||||||
folderMonitorReadySent = false;
|
folderMonitorRendererGeneration = null;
|
||||||
|
folderMonitorReadySentGeneration = null;
|
||||||
|
ipcRenderer.removeAllListeners('folder-monitor:renderer-generation');
|
||||||
ipcRenderer.removeAllListeners('upload-progress');
|
ipcRenderer.removeAllListeners('upload-progress');
|
||||||
ipcRenderer.removeAllListeners('upload-batch-done');
|
ipcRenderer.removeAllListeners('upload-batch-done');
|
||||||
ipcRenderer.removeAllListeners('upload-stats');
|
ipcRenderer.removeAllListeners('upload-stats');
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ function createAutomationLifecycleHarness(mainSource) {
|
|||||||
assert.notEqual(blockStart, -1, 'automation lifecycle block missing');
|
assert.notEqual(blockStart, -1, 'automation lifecycle block missing');
|
||||||
assert.notEqual(blockEnd, -1, 'automation lifecycle block boundary missing');
|
assert.notEqual(blockEnd, -1, 'automation lifecycle block boundary missing');
|
||||||
const handlers = new Map();
|
const handlers = new Map();
|
||||||
|
const onHandlers = new Map();
|
||||||
const order = [];
|
const order = [];
|
||||||
const sent = [];
|
const sent = [];
|
||||||
const saves = [];
|
const saves = [];
|
||||||
@@ -113,24 +114,43 @@ function createAutomationLifecycleHarness(mainSource) {
|
|||||||
finishAfterActive: () => order.push('finish'),
|
finishAfterActive: () => order.push('finish'),
|
||||||
startBatch: () => order.push('startBatch')
|
startBatch: () => order.push('startBatch')
|
||||||
};
|
};
|
||||||
|
const webContents = {};
|
||||||
const context = {
|
const context = {
|
||||||
configStore,
|
configStore,
|
||||||
debugLog: () => {},
|
debugLog: () => {},
|
||||||
dialog: { showOpenDialog: async () => ({ canceled: true, filePaths: [] }) },
|
dialog: { showOpenDialog: async () => ({ canceled: true, filePaths: [] }) },
|
||||||
folderMonitor,
|
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,
|
normalizeAutomationSettings: require('../lib/automation-control').normalizeAutomationSettings,
|
||||||
path,
|
path,
|
||||||
|
quitTeardownStarted: false,
|
||||||
safeSend: (channel, snapshot) => { sent.push([channel, snapshot]); return true; },
|
safeSend: (channel, snapshot) => { sent.push([channel, snapshot]); return true; },
|
||||||
uploadManager
|
uploadManager
|
||||||
};
|
};
|
||||||
vm.runInNewContext(mainSource.slice(blockStart, blockEnd), context);
|
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();
|
publishStatus = () => context.publishAutomationStatus();
|
||||||
return {
|
return {
|
||||||
handlers,
|
handlers,
|
||||||
configuredSettings,
|
configuredSettings,
|
||||||
context,
|
context,
|
||||||
order,
|
order,
|
||||||
|
onHandlers,
|
||||||
pauseDeferred,
|
pauseDeferred,
|
||||||
resumeDeferred,
|
resumeDeferred,
|
||||||
saves,
|
saves,
|
||||||
@@ -140,6 +160,7 @@ function createAutomationLifecycleHarness(mainSource) {
|
|||||||
},
|
},
|
||||||
startedSettings,
|
startedSettings,
|
||||||
state: () => structuredClone(state),
|
state: () => structuredClone(state),
|
||||||
|
webContents,
|
||||||
publishStatus
|
publishStatus
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -357,9 +378,9 @@ test('folder monitor readiness is emitted once and only after the candidate list
|
|||||||
invoke: () => Promise.resolve(),
|
invoke: () => Promise.resolve(),
|
||||||
on: (channel, listener) => {
|
on: (channel, listener) => {
|
||||||
listeners.set(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: () => {}
|
removeAllListeners: () => {}
|
||||||
},
|
},
|
||||||
webUtils: {
|
webUtils: {
|
||||||
@@ -383,16 +404,87 @@ test('folder monitor readiness is emitted once and only after the candidate list
|
|||||||
let received = null;
|
let received = null;
|
||||||
exposedApi.onFolderMonitorNewFiles(files => { received = files; });
|
exposedApi.onFolderMonitorNewFiles(files => { received = files; });
|
||||||
exposedApi.signalFolderMonitorReady();
|
exposedApi.signalFolderMonitorReady();
|
||||||
|
listeners.get('folder-monitor:renderer-generation')({}, 4);
|
||||||
exposedApi.signalFolderMonitorReady();
|
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' }]);
|
listeners.get('folder-monitor:new-files')({}, [{ path: 'C:\\watch\\ready.mkv' }]);
|
||||||
|
|
||||||
assert.deepEqual(order, [
|
assert.deepEqual(order, [
|
||||||
'listen:folder-monitor:new-files',
|
['listen', 'folder-monitor:renderer-generation'],
|
||||||
'send:folder-monitor:renderer-ready'
|
['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' }]);
|
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 () => {
|
test('preload exposes account cooldown snapshots and removes their listener during cleanup', async () => {
|
||||||
const listeners = new Map();
|
const listeners = new Map();
|
||||||
const invocations = [];
|
const invocations = [];
|
||||||
@@ -531,9 +623,14 @@ test('automation pause save commits before lifecycle effects and save failure is
|
|||||||
debugLog: () => {},
|
debugLog: () => {},
|
||||||
dialog: { showOpenDialog: async () => ({ canceled: true, filePaths: [] }) },
|
dialog: { showOpenDialog: async () => ({ canceled: true, filePaths: [] }) },
|
||||||
folderMonitor,
|
folderMonitor,
|
||||||
|
folderMonitorLifecycleGeneration: 0,
|
||||||
|
folderMonitorRendererGeneration: 1,
|
||||||
|
folderMonitorRendererReadyGeneration: null,
|
||||||
|
folderMonitorStartupReconcile: null,
|
||||||
ipcMain: { handle: (channel, handler) => handlers.set(channel, handler) },
|
ipcMain: { handle: (channel, handler) => handlers.set(channel, handler) },
|
||||||
normalizeAutomationSettings: require('../lib/automation-control').normalizeAutomationSettings,
|
normalizeAutomationSettings: require('../lib/automation-control').normalizeAutomationSettings,
|
||||||
path,
|
path,
|
||||||
|
quitTeardownStarted: false,
|
||||||
safeSend: (channel, snapshot) => { sent.push([channel, snapshot]); return true; },
|
safeSend: (channel, snapshot) => { sent.push([channel, snapshot]); return true; },
|
||||||
uploadManager
|
uploadManager
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3655,6 +3655,11 @@ const watchPath = process.env.MHU_AUTOMATION_WATCH;
|
|||||||
app.setPath('userData', process.env.MHU_AUTOMATION_USER_DATA);
|
app.setPath('userData', process.env.MHU_AUTOMATION_USER_DATA);
|
||||||
let configStore;
|
let configStore;
|
||||||
let closeFlushRequested = false;
|
let closeFlushRequested = false;
|
||||||
|
let folderMonitorLifecycleGeneration = 0;
|
||||||
|
let folderMonitorRendererGeneration = 1;
|
||||||
|
let folderMonitorRendererReadyGeneration = null;
|
||||||
|
let folderMonitorStartupReconcile = null;
|
||||||
|
let quitTeardownStarted = false;
|
||||||
const settingsImportGate = { canStartUpload: () => true };
|
const settingsImportGate = { canStartUpload: () => true };
|
||||||
const sentEvents = [];
|
const sentEvents = [];
|
||||||
const intervalCallbacks = new Set();
|
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' }, () => {
|
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 projectRoot = path.join(__dirname, '..');
|
||||||
const probeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-startup-ready-e2e-'));
|
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;
|
const watchPath = process.env.MHU_STARTUP_READY_WATCH;
|
||||||
app.setPath('userData', userDataPath);
|
app.setPath('userData', userDataPath);
|
||||||
BrowserWindow.prototype.show = function () {};
|
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;
|
let monitorStartedResolve;
|
||||||
const monitorStarted = new Promise(resolve => { monitorStartedResolve = resolve; });
|
const monitorStarted = new Promise(resolve => { monitorStartedResolve = resolve; });
|
||||||
let getConfigStartedResolve;
|
let getConfigStartedResolve;
|
||||||
const getConfigStarted = new Promise(resolve => { getConfigStartedResolve = resolve; });
|
const getConfigStarted = new Promise(resolve => { getConfigStartedResolve = resolve; });
|
||||||
|
let getConfigCalls = 0;
|
||||||
let releaseConfig;
|
let releaseConfig;
|
||||||
const configRelease = new Promise(resolve => { releaseConfig = resolve; });
|
const configRelease = new Promise(resolve => { releaseConfig = resolve; });
|
||||||
let startupScanSettledResolve;
|
let startupScanSettledResolve;
|
||||||
@@ -3927,15 +4171,20 @@ FolderMonitor.prototype.scan = function (options = {}) {
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
const readySignals = { folder: 0, close: 0 };
|
const readySignals = { folder: 0, close: 0 };
|
||||||
|
let folderReadyHandler = null;
|
||||||
const originalOn = ipcMain.on.bind(ipcMain);
|
const originalOn = ipcMain.on.bind(ipcMain);
|
||||||
ipcMain.on = (channel, handler) => originalOn(channel, function (event, ...args) {
|
ipcMain.on = (channel, handler) => {
|
||||||
if (channel === 'folder-monitor:renderer-ready') readySignals.folder++;
|
if (channel === 'folder-monitor:renderer-ready') folderReadyHandler = handler;
|
||||||
if (channel === 'app:close-handshake-ready') readySignals.close++;
|
return originalOn(channel, function (event, ...args) {
|
||||||
return handler.call(this, 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);
|
const originalHandle = ipcMain.handle.bind(ipcMain);
|
||||||
ipcMain.handle = (channel, handler) => originalHandle(channel, async function (event, ...args) {
|
ipcMain.handle = (channel, handler) => originalHandle(channel, async function (event, ...args) {
|
||||||
if (channel === 'get-config') {
|
if (channel === 'get-config') {
|
||||||
|
getConfigCalls++;
|
||||||
getConfigStartedResolve();
|
getConfigStartedResolve();
|
||||||
await configRelease;
|
await configRelease;
|
||||||
}
|
}
|
||||||
@@ -3988,7 +4237,15 @@ async function waitFor(read, timeoutMs = 20000) {
|
|||||||
startupScanSettledCalls,
|
startupScanSettledCalls,
|
||||||
folderReadySignals: readySignals.folder
|
folderReadySignals: readySignals.folder
|
||||||
};
|
};
|
||||||
if (startupScanCalls > 0) await startupScanSettled;
|
const foreign = new BrowserWindow({ show: false });
|
||||||
|
await foreign.loadURL('data:text/html,<html></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();
|
releaseConfig();
|
||||||
await waitFor(async () => readySignals.close === 1);
|
await waitFor(async () => readySignals.close === 1);
|
||||||
await waitFor(async () => startupScanCalls === 1);
|
await waitFor(async () => startupScanCalls === 1);
|
||||||
@@ -4001,18 +4258,26 @@ async function waitFor(read, timeoutMs = 20000) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
window.webContents.reload();
|
||||||
|
await waitFor(async () => readySignals.folder === 2 && readySignals.close === 2);
|
||||||
fs.writeFileSync(outputPath, JSON.stringify({
|
fs.writeFileSync(outputPath, JSON.stringify({
|
||||||
hidden: window.isVisible() === false,
|
hidden: window.isVisible() === false,
|
||||||
beforeReady,
|
beforeReady,
|
||||||
readySignals,
|
readySignals,
|
||||||
startupScanCalls,
|
startupScanCalls,
|
||||||
startupScanSettledCalls,
|
startupScanSettledCalls,
|
||||||
|
getConfigCalls,
|
||||||
|
rendererGenerations,
|
||||||
|
afterForeignSignal,
|
||||||
|
beforeStaleSignal,
|
||||||
|
afterStaleSignal,
|
||||||
rendererState
|
rendererState
|
||||||
}), 'utf8');
|
}), 'utf8');
|
||||||
|
foreign.destroy();
|
||||||
window.destroy();
|
window.destroy();
|
||||||
app.exit(0);
|
app.exit(0);
|
||||||
})().catch(error => {
|
})().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);
|
app.exit(1);
|
||||||
});
|
});
|
||||||
`;
|
`;
|
||||||
@@ -4043,6 +4308,11 @@ async function waitFor(read, timeoutMs = 20000) {
|
|||||||
readySignals: outcome.readySignals,
|
readySignals: outcome.readySignals,
|
||||||
startupScanCalls: outcome.startupScanCalls,
|
startupScanCalls: outcome.startupScanCalls,
|
||||||
startupScanSettledCalls: outcome.startupScanSettledCalls,
|
startupScanSettledCalls: outcome.startupScanSettledCalls,
|
||||||
|
getConfigCalls: outcome.getConfigCalls,
|
||||||
|
rendererGenerations: outcome.rendererGenerations,
|
||||||
|
afterForeignSignal: outcome.afterForeignSignal,
|
||||||
|
beforeStaleSignal: outcome.beforeStaleSignal,
|
||||||
|
afterStaleSignal: outcome.afterStaleSignal,
|
||||||
lastScanTrigger: outcome.rendererState.monitor.lastScanTrigger,
|
lastScanTrigger: outcome.rendererState.monitor.lastScanTrigger,
|
||||||
candidateNames: outcome.rendererState.candidateJobs.map(job => job.fileName)
|
candidateNames: outcome.rendererState.candidateJobs.map(job => job.fileName)
|
||||||
}, {
|
}, {
|
||||||
@@ -4051,9 +4321,14 @@ async function waitFor(read, timeoutMs = 20000) {
|
|||||||
startupScanSettledCalls: 0,
|
startupScanSettledCalls: 0,
|
||||||
folderReadySignals: 0
|
folderReadySignals: 0
|
||||||
},
|
},
|
||||||
readySignals: { folder: 1, close: 1 },
|
readySignals: { folder: 2, close: 2 },
|
||||||
startupScanCalls: 1,
|
startupScanCalls: 1,
|
||||||
startupScanSettledCalls: 1,
|
startupScanSettledCalls: 1,
|
||||||
|
getConfigCalls: 3,
|
||||||
|
rendererGenerations: [1, 2, 3],
|
||||||
|
afterForeignSignal: 0,
|
||||||
|
beforeStaleSignal: 0,
|
||||||
|
afterStaleSignal: 0,
|
||||||
lastScanTrigger: 'startup',
|
lastScanTrigger: 'startup',
|
||||||
candidateNames: ['startup-ready.mkv']
|
candidateNames: ['startup-ready.mkv']
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user