Sync.sys.mjs (13880B)
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 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; 6 7 const lazy = {}; 8 9 ChromeUtils.defineESModuleGetters(lazy, { 10 clearTimeout: "resource://gre/modules/Timer.sys.mjs", 11 setTimeout: "resource://gre/modules/Timer.sys.mjs", 12 13 error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", 14 Log: "chrome://remote/content/shared/Log.sys.mjs", 15 }); 16 17 const { TYPE_ONE_SHOT, TYPE_REPEATING_SLACK } = Ci.nsITimer; 18 19 const PROMISE_TIMEOUT = AppConstants.DEBUG ? 4500 : 1500; 20 21 ChromeUtils.defineLazyGetter(lazy, "logger", () => 22 lazy.Log.get(lazy.Log.TYPES.REMOTE_AGENT) 23 ); 24 25 /** 26 * Throttle until the `window` has performed an animation frame. 27 * 28 * The animation frame is requested after the main thread has processed 29 * all the already queued-up runnables. 30 * 31 * @param {ChromeWindow} win 32 * Window to request the animation frame from. 33 * @param {object=} options 34 * @param {number=} options.timeout 35 * Timeout duration in milliseconds. 36 * This copes with navigating away from hidden iframes: if 37 * fragmentNavigated happens before their animation finishes, this would 38 * never resolve otherwise. By default 1500 ms in an optimised build and 39 * 4500 ms in debug builds. Specify null to disable the timeout. 40 * 41 * @returns {Promise} 42 * 43 * @throws {TypeError} 44 * @throws {RangeError} 45 */ 46 export function AnimationFramePromise(win, options = {}) { 47 const { timeout = PROMISE_TIMEOUT } = options; 48 49 if (timeout !== null) { 50 if (typeof timeout != "number") { 51 throw new TypeError("timeout must be a number or null"); 52 } 53 54 if (!Number.isInteger(timeout) || timeout < 0) { 55 throw new RangeError("timeout must be a non-negative integer"); 56 } 57 } 58 59 const animationFramePromise = new Promise(resolve => { 60 executeSoon(() => { 61 win.requestAnimationFrame(resolve); 62 }); 63 }); 64 65 const promises = [ 66 animationFramePromise, 67 new EventPromise(win, "pagehide"), // window closed or moved to BFCache 68 ]; 69 70 let timer; 71 if (timeout != null) { 72 promises.push( 73 new Promise(resolve => { 74 timer = lazy.setTimeout(() => { 75 lazy.logger.warn("Timed out waiting for animation frame"); 76 resolve(); 77 }, timeout); 78 }) 79 ); 80 } 81 82 return Promise.race(promises).then(() => lazy.clearTimeout(timer)); 83 } 84 85 /** 86 * Create a helper object to defer a promise. 87 * 88 * @returns {object} 89 * An object that returns the following properties: 90 * - fulfilled Flag that indicates that the promise got resolved 91 * - pending Flag that indicates a not yet fulfilled/rejected promise 92 * - promise The actual promise 93 * - reject Callback to reject the promise 94 * - rejected Flag that indicates that the promise got rejected 95 * - resolve Callback to resolve the promise 96 */ 97 export function Deferred() { 98 const deferred = {}; 99 100 deferred.promise = new Promise((resolve, reject) => { 101 deferred.fulfilled = false; 102 deferred.pending = true; 103 deferred.rejected = false; 104 105 deferred.resolve = (...args) => { 106 deferred.fulfilled = true; 107 deferred.pending = false; 108 resolve(...args); 109 }; 110 111 deferred.reject = (...args) => { 112 deferred.pending = false; 113 deferred.rejected = true; 114 reject(...args); 115 }; 116 }); 117 118 return deferred; 119 } 120 121 /** 122 * Wait for an event to be fired on a specified element. 123 * 124 * The returned promise is guaranteed to not resolve before the 125 * next event tick after the event listener is called, so that all 126 * other event listeners for the element are executed before the 127 * handler is executed. For example: 128 * 129 * const promise = new EventPromise(element, "myEvent"); 130 * // same event tick here 131 * await promise; 132 * // next event tick here 133 * 134 * @param {Element} subject 135 * The element that should receive the event. 136 * @param {string} eventName 137 * Case-sensitive string representing the event name to listen for. 138 * @param {object=} options 139 * @param {boolean=} options.capture 140 * Indicates the event will be dispatched to this subject, 141 * before it bubbles down to any EventTarget beneath it in the 142 * DOM tree. Defaults to false. 143 * @param {Function=} options.checkFn 144 * Called with the Event object as argument, should return true if the 145 * event is the expected one, or false if it should be ignored and 146 * listening should continue. If not specified, the first event with 147 * the specified name resolves the returned promise. Defaults to null. 148 * @param {number=} options.timeout 149 * Timeout duration in milliseconds, if provided. 150 * If specified, then the returned promise will be rejected with 151 * TimeoutError, if not already resolved, after this duration has elapsed. 152 * If not specified, then no timeout is used. Defaults to null. 153 * @param {boolean=} options.mozSystemGroup 154 * Determines whether to add listener to the system group. Defaults to 155 * false. 156 * @param {boolean=} options.wantUntrusted 157 * Receive synthetic events dispatched by web content. Defaults to false. 158 * 159 * @returns {Promise<Event>} 160 * Either fulfilled with the first described event, satisfying 161 * options.checkFn if specified, or rejected with TimeoutError after 162 * options.timeout milliseconds if specified. 163 * 164 * @throws {TypeError} 165 * @throws {RangeError} 166 */ 167 export function EventPromise(subject, eventName, options = {}) { 168 const { 169 capture = false, 170 checkFn = null, 171 timeout = null, 172 mozSystemGroup = false, 173 wantUntrusted = false, 174 } = options; 175 if ( 176 !subject || 177 !("addEventListener" in subject) || 178 typeof eventName != "string" || 179 typeof capture != "boolean" || 180 (checkFn && typeof checkFn != "function") || 181 (timeout !== null && typeof timeout != "number") || 182 typeof mozSystemGroup != "boolean" || 183 typeof wantUntrusted != "boolean" 184 ) { 185 throw new TypeError(); 186 } 187 if (timeout < 0) { 188 throw new RangeError(); 189 } 190 191 return new Promise((resolve, reject) => { 192 let timer; 193 194 function cleanUp() { 195 subject.removeEventListener(eventName, listener, capture); 196 timer?.cancel(); 197 } 198 199 function listener(event) { 200 lazy.logger.trace(`Received DOM event ${event.type} for ${event.target}`); 201 try { 202 if (checkFn && !checkFn(event)) { 203 return; 204 } 205 } catch (e) { 206 // Treat an exception in the callback as a falsy value 207 lazy.logger.warn(`Event check failed: ${e.message}`); 208 } 209 210 cleanUp(); 211 executeSoon(() => resolve(event)); 212 } 213 214 subject.addEventListener(eventName, listener, { 215 capture, 216 mozSystemGroup, 217 wantUntrusted, 218 }); 219 220 if (timeout !== null) { 221 timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); 222 timer.init( 223 () => { 224 cleanUp(); 225 reject( 226 new lazy.error.TimeoutError( 227 `EventPromise timed out after ${timeout} ms` 228 ) 229 ); 230 }, 231 timeout, 232 TYPE_ONE_SHOT 233 ); 234 } 235 }); 236 } 237 238 /** 239 * Wait for the next tick in the event loop to execute a callback. 240 * 241 * @param {Function} fn 242 * Function to be executed. 243 */ 244 export function executeSoon(fn) { 245 if (typeof fn != "function") { 246 throw new TypeError(); 247 } 248 249 Services.tm.dispatchToMainThread(fn); 250 } 251 252 /** 253 * Runs a Promise-like function off the main thread until it is resolved 254 * through ``resolve`` or ``rejected`` callbacks. The function is 255 * guaranteed to be run at least once, regardless of the timeout. 256 * 257 * The ``func`` is evaluated every ``interval`` for as long as its 258 * runtime duration does not exceed ``interval``. Evaluations occur 259 * sequentially, meaning that evaluations of ``func`` are queued if 260 * the runtime evaluation duration of ``func`` is greater than ``interval``. 261 * 262 * ``func`` is given two arguments, ``resolve`` and ``reject``, 263 * of which one must be called for the evaluation to complete. 264 * Calling ``resolve`` with an argument indicates that the expected 265 * wait condition was met and will return the passed value to the 266 * caller. Conversely, calling ``reject`` will evaluate ``func`` 267 * again until the ``timeout`` duration has elapsed or ``func`` throws. 268 * The passed value to ``reject`` will also be returned to the caller 269 * once the wait has expired. 270 * 271 * Usage:: 272 * 273 * let els = new PollPromise((resolve, reject) => { 274 * let res = document.querySelectorAll("p"); 275 * if (res.length > 0) { 276 * resolve(Array.from(res)); 277 * } else { 278 * reject([]); 279 * } 280 * }, {timeout: 1000}); 281 * 282 * @param {Condition} func 283 * Function to run off the main thread. 284 * @param {object=} options 285 * @param {string=} options.errorMessage 286 * Message to use to send a warning if ``timeout`` is over. 287 * Defaults to `PollPromise timed out`. 288 * @param {number=} options.timeout 289 * Desired timeout if wanted. If 0 or less than the runtime evaluation 290 * time of ``func``, ``func`` is guaranteed to run at least once. 291 * Defaults to using no timeout. 292 * @param {number=} options.interval 293 * Duration between each poll of ``func`` in milliseconds. 294 * Defaults to 10 milliseconds. 295 * 296 * @returns {Promise.<*>} 297 * Yields the value passed to ``func``'s 298 * ``resolve`` or ``reject`` callbacks. 299 * 300 * @throws {*} 301 * If ``func`` throws, its error is propagated. 302 * @throws {TypeError} 303 * If `timeout` or `interval`` are not numbers. 304 * @throws {RangeError} 305 * If `timeout` or `interval` are not unsigned integers. 306 */ 307 export function PollPromise(func, options = {}) { 308 const { 309 errorMessage = "PollPromise timed out", 310 interval = 10, 311 timeout = null, 312 } = options; 313 const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); 314 let didTimeOut = false; 315 316 if (typeof func != "function") { 317 throw new TypeError(); 318 } 319 if (timeout != null && typeof timeout != "number") { 320 throw new TypeError(); 321 } 322 if (typeof interval != "number") { 323 throw new TypeError(); 324 } 325 if ( 326 (timeout && (!Number.isInteger(timeout) || timeout < 0)) || 327 !Number.isInteger(interval) || 328 interval < 0 329 ) { 330 throw new RangeError(); 331 } 332 333 return new Promise((resolve, reject) => { 334 let start, end; 335 336 if (Number.isInteger(timeout)) { 337 start = new Date().getTime(); 338 end = start + timeout; 339 } 340 341 let evalFn = () => { 342 new Promise(func) 343 .then(resolve, rejected => { 344 if (typeof rejected != "undefined") { 345 throw rejected; 346 } 347 348 // return if there is a timeout and set to 0, 349 // allowing |func| to be evaluated at least once 350 if ( 351 typeof end != "undefined" && 352 (start == end || new Date().getTime() >= end) 353 ) { 354 didTimeOut = true; 355 resolve(rejected); 356 } 357 }) 358 .catch(reject); 359 }; 360 361 // the repeating slack timer waits |interval| 362 // before invoking |evalFn| 363 evalFn(); 364 365 timer.init(evalFn, interval, TYPE_REPEATING_SLACK); 366 }).then( 367 res => { 368 if (didTimeOut) { 369 lazy.logger.warn(`${errorMessage} after ${timeout} ms`); 370 } 371 timer.cancel(); 372 return res; 373 }, 374 err => { 375 timer.cancel(); 376 throw err; 377 } 378 ); 379 } 380 381 /** 382 * Represents the timed, eventual completion (or failure) of an 383 * asynchronous operation, and its resulting value. 384 * 385 * In contrast to a regular Promise, it times out after ``timeout``. 386 * 387 * @param {Function} fn 388 * Function to run, which will have its ``reject`` 389 * callback invoked after the ``timeout`` duration is reached. 390 * It is given two callbacks: ``resolve(value)`` and 391 * ``reject(error)``. 392 * @param {object=} options 393 * @param {string} options.errorMessage 394 * Message to use for the thrown error. 395 * @param {number=} options.timeout 396 * ``condition``'s ``reject`` callback will be called 397 * after this timeout, given in milliseconds. 398 * By default 1500 ms in an optimised build and 4500 ms in 399 * debug builds. 400 * @param {Error=} options.throws 401 * When the ``timeout`` is hit, this error class will be 402 * thrown. If it is null, no error is thrown and the promise is 403 * instead resolved on timeout with a TimeoutError. 404 * 405 * @returns {Promise.<*>} 406 * Timed promise. 407 * 408 * @throws {TypeError} 409 * If `timeout` is not a number. 410 * @throws {RangeError} 411 * If `timeout` is not an unsigned integer. 412 */ 413 export function TimedPromise(fn, options = {}) { 414 const { 415 errorMessage = "TimedPromise timed out", 416 timeout = PROMISE_TIMEOUT, 417 throws = lazy.error.TimeoutError, 418 } = options; 419 420 const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); 421 422 if (typeof fn != "function") { 423 throw new TypeError(); 424 } 425 if (typeof timeout != "number") { 426 throw new TypeError(); 427 } 428 if (!Number.isInteger(timeout) || timeout < 0) { 429 throw new RangeError(); 430 } 431 432 return new Promise((resolve, reject) => { 433 let trace; 434 435 // Reject only if |throws| is given. Otherwise it is assumed that 436 // the user is OK with the promise timing out. 437 let bail = () => { 438 const message = `${errorMessage} after ${timeout} ms`; 439 if (throws !== null) { 440 let err = new throws(message); 441 reject(err); 442 } else { 443 lazy.logger.warn(message, trace); 444 resolve(); 445 } 446 }; 447 448 trace = lazy.error.stack(); 449 timer.initWithCallback({ notify: bail }, timeout, TYPE_ONE_SHOT); 450 451 try { 452 fn(resolve, reject); 453 } catch (e) { 454 reject(e); 455 } 456 }).then( 457 res => { 458 timer.cancel(); 459 return res; 460 }, 461 err => { 462 timer.cancel(); 463 throw err; 464 } 465 ); 466 }