New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: MessagePorts in the main process #22404
Conversation
TODO:
In a followup:
|
Some other things to think about:
|
f618c54
to
d87e0cd
Compare
6a9a57a
to
d3ec3b7
Compare
d3ec3b7
to
e3e8995
Compare
I have not performed any benchmark, but |
Linux failure is unrelated; merging. |
Release Notes Persisted
|
@nornagon Thank you for your insight. I'll do some benchmarks on the various options. Is it likely for this feature to be back-ported for v8.2.0 or v9.0.0? |
No, this feature will land in v10. |
Hi @nornagon I build Electron 10.0.0-nightly.20200316 and did some benchmarks. I'm actually finding To benchmark I'm setting up two BrowserWindows, designating one as a 'server' and one as a 'client'. The client merely echos data back to the server. I'm then sending 10k messages in a row and waiting for the last 'pong' back. I'm sending small objects that look like this:
Results
The exact code used is available here: https://github.com/Mike-Dax/pipe-benchmarks I'm finding similar results with large binary messages, with sendTo being about 7% faster. |
@Mike-Dax thanks for those benchmarks, that's really interesting! I expanded on the benchmarks and went digging in Blink to try to figure out what was going on. Here's what I found.
I tried modifying the benchmark to send 10 1MB ArrayBuffers instead of 10000 small objects, and found
And that difference is exacerbated in my local build with the Blink yielding logic removed (NB. this is slower in absolute terms because it's a debug build):
|
@Mike-Dax update, I was able to get the changes merged upstream into Blink: https://chromium-review.googlesource.com/c/chromium/src/+/2112954, so we'll pick them up in the next Chromium roll. After that, throughput should be similar between Also, as a bonus, |
@nornagon Oh incredible. Thank you so much! I'll likely have some time in the coming week or two to do more benchmarks - I'll make one for latency as well so you don't have to. Benchmark.js isn't a good fit for measuring latency so I'll need to make something bespoke. Yeah it's interesting, a named pipe implementation (that transfers all serialisation / deserialisation responsibility to the dev) is about 3x faster on the throughput side for me, and lower latency as well, however I'm getting weird cases where it just won't connect on Windows, specifically on the first 'install' of Electron. |
When other types of transferable objects are supported would you expect support for OffscreenCanvas? |
@willium no. That would require support for Canvas in the main process, which is not a Blink context. |
The new message channel APIs are awesome, but the docs are a little unclear ~ I'm curious if there is there any implementation difference or tradeoff between:
Also, as a follow-up, in your example @nornagon, would there not be a race condition with the postMessage call before the other port is even sent to the main process (let alone to the other renderer which will listen on the MessageChannel)? If so, is there a solid pattern to avoid this besides facilitating a handshake? // w1
const {port1, port2} = new MessageChannel
const buf = new SharedArrayBuffer(16)
port2.postMessage(buf) // posting message before sending port, will port2 really
ipcRenderer.postMessage('port', null, [port2])
// w2
ipcRenderer.on('port', (e) => {
const [port] = e.ports
port.onmessage = (e) => {
const buf = e.data
// will buf actually make it here? or will this have missed the message?
}
}) |
Also, if there is a better place to ask these questions, my apologies. Let me know, happy to engage where you are! |
@willium there's no difference in performance based on how the two ends of the channel are created. Regardless of how the ports arrive at their destination, performance characteristics will thereafter be the same. There's no race condition in that example because messages are queued until the port is started (which happens automatically when an |
I'm having a hell of a time getting MessageChannels to work while also using contextIsolation: true,
nodeIntegration: false, I was able to attach listeners on the port's events within the preload. But oddly serializing the message and forward it removes all properties except contextBridge.exposeInMainWorld("intercom", {
on: (channel: string, callback: (...args: any[]) => any) => {
// Filtering the event param from ipcRenderer
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
ipcRenderer.on(channel, (e: IpcRendererEvent) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
// callback(e)
e.ports.forEach((port, i) => {
console.log("port", port)
port.onmessage = (m: MessageEvent) => {
callback("message", i, m.data, m.bubbles, m.timeStamp, m)
}
port.onmessageerror = (m: MessageEvent) => {
callback("messageerror", i, m.data, m)
}
port.postMessage("BAR")
// port.start()
})
})
}
}) Additionally, I haven't found a way to allow the renderer to attach a callback to the port's message event(s) and handle the arguments itself in the browser context (rather than using the intermediary preload node context). After inspecting the messageEvent type, it seems everything is available in the DOM context, so I'm not sure why that's not working—am I missing something? |
Are there any docs or examples on combining message channels with contextBridge? As methods on the port aren’t available outside of the closure of the ipc event, it's not clear how they can exposed on the contextBridge |
@willium i'm having a hard time understanding your example as it's incomplete. Would you be able to provide a gist (preferably using Electron Fiddle) that shows the issue you're having? Also, you might have better luck with this question at one of the links at the project's Community page -- in particular, be sure to give the Electron Discord server a try. |
Hi Jeremy, thanks for your reply! I'll work on a minimal reproduction now, but I don't believe Fiddle allows for multiple renderer processes just yet, so that may not be as trivial to emulate. Perhaps I can better isolate the questions for you:
The docs suggest this pattern: contextBridge.exposeInMainWorld(
'electron',
{
doThing: () => ipcRenderer.send('do-a-thing')
}
) but it's not straightforward to use this pattern with a MessagePort, as its only accessible within the callback of ipcRender.on("port", ({ port }) => { ... }) e.g. type PortName = string
type Channel = string
type Payload = object
type Reply = (payload: Payload) => void
type Callback = (payload: Payload, reply: Reply, remove: () => void) => void
type Post = (payload: Payload, transferables: Transferable[]) => void
type Connection = (
payload: Payload,
post: Post,
reply: Reply,
disconnect: () => void,
) => void
contextBridge.exposeInMainWorld("electron", {
connect: (port: PortName, connection: Connection) => {
if (!validPorts.includes(port)) {
throw new Error(`Invalid Channel "${port}"`)
}
const wrappedCallback = ({ ports }: IpcRendererEvent, _: Payload) => {
const [messagePort] = ports
if (!messagePort) throw new Error("Connection requires (1) port")
const post: Post = (outgoing: Payload, transferables: Transferable[]) => {
messagePort.postMessage(outgoing, transferables)
}
const reply: Reply = (outgoing: Payload) => {
// same channel (channel should also be in outgoing payload)
messagePort.postMessage(outgoing)
}
const disconnect = () => {
ipcRenderer.removeListener(port, wrappedCallback)
}
messagePort.onmessage = ({ data }: MessageEvent<Payload>) => {
connection(data, post, reply, disconnect)
}
messagePort.onmessageerror = ({ data }: MessageEvent<Payload>) => {
connection(data, post, reply, disconnect)
}
messagePort.start()
}
ipcRenderer.on(`${port}:port`, wrappedCallback)
},
}) |
By the way, I appreciate your responsiveness and I really don't mean to overuse issues as a support forum! I've attempted to engage in the community support channels, but both MessageChannels and ContextBridge are fairly new patterns without a ton of documentation (especially not when used together). And, with ContextBridge especially, much of what you find on StackOverflow/etc employs dark patterns of exposing I do believe this might be pointing a to a bug or at least a blocker for allowing MessageChannel use with Context Isolation active. In any case, I'd be happy to help contribute documentation once I figure this out! |
Let's continue discussion at #27024. |
@nornagon this API seems great for a use-case we have in VSCode: direct window-window communication without going through the main process, thanks a lot for adding it 👍 I was able to come up with a minimal sample for how to communicate between 2 windows, maybe this is something that would make the docs clearer: //main
const window1 = new BrowserWindow(...);
const window2 = new BrowserWindow(...);
ipcMain.on('port', e => {
// transfer the port to the second window
window2.webContents.postMessage('port', null, e.ports);
});
//window1
const { port1, port2 } = new MessageChannel();
ipcRenderer.postMessage('port', null, [port2]); // send port to main so that it can be forwarded to window2
port1.onmessage = msg => console.log('[From Renderer 2', msg);
port1.postMessage('Hello From Renderer 1');
//window2
ipcRenderer.on('port', event => {
[port] = event.ports;
port.onmessage = msg => console.log('From Renderer 1', msg);
port.postMessage('Hello From Renderer 2');
}); If this seems like a good sample, I am happy to open a PR. One question: the purpose of One use-case I think could be to prepare the ports on the main side and send them over into the 2 windows? But isn't this something covered by my example already? Thanks for clarification 👍 |
Description of Change
This adds a new IPC primitive to Electron based on MessageChannel, the DOM primitive. MessageChannel already worked fine in the renderer process, and between renderer processes opened through
window.open
(whennativeWindowOpen
orsandbox
is enabled). This change allows passingMessagePort
s from renderer to the main process. From there, the ports can be forwarded on to other renderers, which wouldn't otherwise necessarily be able to communicate with one another.Communication over
MessagePort
between two renderer processes is not proxied through the main process. (Caveat: if messages are sent on the port before the other end of it is fully set up in the target renderer, those messages may be queued in the main process until the channel is ready.)Initial proposal: https://hackmd.io/bwzFRWZzRIyewIBc1Vf7DQ?both
This change adds some new APIs:
ipcRenderer.postMessage(channel, message, [options])
This is similar to the existing
ipcRenderer.send
, with two key differences. First, you can only send a single argument, as opposed toipcRenderer.send
which forwards all arguments after the channel name. Secondly, the 3rd argument is an array of "transferable objects", i.e.MessagePort
s. To send aMessagePort
to the main process, you must transfer it by passing it in the 3rd argument ofpostMessage
, like so:This is intentionally similar to
window.postMessage
.Currently, the only kind of transferable object that may be passed in the transfer list is
MessagePort
. In future, more kinds of transferable objects such asSharedArrayBuffer
may be supported.TODO: currently this function only accepts an array, but we should match
Window.postMessage
and also accept an object of the form{transfer: [...]}
.WebContents.postMessage(channel, message, [options])
This is the same as
ipcRenderer.postMessage
but going in the other direction, from main to renderer.MessagePortMain
As the JS context in the main process in Electron is not a Blink context, we do not have access to Blink's implementation of
MessagePort
. Thus, we provide a polyfill, which communicates over the same underlying Mojo pipe.This has a similar API to MessagePort, but instead of
port.addEventListener('message', ...)
orport.onmessage = ...
, we adopt the Node.js convention and offer the EventEmitter-styleport.on('message', ...)
.This object can be passed as a transferable to
WebContents.postMessage
in order to transfer ownership of the message port to a renderer.Checklist
npm test
passesRelease Notes
Notes: Added support for
MessagePort
in the main process.