Skip to content

Commit 2c51c8a

Browse files
[WPT] Introduce RemoteContext.execute_script() and add basic BFCache tests + helpers
This PR adds `RemoteContext.execute_script()` and its documentation in `/common/dispatcher/`. This is based on with `execute_script()`-related parts of RFCs 88/89/91: - web-platform-tests/rfcs#88 - web-platform-tests/rfcs#89 - web-platform-tests/rfcs#91 plus additional clarifications around navigation, minus `testdriver` integration (so this is implemented using `send()`/`receive()` in `/common/dispatcher/`). This PR also adds back-forward cache WPTs (basic event firing tests), as well as BFCache-specific helpers, based on `RemoteContext.execute_script()`. Design doc: https://docs.google.com/document/d/1p3G-qNYMTHf5LU9hykaXcYtJ0k3wYOwcdVKGeps6EkU/edit?usp=sharing (Note: this CL is tentatively rebased on `main`, to trigger Blink WPT bot, as this CL depends on https://chromium-review.googlesource.com/c/chromium/src/+/3033199. After CL 3033199 lands, this CL should be rebased) Bug: 1107415 Change-Id: I034f9f5376dc3f9f32ca0b936dbd06e458c9160b
1 parent d7554a0 commit 2c51c8a

File tree

8 files changed

+752
-0
lines changed

8 files changed

+752
-0
lines changed

