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

RFC 98: Remote channels for cross-browsing-group communication #98

Merged
merged 14 commits into from
Nov 22, 2021
174 changes: 98 additions & 76 deletions rfcs/remote_channel.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ the prior context. Typically in implementations each browsing context
group is assigned a unique OS-level process.

This creates some problems for testing; because testharness tests are
running in a single browsing context, and test that involves a context
running in a single browsing context, and tests that involves a context
that is isolated from the context containing the test will be unable
to communicate test results back to the harness using web platform
APIs.
Expand All @@ -35,7 +35,7 @@ browser-specific techniques.

### Prior Art

* Gecko tests able to provide cross-context communication using the
* Gecko tests are able to provide cross-context communication using the
parent (i.e. browser UI) process as an intermediary. The
[SpecialPowers.spawn](https://searchfox.org/mozilla-central/source/testing/specialpowers/content/SpecialPowersChild.jsm#1547-1584)
API available to gecko tests allows running a function in another
Expand Down Expand Up @@ -89,12 +89,13 @@ combines some of these strengths.

#### Addressing

Contexts that want to participate in messaging must have a parameter
Contexts that want to participate in messaging must have a query parameter
called `uuid` in their URL, with a value that's a UUID. This will be
used to identify the channel dedicated to messages sent to that
context. If the context is navigated the new document may reuse the
same UUID if it wants to share the same message queue. Otherwise it's
an error to use the same UUID for multiple contexts.
an error to use the same UUID for multiple contexts. Trying to create
more than one simultaneous reader channel for the same UUID will fail.

#### Backend

Expand Down Expand Up @@ -124,17 +125,25 @@ consumers, but to message a specific browsing context. Browsing
contexts will be able to create a `RecvChannel` with the UUID in their
URL and use that to receive messages from other contexts.

`Channel` acts approximately like an event target; consumers can call
the `addEventListener(type, fn)` method to add a event callback
function. The function is called with an object `{type, data}`, where
`type` is the event type and `data` is type-specific data. All
channels support `connect` and `close` events. Callbacks can be
removed using `removeEventListener(type, fn)`.

A `SendChannel` has a `send(obj)` method. This causes the object to be
encoded, first using the remote object serialization (see below) and
then as a JSON string, before being sent to the queue.

A `RecvChannel` must call its async `connect()` method to start
receiving messages. Messages are first deserialized from JSON and then
undergo remote object serialization. Once connected, the
`RecvChannel` acts like an event target; consumers can call the
`addEventListener(fn)` method to add a callback function when a
message is received. Alternatively the async `next()` function will
return the next message to be received.
undergo remote object serialization. They are provided as event
callbacks with a type of `message`. A message handler function can be
registered using `addEventListener("message", callback)`; the object
passed to the callback function has the message in its `data`
property. Alternatively the async `nextMessage()` function returns a
promise which resolves to next message received.

The implementation of channels is based on websockets, with one
websocket per `Channel` object (i.e. a different websocket is used for
Expand Down Expand Up @@ -224,77 +233,76 @@ as the result of deserialization.
The low-level API provides required primitives, but it's difficult to
use directly. To achieve the aim of making tests no harder to write
than SpecialPowers-based Gecko tests, there is also a higher-level API
initially providing two main capabilities: the ability to
`postMessage` a remote context, and the ability to `executeScript` so
that the script runs in a remote context.
initially providing two main capabilities: `postMessage` which sends a
message to a remote global, and `call` which executes a provided
function in the remote global with given arguments.


This API is provided by a `RemoteWindow` object. The `RemoteWindow`
object doesn't handle creating the browsing context, but given the
UUID for the remote window, creates a `SendChannel` which is able to
send messages to the remote. Alternatively the `RemoteWindow` may be
created first and its `uuid` property used when constructing the URL.
This API is provided by a `RemoteGlobal` object. The `RemoteGlobal`
object doesn't handle creating the browsing context (or other global
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good clarification!

object), but given the UUID for the remote, creates a `SendChannel`
which is able to send messages to the remote. Alternatively the
`RemoteGlobal` may be created first and its `uuid` property used when
constructing the URL.

Inside the remote browsing context itself, the test author has to call
`window_channel()` in order to set up a `RecvChannel` with UUID given
`global_channel()` in order to set up a `RecvChannel` with UUID given
by the `uuid` parameter in `location.href`. By default this is not
connected until the `async connect()` method is called. This allows
connected until the async `connect()` method is called. This allows
message handlers to be attached before processing any messages. For
convenience `await start_window_channel()` returns an already
convenience `await start_global_channel()` returns an already
connected `RecvChannel`.

The `RecvChannel` object offers an `addMessageHandler(callback)` API
to receive messages sent with the `postMessage` API on `RemoteWindow`,
and `async nextMessage()` to wait for the next message.
to receive messages sent with the `postMessage` API on `RemoteGlobal`,
and async `nextMessage()` to wait for the next message.

##### Script Execution

`RemoteWindow.executeScript(fn, ...args)` serializes the function
`fn`, using `Function.toString()`. Each argument is
`RemoteGlobal.call(fn, ...args)` serializes the function
`fn`, using `Function.prototype.toString()`. Each argument is
serialized using the remote value serialization algorithm. Along with
the function string and the arguments, a `SendChannel` is sent for the
command response (only one such channel is created per `RemoteWindow`
command response (only one such channel is created per `RemoteGlobal`
for efficiency reasons), and a command id is sent to disambiguate
responses from different commands.

On the remote side the function is deserialized and executed. If
execution results in a `Promise` value, the result of that promise is
foolip marked this conversation as resolved.
Show resolved Hide resolved
awaited. The final return value after the promise is resolved is sent
back and forms the async return value of the `executeScript` call. If
back and forms the async return value of the `call` call. If
the script throws, the thrown value is provided as the result, and
re-thrown in the originating context. In addition an
`exceptionDetails` field on the response provides the line/column
foolip marked this conversation as resolved.
Show resolved Hide resolved
numbers of the original exception, where available.

TODO: the naming here isn't great. In particular a `RemoteWindow`
could actually be some other kind of global like a worker, and
`start_window_channel()` is a pretty nondescript method name.

#### Navigation and bfcache

For specific use cases around bfcache, it's important to be able to
ensure that no network connections (including websockets) remain open
at the time of navigation, otherwise the page will be excluded from
bfcache. In the current prototype this is handled as follows:

* A `pause` method on `SendChannel`. This causes a server-initiated
disconnect of the corresponding `RecvChannel` websocket. This is
pretty confusing! The idea is to allow a page to send a command that
will initiate a navigation, then without knowing when the navigation
is done, send further commands that will be processed when the
`RecvChannel` reconnects. If the commands are sent before the
navigation, but not processed, they can be buffered by the remote
and then lost during navigation. An alternative here would be a more
explicit protocol in which the remote has to send an explicit
message to the test page indicating that it's done navigating and
it's safe to send more commands. But the way the messaging works,
it's hard for a random page that's loaded to initiate a connection
to the top-level test context For example, consider a test page T
with a channel pair allowing communication with remote window A. If
A navigates to B, there's no simple mechanism for B to create a
channel to T. One could get around this by e.g. putting the uuid of
the existing `SendChannel` from A to T into the URL of B and
constructing it from there, but that's quite fiddly and doesn't work
in cases where the URL is immutable e.g. history navigation.
* A `disconnectReader` method on `SendChannel`. This causes a
server-initiated disconnect of the corresponding `RecvChannel`
websocket. This is pretty confusing! The idea is to allow a page to
send a command that will initiate a navigation, then without knowing
when the navigation is done, send further commands that will be
processed when the `RecvChannel` reconnects. If the commands are
sent before the navigation, but not processed, they can be buffered
by the remote and then lost during navigation. An alternative here
would be a more explicit protocol in which the remote has to send an
explicit message to the test page indicating that it's done
navigating and it's safe to send more commands. But the way the
messaging works, it's hard for a random page that's loaded to
initiate a connection to the top-level test context For example,
foolip marked this conversation as resolved.
Show resolved Hide resolved
consider a test page T with a channel pair allowing communication
with remote window A. If A navigates to B, there's no simple
mechanism for B to create a channel to T. One could get around this
by e.g. putting the uuid of the existing `SendChannel` from A to T
into the URL of B and constructing it from there, but that's quite
fiddly and doesn't work in cases where the URL is immutable
e.g. history navigation.

* A `closeAllChannelSockets()` method. This just closes all the
open websockets associated with channels in the context in which
Expand All @@ -320,6 +328,11 @@ is only created when actually sending a message.
*/
function channel(): [RecvChannel, SendChannel] {}

interface ChannelEvent {
type: string,
data: Object | undefined
}

/**
* Channel used to send messages
*/
Expand All @@ -342,6 +355,16 @@ class SendChannel() {
*/
async close() {}

/**
* Add a event callback funtion. Supported message types are "connect", and "close".
*/
addEventListener(type: string, fn: (event: ChannelEvent) => void) {}

/**
* Remove an event callback function
*/
removeEventListener(type: string fn: (event: ChannelEvent) => void) {}

/**
* Send a message `msg`. The message object must be JSON-serializable.
*/
Expand All @@ -350,7 +373,7 @@ class SendChannel() {
/**
* Disconnect the RecvChannel, if any, on the server side
*/
async pause() {}
async disconnectReader() {}
}

/**
Expand All @@ -370,41 +393,42 @@ class RecvChannel() {
async close() {}

/**
* Add a message handler function
* Add a event callback funtion. Supported message types are "connect", "close", and "message".
*/
addEventListener(fn: (msg: Object) => void) {}
addEventListener(type: string, fn: (event: ChannelEvent) => void) {}

/**
* Remove a message handler function
* Remove an event callback function
*/
removeEventListener(fn: (msg: Object) => void) {}
removeEventListener(type: string fn: (event: ChannelEvent) => void) {}

/**
* Wait for the next message and return it (after passing it to
* existing handlers)
*/
async next(): Promise<Object> {}
async nextMessage(): Promise<Object> {}
}

/**
* Create an unconnected channel defined by a `uuid` in
* `location.href` for listening for RemoteWindow messages.
* `location.href` for listening for RemoteGlobal messages.
*/
async window_channel(): RemoteWindowCommandRecvChannel {}
async global_channel(): RemoteGlobalCommandRecvChannel {}


/**
* Start listening for RemoteWindow messages on a channel defined by
* Start listening for RemoteGlobal messages on a channel defined by
* a `uuid` in `location.href`
*/
async start_window_channel(): Promise<RemoteWindowCommandRecvChannel> {}
async start_global_channel(): Promise<RemoteGlobalCommandRecvChannel> {}


/**
* Handler for RemoteWindow commands
* Handler for RemoteGlobal commands. This can't be constructed directly
* but must be onbtained from `global_channel()` or `start_global_channel()`.
*/

class RemoteWindowCommandRecvChannel {
class RemoteGlobalCommandRecvChannel {
/**
* Connect to the channel and start handling messages.
*/
Expand Down Expand Up @@ -432,9 +456,9 @@ class RemoteWindowCommandRecvChannel {
async nextMessage(): Promise<Object> {}
}

class RemoteWindow {
class RemoteGlobal {
/**
* Create a RemoteWindow. The dest parameter is either a
* Create a RemoteGlobal. The dest parameter is either a
`SendChannel` object or the UUID for the channel. If ommitted a new
UUID is generated.
*/
Expand All @@ -455,7 +479,7 @@ class RemoteWindow {
* Disconnect the RecvChannel running in the remote context, if any,
* on the server side
*/
async pause() {}
async disconnectReader() {}

/**
* Post the object msg to the remote, using JSON serialization
Expand All @@ -469,15 +493,15 @@ class RemoteWindow {
*
* Arguments and return values are serialized as RemoteObjects.
*/
async executeScript(fn: (args: ...any) => any, ..args: any): Promise<any> {}
async call(fn: (args: ...any) => any, ...args: any): Promise<any> {}
}

/**
* Representation of a non-primitive type passed through a channel
*/
class RemoteObject {
type: string;
objectId: string | undefined;
objectId: string;

/**
* Create a RemoteObject containing a handle to reference obj
Expand All @@ -502,8 +526,6 @@ class RemoteObject {
* Close all websockets in the current global that are being used for channels.
*/
function closeAllChannelSockets() {}

///
```

### Resource Management
Expand Down Expand Up @@ -535,19 +557,19 @@ test.html

```html
<!doctype html>
<title>executeScript example</title>
<title>call example</title>
<script src="/resources/testharness.js">
<script src="/resources/testharnessreport.js">
<script src="/resources/channel.js">

<script>
promise_test(async t => {
let remote = new RemoteWindow();
let remote = new RemoteGlobal();
window.open(`child.html?uuid=${remote.uuid}`, "_blank", "noopener");
let result = await remote.executeScript(id => {
let result = await remote.call(id => {
return document.getElementById(id).textContent;
}, "test");
assert_equals("result", PASS);
assert_equals("result", "PASS");
});
</script>
```
Expand Down Expand Up @@ -576,17 +598,17 @@ in the context of a further RFC. This section is only to sketch some
possibilities for further development of the API.

The primitives here could be integrated more completely with
testharness.js. For example we could use a `RemoteWindow` as a source
testharness.js. For example we could use a `RemoteGlobal` as a source
of tests in `fetch_tests_from_window`. Alternatively, or in addition
to that, we could integrate asserts with script execution, so that a
remote context could include a minimal testharness.js and enable
something like:

```js
promise_test(t => {
let r = new RemoteWindow();
let r = new RemoteGlobal();
window.open(`file.html?uuid=${r.uuid}`, "_blank", "noopener");
await r.executeScript(() => {
await r.call(() => {
assert_equals(window.opener, null)
});
});
Expand All @@ -608,7 +630,7 @@ testdriver integration is possible. For example we could add
`set_permission` command in the remote context (and similarly for the
remainder of the testdriver API). This would desugar to
`testdriver.set_permission(params, uuid)` and testdriver would be
update to identify the target window from the `uuid` parameter in its
updated to identify the target window from the `uuid` parameter in its
`location.href`.

## Risks
Expand Down