Skip to content
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

Merged
merged 44 commits into from Mar 12, 2020
Merged

feat: MessagePorts in the main process #22404

merged 44 commits into from Mar 12, 2020

Conversation

nornagon
Copy link
Member

@nornagon nornagon commented Feb 27, 2020

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 (when nativeWindowOpen or sandbox is enabled). This change allows passing MessagePorts 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 to ipcRenderer.send which forwards all arguments after the channel name. Secondly, the 3rd argument is an array of "transferable objects", i.e. MessagePorts. To send a MessagePort to the main process, you must transfer it by passing it in the 3rd argument of postMessage, like so:

const {port1, port2} = new MessageChannel
ipcRenderer.postMessage("my-message", {some: "message"}, [port1])

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 as SharedArrayBuffer 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', ...) or port.onmessage = ..., we adopt the Node.js convention and offer the EventEmitter-style port.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

Release Notes

Notes: Added support for MessagePort in the main process.

@nornagon nornagon requested a review from a team as a code owner February 27, 2020 00:14
@electron-cation electron-cation bot added the new-pr 🌱 PR opened in the last 24 hours label Feb 27, 2020
@nornagon
Copy link
Member Author

nornagon commented Feb 27, 2020

TODO:

  • There's currently no way to create a MessageChannel from the browser process; only to receive them from the renderer. There should be such a way.
  • Error handling is very haphazard currently.
  • BrowserSideMessagePort.postMessage doesn't yet extract transferables that are passed to it, so it's not possible to pass a message port across another message port from the browser.
  • Tests for failure/edge cases:
    • Neutered ports
    • Sending messages to closed ports
    • Attempting to transfer things other than MessagePorts, both on the browser and renderer side
    • Attempting to transfer duplicate or neutered ports
  • Check GC behavior of MessagePortMain (https://html.spec.whatwg.org/multipage/web-messaging.html#ports-and-garbage-collection). i.e. the object shouldn't get GC'd if the channel is open.

In a followup:

@nornagon
Copy link
Member Author

Some other things to think about:

  • Once we support Node.js worker_threads in the main process, can we enable passing MessagePorts to workers in the main process?
  • MessagePorts in web workers / service workers in the renderer process ought to work fine, but it's probably worth writing a couple of tests to make sure.

@electron-cation electron-cation bot removed the new-pr 🌱 PR opened in the last 24 hours label Feb 28, 2020
@nornagon
Copy link
Member Author

@nornagon Do you know of the performance implications of this versus using ipcRenderer.sendTo between renderer processes?

I have not performed any benchmark, but sendTo will pass the message via the main process, whereas MessagePort allows a direct connection between two renderer processes. Additionally, sendTo will result in the message being deserialized and reserialized in the main process, while MessagePort requires only a single serialize/deserialize pair.

@nornagon
Copy link
Member Author

Linux failure is unrelated; merging.

@nornagon nornagon merged commit b4d07f7 into master Mar 12, 2020
@release-clerk
Copy link

release-clerk bot commented Mar 12, 2020

Release Notes Persisted

Added support for MessagePort in the main process.

@Mike-Dax
Copy link
Contributor

@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?

@nornagon
Copy link
Member Author

No, this feature will land in v10.

@Mike-Dax
Copy link
Contributor

Hi @nornagon

I build Electron 10.0.0-nightly.20200316 and did some benchmarks.

I'm actually finding ipcRenderer.sendTo to not only be faster than using this MessageChannel API, but also have a tighter standard deviation.

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:

{
  keyA: index,
  keyB: index * 1000
}

Results

sendTo x 1.51 ops/sec ±0.46% (12 runs sampled)
MessageChannel x 1.19 ops/sec ±2.43% (10 runs sampled)

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.

@nornagon
Copy link
Member Author

nornagon commented Mar 20, 2020

@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.

  1. Blink's postMessage implementation yields the thread after 200 messages have been received or 50ms has passed. I tried disabling this logic in my local build and the two benchmarks became approximately equal. Blink has a TODO in it saying the logic should be removed once this lands, which it has. I'll look into whether it's reasonable to remove that logic upstream.
  2. This benchmark measures throughput, rather than end-to-end latency, so the performance difference between bouncing to the main process vs. going direct isn't likely to be dominant, because Mojo pipelines all its messages. So the indirection to the main process only adds a constant overhead per cycle.

I tried modifying the benchmark to send 10 1MB ArrayBuffers instead of 10000 small objects, and found MessageChannel to be about 10% faster:

MessageChannel x 29.36 ops/sec ±1.57% (68 runs sampled)
sendTo x 26.41 ops/sec ±0.82% (62 runs sampled)

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):

MessageChannel x 5.27 ops/sec ±3.35% (27 runs sampled)
sendTo x 1.95 ops/sec ±1.09% (14 runs sampled)

@nornagon
Copy link
Member Author

@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 MessageChannel and sendTo for small objects. Performance for large arrays should be much better over MessageChannel. And end-to-end latency ought to still be better over MessageChannel; I'll try to find some time to set up a benchmark for that.

Also, as a bonus, MessageChannel in Blink (and thus Chrome, Edge and Opera) will get more performant too :)

@Mike-Dax
Copy link
Contributor

@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.

@willium
Copy link

willium commented Jun 11, 2020

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 as SharedArrayBuffer 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: [...]}.

When other types of transferable objects are supported would you expect support for OffscreenCanvas?

@nornagon
Copy link
Member Author

@willium no. That would require support for Canvas in the main process, which is not a Blink context.

@trop
Copy link
Contributor

trop bot commented Jun 26, 2020

@miniak has manually backported this PR to "9-x-y", please check out #24323

@willium
Copy link

willium commented Dec 2, 2020

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:

  1. using new MessageChannelMain() in the main process and and sending the ports to two renderer processes (described here and in @nornagon's original proposal)
  2. using new MessageChannel() in one renderer process and send one of the ports to main and then forwarding it to another renderer? (described here in this PR)

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?
  }
})

@willium
Copy link

willium commented Dec 2, 2020

Also, if there is a better place to ask these questions, my apologies. Let me know, happy to engage where you are!

@nornagon
Copy link
Member Author

nornagon commented Dec 2, 2020

@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 onmessage handler is registered). See https://developer.mozilla.org/en-US/docs/Web/API/MessagePort/start.

@willium
Copy link

willium commented Dec 15, 2020

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 isTrusted but you can manually pull off those properties and send them to the renderer.

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()
			})
		})
	}
})

output within callback:
image

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?

@willium
Copy link

willium commented Dec 15, 2020

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

@nornagon
Copy link
Member Author

@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.

@willium
Copy link

willium commented Dec 15, 2020

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:

  1. Transferables seems to be copied as they go over the ContextBridge, is there anyway to send Transferables using MessagePorts without incurring a copy step as they go over the ContextBridge?
  2. What is the best way to interface with the MessagePort on the renderer thread with context isolation? Is there some way to attach the MessagePort to the window with ContextBridge, or must it done within a closure?

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)
   },
})

@willium
Copy link

willium commented Dec 15, 2020

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 ipcRenderer.

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!

@nornagon
Copy link
Member Author

Let's continue discussion at #27024.

@bpasero
Copy link
Contributor

bpasero commented Dec 21, 2020

@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 MessageChannelMain is not immediately obvious to me. What is the use-case over just ipcRenderer and ipcMain methods? Is there any benefit?

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 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

7 participants