common/dispatcher/README.md

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
# `RemoteContext`: API for script execution in another context
2+
3+
`RemoteContext` in `/common/dispatcher/dispatcher.js` provides an interface to
4+
execute JavaScript in another global object (page or worker, the "executor"),
5+
based on
6+
7+
- [WPT RFC 88: context IDs from uuid searchParams in URL](https://github.com/web-platform-tests/rfcs/pull/88),
8+
- [WPT RFC 89: execute_script](https://github.com/web-platform-tests/rfcs/pull/89) and
9+
- [WPT RFC 91: RemoteContext](https://github.com/web-platform-tests/rfcs/pull/91).
10+
11+
Tests can send arbitrary javascript to executors to evaluate in its global
12+
object, like:
13+
14+
```
15+
// injector.html
16+
const argOnLocalContext = ...;
17+
18+
async function execute() {
19+
window.open('executor.html?uuid=' + uuid);
20+
const ctx = new RemoteContext(uuid);
21+
await ctx.execute_script(
22+
(arg) => functionOnRemoteContext(arg),
23+
[argOnLocalContext]);
24+
};
25+
```
26+
27+
and on executor:
28+
29+
```
30+
// executor.html
31+
function functionOnRemoteContext(arg) { ... }
32+
33+
const uuid = new URLSearchParams(window.location.search).get('uuid');
34+
const executor = new Executor(uuid);
35+
```
36+
37+
For concrete examples, see
38+
[events.html](../../html/browsers/browsing-the-web/back-forward-cache/events.html)
39+
and
40+
[executor.html](../../html/browsers/browsing-the-web/back-forward-cache/resources/executor.html)
41+
in back-forward cache tests.
42+
Note that executor files under `/common/dispatcher/` are NOT for
43+
`RemoteContext.execute_script()`.
44+
45+
This is universal and avoids introducing many specific `XXX-helper.html`
46+
resources.
47+
Moreover, tests are easier to read, because the whole logic of the test can be
48+
defined in a single file.
49+
50+
## `new RemoteContext(uuid)`
51+
52+
- `uuid` is a UUID string that identifies the remote context and should match
53+
with the `uuid` parameter of the URL of the remote context.
54+
- Callers should create the remote context outside this constructor (e.g.
55+
`window.open('executor.html?uuid=' + uuid)`).
56+
57+
## `RemoteContext.execute_script(fn, args)`
58+
59+
- `fn` is a JavaScript function to execute on the remote context, which is
60+
converted to a string using `toString()` and sent to the remote context.
61+
- `args` is null or an array of arguments to pass to the function on the
62+
remote context. Arguments are passed as JSON.
63+
- If the return value of `fn` when executed in the remote context is a promise,
64+
the promise returned by `execute_script` resolves to the resolved value of
65+
that promise. Otherwise the `execute_script` promise resolves to the return
66+
value of `fn`.
67+
68+
Note that `fn` is evaluated on the remote context (`executor.html` in the
69+
example above), while `args` are evaluated on the caller context
70+
(`injector.html`) and then passed to the remote context.
71+
72+
## Return value of injected functions and `execute_script()`
73+
74+
If the return value of the injected function when executed in the remote
75+
context is a promise, the promise returned by `execute_script` resolves to the
76+
resolved value of that promise. Otherwise the `execute_script` promise resolves
77+
to the return value of the function.
78+
79+
When the return value of an injected script is a Promise, it should be resolved
80+
before any navigation starts on the remote context. For example, it shouldn't
81+
be resolved after navigating out and navigating back to the page again.
82+
It's fine to create a Promise to be resolved after navigations, if it's not the
83+
return value of the injected function.
84+
85+
## Calling timing of `execute_script()`
86+
87+
When `RemoteContext.execute_script()` is called when the remote context is not
88+
active (for example before it is created, before navigation to the page, or
89+
during the page is in back-forward cache), the injected script is evaluated
90+
after the remote context becomes active.
91+
92+
`RemoteContext.execute_script()` calls should be serialized by always waiting
93+
for the returned promise to be resolved.
94+
So it's a good practice to always write `await ctx.execute_script(...)`.
95+
96+
## Evaluation timing of injected functions
97+
98+
The script injected by `RemoteContext.execute_script()` can be evaluated any
99+
time during the remote context is active.
100+
For example, even before DOMContentLoaded events or even during navigation.
101+
It's the responsibility of test-specific code/helpers to ensure evaluation
102+
timing constraints (which can be also test-specific), if any needed.
103+
104+
### Ensuring evaluation timing around page load
105+
106+
For example, to ensure that injected functions (`mainFunction` below) are
107+
evaluated after the first `pageshow` event, we can use pure JavaScript code
108+
like below:
109+
110+
```
111+
// executor.html
112+
window.pageShowPromise = new Promise(resolve =>
113+
window.addEventListener('pageshow', resolve, {once: true}));
114+
115+
116+
// injector.html
117+
const waitForPageShow = async () => {
118+
while (!window.pageShowPromise) {
119+
await new Promise(resolve => setTimeout(resolve, 100));
120+
}
121+
await window.pageShowPromise;
122+
};
123+
124+
await ctx.execute(waitForPageShow);
125+
await ctx.execute(mainFunction);
126+
```
127+
128+
### Ensuring evaluation timing around navigation out/unloading
129+
130+
It can be important to ensure there are no injected functions nor code behind
131+
`RemoteContext` (such as Fetch APIs accessing server-side stash) running after
132+
navigation is initiated, for example in the case of back-forward cache testing.
133+
134+
To ensure this,
135+
136+
- Do not call the next `RemoteContext.execute()` for the remote context after
137+
triggering the navigation, until we are sure that the remote context is not
138+
active (e.g. after we confirm that the new page is loaded).
139+
- Call `Executor.suspend(callback)` synchronously within the injected script.
140+
This suspends executor-related code, and calls `callback` when it is ready
141+
to start navigation.
142+
143+
The code on the injector side would be like:
144+
145+
```
146+
// injector.html
147+
await ctx.execute_script(() => {
148+
executor.suspend(() => {
149+
location.href = 'new-url.html';
150+
});
151+
});
152+
```
153+
154+
## Future Work: Possible integration with `test_driver`
155+
156+
Currently `RemoteContext` is implemented by JavaScript and WPT-server-side
157+
stash, and not integrated with `test_driver` nor `testharness`.
158+
There is a proposal of `test_driver`-integrated version (see the RFCs listed
159+
above).
160+
161+
The API semantics and guidelines in this document are designed to be applicable
162+
to both the current stash-based `RemoteContext` and `test_driver`-based
163+
version, and thus the tests using `RemoteContext` will be migrated with minimum
164+
modifications (mostly in `/common/dispatcher/dispatcher.js` and executors), for
165+
example in a
166+
[draft CL](https://chromium-review.googlesource.com/c/chromium/src/+/3082215/).
167+
168+
169+
# `send()`/`receive()` Message passing APIs
170+
171+
`dispatcher.js` (and its server-side backend `dispatcher.py`) provides a
172+
universal queue-based message passing API.
173+
Each queue is identified by a UUID, and accessed via the following APIs:
174+
175+
- `send(uuid, message)` pushes a string `message` to the queue `uuid`.
176+
- `receive(uuid)` pops the first item from the queue `uuid`.
177+
- `showRequestHeaders(origin, uuid)` and
178+
`cacheableShowRequestHeaders(origin, uuid)` return URLs, that push request
179+
headers to the queue `uuid` upon fetching.
180+
181+
It works cross-origin, and even access different browser context groups.
182+
183+
Messages are queued, this means one doesn't need to wait for the receiver to
184+
listen, before sending the first message
185+
(but still need to wait for the resolution of the promise returned by `send()`
186+
to ensure the order between `send()`s).
187+
188+
## Executors
189+
190+
Similar to `RemoteContext.execute_script()`, `send()`/`receive()` can be used
191+
for sending arbitrary javascript to be evaluated in another page or worker.
192+
193+
- `executor.html` (as a Document),
194+
- `executor-worker.js` (as a Web Worker), and
195+
- `executor-service-worker.js` (as a Service Worker)
196+
197+
are examples of executors.
198+
Note that these executors are NOT compatible with
199+
`RemoteContext.execute_script()`.
200+
201+
## Future Work
202+
203+
`send()`, `receive()` and the executors below are kept for COEP/COOP tests.
204+
205+
For remote script execution, new tests should use
206+
`RemoteContext.execute_script()` instead.
207+
208+
For message passing,
209+
[WPT RFC 90](https://github.com/web-platform-tests/rfcs/pull/90) is still under
210+
discussion.

common/dispatcher/dispatcher.js

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
// Define an universal message passing API. It works cross-origin and across
2+
// browsing context groups.
3+
const dispatcher_path = "/common/dispatcher/dispatcher.py";
4+
const dispatcher_url = new URL(dispatcher_path, location.href).href;
5+
6+
// Return a promise, limiting the number of concurrent accesses to a shared
7+
// resources to |max_concurrent_access|.
8+
const concurrencyLimiter = (max_concurrency) => {
9+
let pending = 0;
10+
let waiting = [];
11+
return async (task) => {
12+
pending++;
13+
if (pending > max_concurrency)
14+
await new Promise(resolve => waiting.push(resolve));
15+
let result = await task();
16+
pending--;
17+
waiting.shift()?.();
18+
return result;
19+
};
20+
}
21+
22+
// Wait for a random amount of time in the range [10ms,100ms].
23+
const randomDelay = () => {
24+
return new Promise(resolve => setTimeout(resolve, 10 + 90*Math.random()));
25+
}
26+
27+
// Sending too many requests in parallel causes congestion. Limiting it improves
28+
// throughput.
29+
//
30+
// Note: The following table has been determined on the test:
31+
// ../cache-storage.tentative.https.html
32+
// using Chrome with a 64 core CPU / 64GB ram, in release mode:
33+
// ┌───────────┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬────┐
34+
// │concurrency│ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 10│ 15│ 20│ 30│ 50│ 100│
35+
// ├───────────┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼────┤
36+
// │time (s) │ 54│ 38│ 31│ 29│ 26│ 24│ 22│ 22│ 22│ 22│ 34│ 36 │
37+
// └───────────┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴────┘
38+
const limiter = concurrencyLimiter(6);
39+
40+
const send = async function(uuid, message) {
41+
await limiter(async () => {
42+
// Requests might be dropped. Retry until getting a confirmation it has been
43+
// processed.
44+
while(1) {
45+
try {
46+
let response = await fetch(dispatcher_url + `?uuid=${uuid}`, {
47+
method: 'POST',
48+
body: message
49+
})
50+
if (await response.text() == "done")
51+
return;
52+
} catch (fetch_error) {}
53+
await randomDelay();
54+
};
55+
});
56+
}
57+
58+
const receive = async function(uuid) {
59+
while(1) {
60+
let data = "not ready";
61+
try {
62+
data = await limiter(async () => {
63+
let response = await fetch(dispatcher_url + `?uuid=${uuid}`);
64+
return await response.text();
65+
});
66+
} catch (fetch_error) {}
67+
68+
if (data == "not ready") {
69+
await randomDelay();
70+
continue;
71+
}
72+
73+
return data;
74+
}
75+
}
76+
77+
// Returns an URL. When called, the server sends toward the `uuid` queue the
78+
// request headers. Useful for determining if something was requested with
79+
// Cookies.
80+
const showRequestHeaders = function(origin, uuid) {
81+
return origin + dispatcher_path + `?uuid=${uuid}&show-headers`;
82+
}
83+
84+
// Same as above, except for the response is cacheable.
85+
const cacheableShowRequestHeaders = function(origin, uuid) {
86+
return origin + dispatcher_path + `?uuid=${uuid}&cacheable&show-headers`;
87+
}
88+
89+
// This script requires
90+
// - `/common/utils.js` for `token()`.
91+
92+
// Represents an remote executor with ID `this.uuid`.
93+
// For more detailed explanation see `README.md`.
94+
class RemoteContext {
95+
// Caller should create an executor with `this.uuid`.
96+
constructor(uuid) {
97+
this.context_id = uuid;
98+
}
99+
100+
// Evaluates the script `expr` on the executor.
101+
// - If `expr` is evaluated to a Promise that is resolved with a value:
102+
// `eval()` returns a Promise resolved with the value.
103+
// - If `expr` is evaluated to a non-Promise value:
104+
// `eval()` returns a Promise resolved with the value.
105+
// - If `expr` throws an error or is evaluated to a Promise that is rejected:
106+
// `eval()` returns a rejected Promise with the error's `message`.
107+
// Note that currently the type of error (e.g. DOMException) is not
108+
// preserved.
109+
// The values should be able to be serialized by JSON.stringify().
110+
async execute_script(fn, args) {
111+
const receiver = token();
112+
await this.send({receiver: receiver, fn: fn.toString(), args: args});
113+
const response = JSON.parse(await receive(receiver));
114+
if (response.status === 'success') {
115+
return response.value;
116+
}
117+
118+
// exception
119+
throw new Error(response.value);
120+
}
121+
122+
async send(msg) {
123+
return await send(this.context_id, JSON.stringify(msg));
124+
}
125+
};
126+
127+
class Executor {
128+
constructor(uuid) {
129+
this.uuid = uuid;
130+
131+
// If `suspend_callback` is not `null`, the executor should be suspended
132+
// when there are no ongoing tasks.
133+
this.suspend_callback = null;
134+
135+
this.execute();
136+
}
137+
138+
suspend(callback) {
139+
this.suspend_callback = callback;
140+
}
141+
142+
resume() {
143+
}
144+
145+
async execute() {
146+
while(true) {
147+
// At this point there are no ongoing tasks and thus it's safe to start
148+
// navigation.
149+
// Therefore we check whether the executor should be suspended.
150+
if (this.suspend_callback !== null) {
151+
this.suspend_callback();
152+
this.suspend_callback = null;
153+
// Wait for `resume()` to be called.
154+
await new Promise(resolve => this.resume = resolve);
155+
// Workaround for crbug.com/1244230.
156+
await new Promise(resolve => setTimeout(resolve, 0));
157+
continue;
158+
}
159+
160+
const task = JSON.parse(await receive(this.uuid));
161+
162+
let response;
163+
try {
164+
const value = await eval(task.fn).apply(null, task.args);
165+
response = JSON.stringify({
166+
status: 'success',
167+
value: value
168+
});
169+
} catch(e) {
170+
response = JSON.stringify({
171+
status: 'exception',
172+
value: e.message
173+
});
174+
}
175+
await send(task.receiver, response);
176+
}
177+
}
178+
}

0 commit comments

Comments
 (0)