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
+216 -1
View File
@@ -214,14 +214,141 @@ test('all production external reveal paths wait for an authorized startup surfac
invoke(bindings);
assert.deepEqual(window.events, [], name);
assert.deepEqual(droppedFiles, name === 'drop target files' ? [['queued.mkv']] : [], name);
assert.deepEqual(droppedFiles, [], name);
revealGate.reveal();
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 () => {
const readyWindow = createRevealWindow({ minimized: false });
const readyGate = createStartupRevealGate(readyWindow);
@@ -894,6 +1021,94 @@ test('production event wiring rejects an old document Ready and exposes the fail
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 () => {
const scheduler = createManualScheduler();
let reloadCalls = 0;