Buffer startup drops until renderer readiness

Keep a bounded FIFO of drop payloads while the startup or recovery document is not validated. Flush each payload exactly once only after the generation-checked renderer Ready signal, retain immediate delivery for a ready renderer, and discard stale payloads when the main window is destroyed or replaced.

Wire renderer block, Ready, and window-close transitions through main and cover recovery, safe failure visibility, stale Ready signals, ordered multi-drop delivery, queue bounds, lifecycle cleanup, and duplicate prevention.
This commit is contained in:
Sucukdeluxe
2026-08-13 23:35:04 +02:00
parent 3767a8b81f
commit cfa138198f
3 changed files with 293 additions and 6 deletions
+74 -5
View File
@@ -75,29 +75,95 @@ function createStartupRevealGate(window, { onBlock } = {}) {
return { block, navigate, request, reveal }; return { block, navigate, request, reveal };
} }
function createStartupExternalRevealBindings({ getWindow, getRevealGate, sendDroppedFiles }) { function createStartupExternalRevealBindings({
getWindow,
getRevealGate,
sendDroppedFiles,
maxPendingDropPayloads = 32
}) {
const pendingDropPayloads = [];
const pendingDropLimit = Number.isSafeInteger(maxPendingDropPayloads) && maxPendingDropPayloads > 0
? maxPendingDropPayloads
: 32;
let pendingWindow = null;
let readyWindow = null;
function clearPendingDropPayloads(window) {
if (window && pendingWindow !== window) return false;
pendingDropPayloads.length = 0;
pendingWindow = null;
return true;
}
function clearWindowState(window) {
clearPendingDropPayloads(window);
if (!window || readyWindow === window) readyWindow = null;
}
function getActiveWindow() { function getActiveWindow() {
const window = getWindow(); const window = getWindow();
if (!window || (typeof window.isDestroyed === 'function' && window.isDestroyed())) return null; if (!window || (typeof window.isDestroyed === 'function' && window.isDestroyed())) {
clearWindowState();
return null;
}
if (pendingWindow && pendingWindow !== window) clearPendingDropPayloads();
if (readyWindow && readyWindow !== window) readyWindow = null;
return window; return window;
} }
function requestReveal() { function requestReveal() {
if (!getActiveWindow()) return false; const activeWindow = getActiveWindow();
if (!activeWindow) return false;
const revealGate = getRevealGate(); const revealGate = getRevealGate();
if (!revealGate || typeof revealGate.request !== 'function') return false; if (!revealGate || typeof revealGate.request !== 'function') return false;
revealGate.request(); revealGate.request();
return true; return true;
} }
function queueDropPayload(window, paths) {
if (pendingWindow && pendingWindow !== window) clearPendingDropPayloads();
pendingWindow = window;
if (pendingDropPayloads.length >= pendingDropLimit) pendingDropPayloads.shift();
pendingDropPayloads.push(paths);
}
function handleDropTargetFiles(_event, paths) { function handleDropTargetFiles(_event, paths) {
const window = getActiveWindow(); const window = getActiveWindow();
if (!window) return false; if (!window) return false;
if (!window.isVisible() || window.isMinimized()) requestReveal(); if (!window.isVisible() || window.isMinimized()) requestReveal();
sendDroppedFiles(paths); if (readyWindow === window) sendDroppedFiles(paths);
else queueDropPayload(window, paths);
return true; return true;
} }
function rendererBlocked(window) {
const activeWindow = getActiveWindow();
if (!activeWindow || activeWindow !== window) {
clearWindowState(window);
return false;
}
readyWindow = null;
return true;
}
function rendererReady(window) {
const activeWindow = getActiveWindow();
if (!activeWindow || activeWindow !== window) {
clearWindowState(window);
return false;
}
readyWindow = window;
if (pendingWindow !== window) return true;
const payloads = pendingDropPayloads.splice(0);
pendingWindow = null;
for (const paths of payloads) sendDroppedFiles(paths);
return true;
}
function windowClosed(window) {
clearWindowState(window);
}
return { return {
bindSecondInstance(app) { bindSecondInstance(app) {
app.on('second-instance', requestReveal); app.on('second-instance', requestReveal);
@@ -110,7 +176,10 @@ function createStartupExternalRevealBindings({ getWindow, getRevealGate, sendDro
}, },
bindDropTargetFiles(ipcMain) { bindDropTargetFiles(ipcMain) {
ipcMain.on('drop-target:files', handleDropTargetFiles); ipcMain.on('drop-target:files', handleDropTargetFiles);
} },
rendererBlocked,
rendererReady,
windowClosed
}; };
} }
+3
View File
@@ -1458,6 +1458,7 @@ function createWindow() {
clearCloseFlushTimer(); clearCloseFlushTimer();
const currentStartupRevealGate = createStartupRevealGate(mainWindow, { const currentStartupRevealGate = createStartupRevealGate(mainWindow, {
onBlock: () => { onBlock: () => {
startupExternalRevealBindings.rendererBlocked(startupWindow.window);
closeHandshakeReady = false; closeHandshakeReady = false;
restoreClosePreparation(closePreparationAttempt); restoreClosePreparation(closePreparationAttempt);
} }
@@ -1525,6 +1526,7 @@ function createWindow() {
debugLog(`RENDER PROCESS GONE: reason=${details.reason} exitCode=${details.exitCode}`); debugLog(`RENDER PROCESS GONE: reason=${details.reason} exitCode=${details.exitCode}`);
}, },
onReady: () => { onReady: () => {
startupExternalRevealBindings.rendererReady(startupWindow.window);
closeHandshakeReady = true; closeHandshakeReady = true;
}, },
onInitializationFailed: (details) => { onInitializationFailed: (details) => {
@@ -1536,6 +1538,7 @@ function createWindow() {
const currentStartupRendererHandlers = startupRendererHandlers; const currentStartupRendererHandlers = startupRendererHandlers;
const currentStartupRecoveryCoordinator = startupRecoveryCoordinator; const currentStartupRecoveryCoordinator = startupRecoveryCoordinator;
mainWindow.once('closed', () => { mainWindow.once('closed', () => {
startupExternalRevealBindings.windowClosed(startupWindow.window);
currentStartupRendererHandlers.dispose(); currentStartupRendererHandlers.dispose();
if (startupRevealGate === currentStartupRevealGate) startupRevealGate = null; if (startupRevealGate === currentStartupRevealGate) startupRevealGate = null;
if (startupRendererHandlers === currentStartupRendererHandlers) startupRendererHandlers = null; if (startupRendererHandlers === currentStartupRendererHandlers) startupRendererHandlers = null;
+216 -1
View File
@@ -214,14 +214,141 @@ test('all production external reveal paths wait for an authorized startup surfac
invoke(bindings); invoke(bindings);
assert.deepEqual(window.events, [], name); assert.deepEqual(window.events, [], name);
assert.deepEqual(droppedFiles, name === 'drop target files' ? [['queued.mkv']] : [], name); assert.deepEqual(droppedFiles, [], name);
revealGate.reveal(); revealGate.reveal();
assert.deepEqual(window.events, ['restore', 'show', 'focus'], name); assert.deepEqual(window.events, ['restore', 'show', 'focus'], name);
assert.deepEqual(droppedFiles, [], name);
bindings.rendererReady(window);
assert.deepEqual(droppedFiles, name === 'drop target files' ? [['queued.mkv']] : [], name);
} }
}); });
test('drop payloads wait through recovery and a safe failure reveal until renderer Ready', () => {
const window = createRevealWindow({ minimized: false, visible: true });
const droppedFiles = [];
let bindings;
const revealGate = createStartupRevealGate(window, {
onBlock() {
bindings.rendererBlocked(window);
}
});
bindings = createStartupExternalRevealBindings({
getWindow: () => window,
getRevealGate: () => revealGate,
sendDroppedFiles: paths => droppedFiles.push(paths)
});
const ipcMain = new EventEmitter();
bindings.bindDropTargetFiles(ipcMain);
bindings.rendererReady(window);
ipcMain.emit('drop-target:files', {}, ['ready.mkv']);
revealGate.navigate(() => {});
ipcMain.emit('drop-target:files', {}, ['recovering.mkv']);
revealGate.reveal();
assert.deepEqual(droppedFiles, [['ready.mkv']]);
bindings.rendererReady(window);
assert.deepEqual(droppedFiles, [['ready.mkv'], ['recovering.mkv']]);
});
test('multiple pending drop payloads flush once in order and ready drops stay immediate', () => {
const window = createRevealWindow();
const revealGate = createStartupRevealGate(window);
const droppedFiles = [];
const bindings = createStartupExternalRevealBindings({
getWindow: () => window,
getRevealGate: () => revealGate,
sendDroppedFiles: paths => droppedFiles.push(paths)
});
const ipcMain = new EventEmitter();
bindings.bindDropTargetFiles(ipcMain);
ipcMain.emit('drop-target:files', {}, ['first.mkv']);
ipcMain.emit('drop-target:files', {}, ['second.mkv']);
ipcMain.emit('drop-target:files', {}, ['third.mkv']);
assert.deepEqual(droppedFiles, []);
revealGate.reveal();
bindings.rendererReady(window);
bindings.rendererReady(window);
assert.deepEqual(droppedFiles, [
['first.mkv'],
['second.mkv'],
['third.mkv']
]);
ipcMain.emit('drop-target:files', {}, ['fourth.mkv']);
assert.deepEqual(droppedFiles, [
['first.mkv'],
['second.mkv'],
['third.mkv'],
['fourth.mkv']
]);
});
test('pending drop payloads stay bounded while retaining delivery order', () => {
const window = createRevealWindow();
const revealGate = createStartupRevealGate(window);
const droppedFiles = [];
const bindings = createStartupExternalRevealBindings({
getWindow: () => window,
getRevealGate: () => revealGate,
sendDroppedFiles: paths => droppedFiles.push(paths),
maxPendingDropPayloads: 2
});
const ipcMain = new EventEmitter();
bindings.bindDropTargetFiles(ipcMain);
ipcMain.emit('drop-target:files', {}, ['first.mkv']);
ipcMain.emit('drop-target:files', {}, ['second.mkv']);
ipcMain.emit('drop-target:files', {}, ['third.mkv']);
bindings.rendererReady(window);
assert.deepEqual(droppedFiles, [
['second.mkv'],
['third.mkv']
]);
});
test('pending drop payloads are cleared for destroyed and replaced windows', () => {
const firstWindow = createRevealWindow();
let activeWindow = firstWindow;
const droppedFiles = [];
const bindings = createStartupExternalRevealBindings({
getWindow: () => activeWindow,
getRevealGate: () => ({ request() {} }),
sendDroppedFiles: paths => droppedFiles.push(paths)
});
const ipcMain = new EventEmitter();
bindings.bindDropTargetFiles(ipcMain);
ipcMain.emit('drop-target:files', {}, ['replaced.mkv']);
activeWindow = createRevealWindow();
bindings.rendererReady(firstWindow);
bindings.rendererReady(activeWindow);
assert.deepEqual(droppedFiles, []);
ipcMain.emit('drop-target:files', {}, ['ready.mkv']);
bindings.rendererBlocked(activeWindow);
ipcMain.emit('drop-target:files', {}, ['destroyed.mkv']);
activeWindow.isDestroyed = () => true;
bindings.rendererReady(activeWindow);
activeWindow = createRevealWindow();
bindings.rendererReady(activeWindow);
assert.deepEqual(droppedFiles, [['ready.mkv']]);
});
test('renderer Ready and the safe failure surface can authorize the gated window', async () => { test('renderer Ready and the safe failure surface can authorize the gated window', async () => {
const readyWindow = createRevealWindow({ minimized: false }); const readyWindow = createRevealWindow({ minimized: false });
const readyGate = createStartupRevealGate(readyWindow); const readyGate = createStartupRevealGate(readyWindow);
@@ -894,6 +1021,94 @@ test('production event wiring rejects an old document Ready and exposes the fail
handlers.dispose(); handlers.dispose();
}); });
test('production recovery wiring flushes pending drops only for the current document Ready', async () => {
const ipcMain = new EventEmitter();
const webContents = new EventEmitter();
const initialFrame = createRendererFrame('drop-initial');
const recoveryFrame = createRendererFrame('drop-recovery');
const window = createRevealWindow({ minimized: false, visible: true });
window.webContents = webContents;
const droppedFiles = [];
let bindings;
const revealGate = createStartupRevealGate(window, {
onBlock() {
bindings.rendererBlocked(window);
}
});
bindings = createStartupExternalRevealBindings({
getWindow: () => window,
getRevealGate: () => revealGate,
sendDroppedFiles: paths => droppedFiles.push(paths)
});
bindings.bindDropTargetFiles(ipcMain);
const coordinator = createStartupRecoveryCoordinator({
load() {},
async reload() {
webContents.mainFrame = recoveryFrame;
webContents.emit('did-start-navigation', {
isMainFrame: true,
isSameDocument: false,
url: recoveryFrame.url,
frame: recoveryFrame
});
webContents.emit('did-finish-load');
},
reveal: revealGate.reveal,
close() {}
});
const handlers = createStartupRendererHandlers({
window,
ipcMain,
coordinator,
onDocumentLoadStarted: revealGate.block,
onReady() {
bindings.rendererReady(window);
},
onInitializationFailed() {}
});
webContents.mainFrame = initialFrame;
webContents.emit('did-start-navigation', {
isMainFrame: true,
isSameDocument: false,
url: initialFrame.url,
frame: initialFrame
});
webContents.emit('did-finish-load');
ipcMain.emit('app:close-handshake-ready', {
sender: webContents,
senderFrame: initialFrame
});
ipcMain.emit('drop-target:files', {}, ['ready.mkv']);
webContents.emit('render-process-gone', {}, { reason: 'crashed' });
await new Promise(resolve => setImmediate(resolve));
ipcMain.emit('drop-target:files', {}, ['first-recovery.mkv']);
ipcMain.emit('drop-target:files', {}, ['second-recovery.mkv']);
ipcMain.emit('app:close-handshake-ready', {
sender: webContents,
senderFrame: initialFrame
});
assert.deepEqual(droppedFiles, [['ready.mkv']]);
ipcMain.emit('app:close-handshake-ready', {
sender: webContents,
senderFrame: recoveryFrame
});
ipcMain.emit('app:close-handshake-ready', {
sender: webContents,
senderFrame: recoveryFrame
});
assert.deepEqual(droppedFiles, [
['ready.mkv'],
['first-recovery.mkv'],
['second-recovery.mkv']
]);
handlers.dispose();
});
test('production startup handlers cancel the Ready deadline after a valid Ready signal', async () => { test('production startup handlers cancel the Ready deadline after a valid Ready signal', async () => {
const scheduler = createManualScheduler(); const scheduler = createManualScheduler();
let reloadCalls = 0; let reloadCalls = 0;