diff --git a/src/main/actions/setupIPCForwarding.js b/src/main/actions/setupIPCForwarding.js index 0b52c90..2c746a1 100644 --- a/src/main/actions/setupIPCForwarding.js +++ b/src/main/actions/setupIPCForwarding.js @@ -1,20 +1,149 @@ import { ipcMain } from "electron"; +// --- Background readiness protocol --- +// Buffer IPC calls until the background process signals it's ready. +let isBackgroundReady = false; +let isBackgroundFailed = false; +const pendingCalls = []; +const BACKGROUND_READY_TIMEOUT_MS = 30_000; +const PER_CALL_TIMEOUT_MS = 60_000; + +// Module-scoped refs so they can be cleaned up on background restart +let readyTimeoutId = null; +let onBackgroundReady = null; + +const IPC_FORWARD_CHANNEL = "forward-event-from-webapp-to-background-and-await-reply"; + export const setupIPCForwardingToBackground = (backgroundWindow) => { + // Clean up previous invocation's timer and listener (background restart scenario) + if (readyTimeoutId !== null) { + clearTimeout(readyTimeoutId); + readyTimeoutId = null; + } + if (onBackgroundReady !== null) { + ipcMain.removeListener("background-process-ready", onBackgroundReady); + onBackgroundReady = null; + } + ipcMain.removeHandler(IPC_FORWARD_CHANNEL); + + // Reset state + isBackgroundReady = false; + isBackgroundFailed = false; + pendingCalls.length = 0; + + // Safety net: if background never signals ready, reject all buffered calls + // and mark as failed so new calls are rejected immediately + readyTimeoutId = setTimeout(() => { + if (!isBackgroundReady) { + isBackgroundFailed = true; + while (pendingCalls.length > 0) { + const { resolve } = pendingCalls.shift(); + resolve({ + success: false, + data: "Background process failed to initialize in time", + }); + } + } + }, BACKGROUND_READY_TIMEOUT_MS); + + onBackgroundReady = () => { + clearTimeout(readyTimeoutId); + readyTimeoutId = null; + isBackgroundReady = true; + isBackgroundFailed = false; + + // Flush all buffered calls in order + while (pendingCalls.length > 0) { + const { eventName, actualPayload, replyChannel, resolve } = + pendingCalls.shift(); + try { + forwardToBackground( + backgroundWindow, + eventName, + actualPayload, + replyChannel, + resolve + ); + } catch (err) { + resolve({ success: false, data: `Flush error: ${err.message}` }); + } + } + }; + + ipcMain.once("background-process-ready", onBackgroundReady); + ipcMain.handle( - "forward-event-from-webapp-to-background-and-await-reply", + IPC_FORWARD_CHANNEL, async (event, incomingData) => { + const { actualPayload, eventName } = incomingData; + + // Unique reply channel per call to prevent concurrent calls to the same + // method from stealing each other's responses via shared ipcMain.once listeners + const callId = `${Date.now()}-${Math.random().toString(36).substring(2)}`; + const replyChannel = `reply-${eventName}-${callId}`; + return new Promise((resolve) => { - const { actualPayload, eventName } = incomingData; - ipcMain.once(`reply-${eventName}`, (responseEvent, responsePayload) => { - resolve(responsePayload); - }); - backgroundWindow.webContents.send(eventName, actualPayload); + // If background failed to init, reject immediately instead of buffering forever + if (isBackgroundFailed) { + resolve({ + success: false, + data: "Background process is not available", + }); + return; + } + + if (!isBackgroundReady) { + pendingCalls.push({ + eventName, + actualPayload, + replyChannel, + resolve, + }); + return; + } + + forwardToBackground( + backgroundWindow, + eventName, + actualPayload, + replyChannel, + resolve + ); }); } ); }; +function forwardToBackground( + backgroundWindow, + eventName, + actualPayload, + replyChannel, + resolve +) { + // Safety: clean up listener if background never replies (crash, unhandled error, etc.) + const callTimeoutId = setTimeout(() => { + ipcMain.removeAllListeners(replyChannel); + resolve({ success: false, data: `IPC call timed out: ${eventName}` }); + }, PER_CALL_TIMEOUT_MS); + + ipcMain.once(replyChannel, (responseEvent, responsePayload) => { + clearTimeout(callTimeoutId); + resolve(responsePayload); + }); + + try { + backgroundWindow.webContents.send(eventName, { + payload: actualPayload, + replyChannel, + }); + } catch (err) { + clearTimeout(callTimeoutId); + ipcMain.removeAllListeners(replyChannel); + resolve({ success: false, data: `Send failed: ${err.message}` }); + } +} + export const setupIPCForwardingToWebApp = (webAppWindow) => { ipcMain.handle( "forward-event-from-background-to-webapp-and-await-reply", diff --git a/src/renderer/index.js b/src/renderer/index.js index e443b6d..91bf1cd 100644 --- a/src/renderer/index.js +++ b/src/renderer/index.js @@ -1,6 +1,7 @@ // Initialize Sentry for background renderer (must be first) import "../utils/sentryInit"; import logger from "../utils/logger"; +import { ipcRenderer } from "electron"; const initGlobalNamespace = () => { global.rq = global.rq || {}; @@ -54,3 +55,6 @@ clearStoredLogs(); // eslint-disable-next-line no-unused-vars, no-new new FsManagerBuilderRPCService(); + +// Signal to main process that background is fully initialized and ready to receive IPC calls +ipcRenderer.send("background-process-ready"); diff --git a/src/renderer/lib/RPCServiceOverIPC.ts b/src/renderer/lib/RPCServiceOverIPC.ts index 985c163..3842094 100644 --- a/src/renderer/lib/RPCServiceOverIPC.ts +++ b/src/renderer/lib/RPCServiceOverIPC.ts @@ -20,7 +20,6 @@ export class RPCServiceOverIPC { } generateChannelNameForMethod(method: Function) { - console.log("DBG-1: method name", method.name); return `${this.RPC_CHANNEL_PREFIX}${method.name}`; } @@ -29,39 +28,31 @@ export class RPCServiceOverIPC { method: (..._args: any[]) => Promise ) { const channelName = `${this.RPC_CHANNEL_PREFIX}${exposedMethodName}`; - // console.log("DBG-1: exposing channel", channelName, Date.now()); - ipcRenderer.on(channelName, async (_event, args) => { - // console.log( - // "DBG-1: received event on channel", - // channelName, - // _event, - // args, - // Date.now() - // ); + ipcRenderer.on(channelName, async (_event, incomingData) => { + // Detect new envelope format { payload, replyChannel } vs old direct payload format + const hasNewFormat = + incomingData != null && + typeof incomingData === "object" && + !Array.isArray(incomingData) && + "replyChannel" in incomingData; + + const actualArgs = hasNewFormat ? incomingData.payload : incomingData; + const actualReplyChannel = hasNewFormat + ? incomingData.replyChannel + : `reply-${channelName}`; + try { - const result = await method(...args); + const result = await method( + ...(Array.isArray(actualArgs) ? actualArgs : [actualArgs]) + ); - // console.log( - // "DBG-2: result in method", - // result, - // channelName, - // _event, - // args, - // exposedMethodName, - // Date.now() - // ); - ipcRenderer.send(`reply-${channelName}`, { + ipcRenderer.send(actualReplyChannel, { success: true, data: result, }); } catch (error: any) { - // console.log( - // `DBG-2: reply-${channelName} error in method`, - // error, - // Date.now() - // ); captureException(error); - ipcRenderer.send(`reply-${channelName}`, { + ipcRenderer.send(actualReplyChannel, { success: false, data: error.message, });