Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 135 additions & 6 deletions src/main/actions/setupIPCForwarding.js
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/index.js
Original file line number Diff line number Diff line change
@@ -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 || {};
Expand Down Expand Up @@ -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");
45 changes: 18 additions & 27 deletions src/renderer/lib/RPCServiceOverIPC.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
}

Expand All @@ -29,39 +28,31 @@ export class RPCServiceOverIPC {
method: (..._args: any[]) => Promise<any>
) {
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,
});
Expand Down