Bug 1839316: part 5) Guard the "fetchpriority" attribute behind a pref. r=kershaw...
[gecko.git] / remote / marionette / sync.sys.mjs
blob94165cb467c28b0ae5a990d8009cb78bb4f05b56
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this file,
3  * You can obtain one at http://mozilla.org/MPL/2.0/. */
5 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
7 const lazy = {};
9 ChromeUtils.defineESModuleGetters(lazy, {
10   error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
11   Log: "chrome://remote/content/shared/Log.sys.mjs",
12 });
14 ChromeUtils.defineLazyGetter(lazy, "logger", () =>
15   lazy.Log.get(lazy.Log.TYPES.MARIONETTE)
18 const { TYPE_ONE_SHOT, TYPE_REPEATING_SLACK } = Ci.nsITimer;
20 const PROMISE_TIMEOUT = AppConstants.DEBUG ? 4500 : 1500;
22 /**
23  * Dispatch a function to be executed on the main thread.
24  *
25  * @param {Function} func
26  *     Function to be executed.
27  */
28 export function executeSoon(func) {
29   if (typeof func != "function") {
30     throw new TypeError();
31   }
33   Services.tm.dispatchToMainThread(func);
36 /**
37  * Runs a Promise-like function off the main thread until it is resolved
38  * through ``resolve`` or ``rejected`` callbacks.  The function is
39  * guaranteed to be run at least once, irregardless of the timeout.
40  *
41  * The ``func`` is evaluated every ``interval`` for as long as its
42  * runtime duration does not exceed ``interval``.  Evaluations occur
43  * sequentially, meaning that evaluations of ``func`` are queued if
44  * the runtime evaluation duration of ``func`` is greater than ``interval``.
45  *
46  * ``func`` is given two arguments, ``resolve`` and ``reject``,
47  * of which one must be called for the evaluation to complete.
48  * Calling ``resolve`` with an argument indicates that the expected
49  * wait condition was met and will return the passed value to the
50  * caller.  Conversely, calling ``reject`` will evaluate ``func``
51  * again until the ``timeout`` duration has elapsed or ``func`` throws.
52  * The passed value to ``reject`` will also be returned to the caller
53  * once the wait has expired.
54  *
55  * Usage::
56  *
57  *     let els = new PollPromise((resolve, reject) => {
58  *       let res = document.querySelectorAll("p");
59  *       if (res.length > 0) {
60  *         resolve(Array.from(res));
61  *       } else {
62  *         reject([]);
63  *       }
64  *     }, {timeout: 1000});
65  *
66  * @param {Condition} func
67  *     Function to run off the main thread.
68  * @param {object=} options
69  * @param {number=} options.timeout
70  *     Desired timeout if wanted.  If 0 or less than the runtime evaluation
71  *     time of ``func``, ``func`` is guaranteed to run at least once.
72  *     Defaults to using no timeout.
73  * @param {number=} options.interval
74  *     Duration between each poll of ``func`` in milliseconds.
75  *     Defaults to 10 milliseconds.
76  *
77  * @returns {Promise.<*>}
78  *     Yields the value passed to ``func``'s
79  *     ``resolve`` or ``reject`` callbacks.
80  *
81  * @throws {*}
82  *     If ``func`` throws, its error is propagated.
83  * @throws {TypeError}
84  *     If `timeout` or `interval`` are not numbers.
85  * @throws {RangeError}
86  *     If `timeout` or `interval` are not unsigned integers.
87  */
88 export function PollPromise(func, { timeout = null, interval = 10 } = {}) {
89   const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
91   if (typeof func != "function") {
92     throw new TypeError();
93   }
94   if (timeout != null && typeof timeout != "number") {
95     throw new TypeError();
96   }
97   if (typeof interval != "number") {
98     throw new TypeError();
99   }
100   if (
101     (timeout && (!Number.isInteger(timeout) || timeout < 0)) ||
102     !Number.isInteger(interval) ||
103     interval < 0
104   ) {
105     throw new RangeError();
106   }
108   return new Promise((resolve, reject) => {
109     let start, end;
111     if (Number.isInteger(timeout)) {
112       start = new Date().getTime();
113       end = start + timeout;
114     }
116     let evalFn = () => {
117       new Promise(func)
118         .then(resolve, rejected => {
119           if (lazy.error.isError(rejected)) {
120             throw rejected;
121           }
123           // return if there is a timeout and set to 0,
124           // allowing |func| to be evaluated at least once
125           if (
126             typeof end != "undefined" &&
127             (start == end || new Date().getTime() >= end)
128           ) {
129             resolve(rejected);
130           }
131         })
132         .catch(reject);
133     };
135     // the repeating slack timer waits |interval|
136     // before invoking |evalFn|
137     evalFn();
139     timer.init(evalFn, interval, TYPE_REPEATING_SLACK);
140   }).then(
141     res => {
142       timer.cancel();
143       return res;
144     },
145     err => {
146       timer.cancel();
147       throw err;
148     }
149   );
153  * Represents the timed, eventual completion (or failure) of an
154  * asynchronous operation, and its resulting value.
156  * In contrast to a regular Promise, it times out after ``timeout``.
158  * @param {Function} fn
159  *     Function to run, which will have its ``reject``
160  *     callback invoked after the ``timeout`` duration is reached.
161  *     It is given two callbacks: ``resolve(value)`` and
162  *     ``reject(error)``.
163  * @param {object=} options
164  * @param {string} options.errorMessage
165  *     Message to use for the thrown error.
166  * @param {number=} options.timeout
167  *     ``condition``'s ``reject`` callback will be called
168  *     after this timeout, given in milliseconds.
169  *     By default 1500 ms in an optimised build and 4500 ms in
170  *     debug builds.
171  * @param {Error=} options.throws
172  *     When the ``timeout`` is hit, this error class will be
173  *     thrown.  If it is null, no error is thrown and the promise is
174  *     instead resolved on timeout with a TimeoutError.
176  * @returns {Promise.<*>}
177  *     Timed promise.
179  * @throws {TypeError}
180  *     If `timeout` is not a number.
181  * @throws {RangeError}
182  *     If `timeout` is not an unsigned integer.
183  */
184 export function TimedPromise(fn, options = {}) {
185   const {
186     errorMessage = "TimedPromise timed out",
187     timeout = PROMISE_TIMEOUT,
188     throws = lazy.error.TimeoutError,
189   } = options;
191   const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
193   if (typeof fn != "function") {
194     throw new TypeError();
195   }
196   if (typeof timeout != "number") {
197     throw new TypeError();
198   }
199   if (!Number.isInteger(timeout) || timeout < 0) {
200     throw new RangeError();
201   }
203   return new Promise((resolve, reject) => {
204     let trace;
206     // Reject only if |throws| is given.  Otherwise it is assumed that
207     // the user is OK with the promise timing out.
208     let bail = () => {
209       const message = `${errorMessage} after ${timeout} ms`;
210       if (throws !== null) {
211         let err = new throws(message);
212         reject(err);
213       } else {
214         lazy.logger.warn(message, trace);
215         resolve();
216       }
217     };
219     trace = lazy.error.stack();
220     timer.initWithCallback({ notify: bail }, timeout, TYPE_ONE_SHOT);
222     try {
223       fn(resolve, reject);
224     } catch (e) {
225       reject(e);
226     }
227   }).then(
228     res => {
229       timer.cancel();
230       return res;
231     },
232     err => {
233       timer.cancel();
234       throw err;
235     }
236   );
240  * Pauses for the given duration.
242  * @param {number} timeout
243  *     Duration to wait before fulfilling promise in milliseconds.
245  * @returns {Promise}
246  *     Promise that fulfills when the `timeout` is elapsed.
248  * @throws {TypeError}
249  *     If `timeout` is not a number.
250  * @throws {RangeError}
251  *     If `timeout` is not an unsigned integer.
252  */
253 export function Sleep(timeout) {
254   if (typeof timeout != "number") {
255     throw new TypeError();
256   }
257   if (!Number.isInteger(timeout) || timeout < 0) {
258     throw new RangeError();
259   }
261   return new Promise(resolve => {
262     const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
263     timer.init(
264       () => {
265         // Bug 1663880 - Explicitely cancel the timer for now to prevent a hang
266         timer.cancel();
267         resolve();
268       },
269       timeout,
270       TYPE_ONE_SHOT
271     );
272   });
276  * Detects when the specified message manager has been destroyed.
278  * One can observe the removal and detachment of a content browser
279  * (`<xul:browser>`) or a chrome window by its message manager
280  * disconnecting.
282  * When a browser is associated with a tab, this is safer than only
283  * relying on the event `TabClose` which signalises the _intent to_
284  * remove a tab and consequently would lead to the destruction of
285  * the content browser and its browser message manager.
287  * When closing a chrome window it is safer than only relying on
288  * the event 'unload' which signalises the _intent to_ close the
289  * chrome window and consequently would lead to the destruction of
290  * the window and its window message manager.
292  * @param {MessageListenerManager} messageManager
293  *     The message manager to observe for its disconnect state.
294  *     Use the browser message manager when closing a content browser,
295  *     and the window message manager when closing a chrome window.
297  * @returns {Promise}
298  *     A promise that resolves when the message manager has been destroyed.
299  */
300 export function MessageManagerDestroyedPromise(messageManager) {
301   return new Promise(resolve => {
302     function observe(subject, topic) {
303       lazy.logger.trace(`Received observer notification ${topic}`);
305       if (subject == messageManager) {
306         Services.obs.removeObserver(this, "message-manager-disconnect");
307         resolve();
308       }
309     }
311     Services.obs.addObserver(observe, "message-manager-disconnect");
312   });
316  * Throttle until the main thread is idle and `window` has performed
317  * an animation frame (in that order).
319  * @param {ChromeWindow} win
320  *     Window to request the animation frame from.
322  * @returns {Promise}
323  */
324 export function IdlePromise(win) {
325   const animationFramePromise = new Promise(resolve => {
326     executeSoon(() => {
327       win.requestAnimationFrame(resolve);
328     });
329   });
331   // Abort if the underlying window gets closed
332   const windowClosedPromise = new PollPromise(resolve => {
333     if (win.closed) {
334       resolve();
335     }
336   });
338   return Promise.race([animationFramePromise, windowClosedPromise]);
342  * Wraps a callback function, that, as long as it continues to be
343  * invoked, will not be triggered.  The given function will be
344  * called after the timeout duration is reached, after no more
345  * events fire.
347  * This class implements the {@link EventListener} interface,
348  * which means it can be used interchangably with `addEventHandler`.
350  * Debouncing events can be useful when dealing with e.g. DOM events
351  * that fire at a high rate.  It is generally advisable to avoid
352  * computationally expensive operations such as DOM modifications
353  * under these circumstances.
355  * One such high frequenecy event is `resize` that can fire multiple
356  * times before the window reaches its final dimensions.  In order
357  * to delay an operation until the window has completed resizing,
358  * it is possible to use this technique to only invoke the callback
359  * after the last event has fired::
361  *     let cb = new DebounceCallback(event => {
362  *       // fires after the final resize event
363  *       console.log("resize", event);
364  *     });
365  *     window.addEventListener("resize", cb);
367  * Note that it is not possible to use this synchronisation primitive
368  * with `addEventListener(..., {once: true})`.
370  * @param {function(Event)} fn
371  *     Callback function that is guaranteed to be invoked once only,
372  *     after `timeout`.
373  * @param {number=} [timeout = 250] timeout
374  *     Time since last event firing, before `fn` will be invoked.
375  */
376 export class DebounceCallback {
377   constructor(fn, { timeout = 250 } = {}) {
378     if (typeof fn != "function" || typeof timeout != "number") {
379       throw new TypeError();
380     }
381     if (!Number.isInteger(timeout) || timeout < 0) {
382       throw new RangeError();
383     }
385     this.fn = fn;
386     this.timeout = timeout;
387     this.timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
388   }
390   handleEvent(ev) {
391     this.timer.cancel();
392     this.timer.initWithCallback(
393       () => {
394         this.timer.cancel();
395         this.fn(ev);
396       },
397       this.timeout,
398       TYPE_ONE_SHOT
399     );
400   }
404  * Wait for a message to be fired from a particular message manager.
406  * This method has been duplicated from BrowserTestUtils.sys.mjs.
408  * @param {nsIMessageManager} messageManager
409  *     The message manager that should be used.
410  * @param {string} messageName
411  *     The message to wait for.
412  * @param {object=} options
413  *     Extra options.
414  * @param {function(Message)=} options.checkFn
415  *     Called with the ``Message`` object as argument, should return ``true``
416  *     if the message is the expected one, or ``false`` if it should be
417  *     ignored and listening should continue. If not specified, the first
418  *     message with the specified name resolves the returned promise.
420  * @returns {Promise.<object>}
421  *     Promise which resolves to the data property of the received
422  *     ``Message``.
423  */
424 export function waitForMessage(
425   messageManager,
426   messageName,
427   { checkFn = undefined } = {}
428 ) {
429   if (messageManager == null || !("addMessageListener" in messageManager)) {
430     throw new TypeError();
431   }
432   if (typeof messageName != "string") {
433     throw new TypeError();
434   }
435   if (checkFn && typeof checkFn != "function") {
436     throw new TypeError();
437   }
439   return new Promise(resolve => {
440     messageManager.addMessageListener(messageName, function onMessage(msg) {
441       lazy.logger.trace(`Received ${messageName} for ${msg.target}`);
442       if (checkFn && !checkFn(msg)) {
443         return;
444       }
445       messageManager.removeMessageListener(messageName, onMessage);
446       resolve(msg.data);
447     });
448   });
452  * Wait for the specified observer topic to be observed.
454  * This method has been duplicated from TestUtils.sys.mjs.
456  * Because this function is intended for testing, any error in checkFn
457  * will cause the returned promise to be rejected instead of waiting for
458  * the next notification, since this is probably a bug in the test.
460  * @param {string} topic
461  *     The topic to observe.
462  * @param {object=} options
463  *     Extra options.
464  * @param {function(string, object)=} options.checkFn
465  *     Called with ``subject``, and ``data`` as arguments, should return true
466  *     if the notification is the expected one, or false if it should be
467  *     ignored and listening should continue. If not specified, the first
468  *     notification for the specified topic resolves the returned promise.
469  * @param {number=} options.timeout
470  *     Timeout duration in milliseconds, if provided.
471  *     If specified, then the returned promise will be rejected with
472  *     TimeoutError, if not already resolved, after this duration has elapsed.
473  *     If not specified, then no timeout is used. Defaults to null.
475  * @returns {Promise.<Array<string, object>>}
476  *     Promise which is either resolved to an array of ``subject``, and ``data``
477  *     from the observed notification, or rejected with TimeoutError after
478  *     options.timeout milliseconds if specified.
480  * @throws {TypeError}
481  * @throws {RangeError}
482  */
483 export function waitForObserverTopic(topic, options = {}) {
484   const { checkFn = null, timeout = null } = options;
485   if (typeof topic != "string") {
486     throw new TypeError();
487   }
488   if (
489     (checkFn != null && typeof checkFn != "function") ||
490     (timeout !== null && typeof timeout != "number")
491   ) {
492     throw new TypeError();
493   }
494   if (timeout && (!Number.isInteger(timeout) || timeout < 0)) {
495     throw new RangeError();
496   }
498   return new Promise((resolve, reject) => {
499     let timer;
501     function cleanUp() {
502       Services.obs.removeObserver(observer, topic);
503       timer?.cancel();
504     }
506     function observer(subject, topic, data) {
507       lazy.logger.trace(`Received observer notification ${topic}`);
508       try {
509         if (checkFn && !checkFn(subject, data)) {
510           return;
511         }
512         cleanUp();
513         resolve({ subject, data });
514       } catch (ex) {
515         cleanUp();
516         reject(ex);
517       }
518     }
520     Services.obs.addObserver(observer, topic);
522     if (timeout !== null) {
523       timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
524       timer.init(
525         () => {
526           cleanUp();
527           reject(
528             new lazy.error.TimeoutError(
529               `waitForObserverTopic timed out after ${timeout} ms`
530             )
531           );
532         },
533         timeout,
534         TYPE_ONE_SHOT
535       );
536     }
537   });