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:
+74
-5
@@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user