// Define a universal message passing API. It works cross-origin and across // browsing context groups. const dispatcher_path = "/common/dispatcher/dispatcher.py"; // Finds the nearest ancestor window that has a non srcdoc location. This should // give us a usable location for constructing further URLs. function findLocationFromAncestors(w) { if (w.location.href == 'about:srcdoc') { return findLocationFromAncestors(w.parent); } return w.location; } // Handles differences between workers vs frames (src vs srcdoc). function findLocation() { if (location.href == 'about:srcdoc') { return findLocationFromAncestors(window.parent); } return location; } const dispatcherLocation = findLocation(); const dispatcher_url = new URL(dispatcher_path, dispatcherLocation).href; // Return a promise, limiting the number of concurrent accesses to a shared // resources to |max_concurrent_access|. const concurrencyLimiter = (max_concurrency) => { let pending = 0; let waiting = []; return async (task) => { pending++; if (pending > max_concurrency) await new Promise(resolve => waiting.push(resolve)); let result = await task(); pending--; waiting.shift()?.(); return result; }; } // Wait for a random amount of time in the range [10ms,100ms]. const randomDelay = () => { return new Promise(resolve => setTimeout(resolve, 10 + 90*Math.random())); } // Sending too many requests in parallel causes congestion. Limiting it improves // throughput. // // Note: The following table has been determined on the test: // ../cache-storage.tentative.https.html // using Chrome with a 64 core CPU / 64GB ram, in release mode: // ┌───────────┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬────┐ // │concurrency│ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 10│ 15│ 20│ 30│ 50│ 100│ // ├───────────┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼────┤ // │time (s) │ 54│ 38│ 31│ 29│ 26│ 24│ 22│ 22│ 22│ 22│ 34│ 36 │ // └───────────┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴────┘ const limiter = concurrencyLimiter(6); // While requests to different remote contexts can go in parallel, we need to // ensure that requests to each remote context are done in order. This maps a // uuid to a queue of requests to send. A queue is processed until it is empty // and then is deleted from the map. const sendQueues = new Map(); // Sends a single item (with rate-limiting) and calls the associated resolver // when it is successfully sent. const sendItem = async function (uuid, resolver, message) { await limiter(async () => { // Requests might be dropped. Retry until getting a confirmation it has been // processed. while(1) { try { let response = await fetch(dispatcher_url + `?uuid=${uuid}`, { method: 'POST', body: message }) if (await response.text() == "done") { resolver(); return; } } catch (fetch_error) {} await randomDelay(); }; }); } // While the queue is non-empty, send the next item. This is async and new items // may be added to the queue while others are being sent. const processQueue = async function (uuid, queue) { while (queue.length) { const [resolver, message] = queue.shift(); await sendItem(uuid, resolver, message); } // The queue is empty, delete it. sendQueues.delete(uuid); } const send = async function (uuid, message) { const itemSentPromise = new Promise((resolve) => { const item = [resolve, message]; if (sendQueues.has(uuid)) { // There is already a queue for `uuid`, just add to it and it will be processed. sendQueues.get(uuid).push(item); } else { // There is no queue for `uuid`, create it and start processing. const queue = [item]; sendQueues.set(uuid, queue); processQueue(uuid, queue); } }); // Wait until the item has been successfully sent. await itemSentPromise; } const receive = async function (uuid) { while(1) { let data = "not ready"; try { data = await limiter(async () => { let response = await fetch(dispatcher_url + `?uuid=${uuid}`); return await response.text(); }); } catch (fetch_error) {} if (data == "not ready") { await randomDelay(); continue; } return data; } } // Returns an URL. When called, the server sends toward the `uuid` queue the // request headers. Useful for determining if something was requested with // Cookies. const showRequestHeaders = function(origin, uuid) { return origin + dispatcher_path + `?uuid=${uuid}&show-headers`; } // Same as above, except for the response is cacheable. const cacheableShowRequestHeaders = function(origin, uuid) { return origin + dispatcher_path + `?uuid=${uuid}&cacheable&show-headers`; } // This script requires // - `/common/utils.js` for `token()`. // Returns the URL of a document that can be used as a `RemoteContext`. // // `uuid` should be a UUID uniquely identifying the given remote context. // `options` has the following shape: // // { // host: (optional) Sets the returned URL's `host` property. Useful for // cross-origin executors. // protocol: (optional) Sets the returned URL's `protocol` property. // } function remoteExecutorUrl(uuid, options) { const url = new URL("/common/dispatcher/remote-executor.html", dispatcherLocation); url.searchParams.set("uuid", uuid); if (options?.host) { url.host = options.host; } if (options?.protocol) { url.protocol = options.protocol; } return url; } // Represents a remote executor. For more detailed explanation see `README.md`. class RemoteContext { // `uuid` is a UUID string that identifies the remote context and should // match with the `uuid` parameter of the URL of the remote context. constructor(uuid) { this.context_id = uuid; } // Evaluates the script `expr` on the executor. // - If `expr` is evaluated to a Promise that is resolved with a value: // `execute_script()` returns a Promise resolved with the value. // - If `expr` is evaluated to a non-Promise value: // `execute_script()` returns a Promise resolved with the value. // - If `expr` throws an error or is evaluated to a Promise that is rejected: // `execute_script()` returns a rejected Promise with the error's // `message`. // Note that currently the type of error (e.g. DOMException) is not // preserved, except for `TypeError`. // The values should be able to be serialized by JSON.stringify(). async execute_script(fn, args) { const receiver = token(); await this.send({receiver: receiver, fn: fn.toString(), args: args}); const response = JSON.parse(await receive(receiver)); if (response.status === 'success') { return response.value; } // exception if (response.name === 'TypeError') { throw new TypeError(response.value); } throw new Error(response.value); } async send(msg) { return await send(this.context_id, JSON.stringify(msg)); } }; class Executor { constructor(uuid) { this.uuid = uuid; // If `suspend_callback` is not `null`, the executor should be suspended // when there are no ongoing tasks. this.suspend_callback = null; this.execute(); } // Wait until there are no ongoing tasks nor fetch requests for polling // tasks, and then suspend the executor and call `callback()`. // Navigation from the executor page should be triggered inside `callback()`, // to avoid conflict with in-flight fetch requests. suspend(callback) { this.suspend_callback = callback; } resume() { } async execute() { while(true) { if (this.suspend_callback !== null) { this.suspend_callback(); this.suspend_callback = null; // Wait for `resume()` to be called. await new Promise(resolve => this.resume = resolve); // Workaround for https://crbug.com/1244230. // Without this workaround, the executor is resumed and the fetch // request to poll the next task is initiated synchronously from // pageshow event after the page restored from BFCache, and the fetch // request promise is never resolved (and thus the test results in // timeout) due to https://crbug.com/1244230. The root cause is not yet // known, but setTimeout() with 0ms causes the resume triggered on // another task and seems to resolve the issue. await new Promise(resolve => setTimeout(resolve, 0)); continue; } const task = JSON.parse(await receive(this.uuid)); let response; try { const value = await eval(task.fn).apply(null, task.args); response = JSON.stringify({ status: 'success', value: value }); } catch(e) { response = JSON.stringify({ status: 'exception', name: e.name, value: e.message }); } await send(task.receiver, response); } } }