Navigate.sys.mjs (17725B)
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/. */ 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 Deferred: "chrome://remote/content/shared/Sync.sys.mjs", 14 isUncommittedInitialDocument: 15 "chrome://remote/content/shared/messagehandler/transports/BrowsingContextUtils.sys.mjs", 16 Log: "chrome://remote/content/shared/Log.sys.mjs", 17 NavigationListener: 18 "chrome://remote/content/shared/listeners/NavigationListener.sys.mjs", 19 truncate: "chrome://remote/content/shared/Format.sys.mjs", 20 NavigationError: "chrome://remote/content/shared/RemoteError.sys.mjs", 21 }); 22 23 ChromeUtils.defineLazyGetter(lazy, "logger", () => 24 lazy.Log.get(lazy.Log.TYPES.REMOTE_AGENT) 25 ); 26 27 // Define a custom multiplier to apply to the unload timer on various platforms. 28 // This multiplier should only reflect the navigation performance of the 29 // platform and not the overall performance. 30 ChromeUtils.defineLazyGetter(lazy, "UNLOAD_TIMEOUT_MULTIPLIER", () => { 31 if (AppConstants.MOZ_CODE_COVERAGE) { 32 // Navigation on ccov platforms can be extremely slow because new processes 33 // need to be instrumented for coverage on startup. 34 return 16; 35 } 36 37 if (AppConstants.ASAN || AppConstants.DEBUG || AppConstants.TSAN) { 38 // Use an extended timeout on slow platforms. 39 return 8; 40 } 41 42 return 1; 43 }); 44 45 export const DEFAULT_UNLOAD_TIMEOUT = 200; 46 47 // Load flag for an error page from the DocShell (0x0001U << 16) 48 const LOAD_FLAG_ERROR_PAGE = 0x10000; 49 50 const STATE_START = Ci.nsIWebProgressListener.STATE_START; 51 const STATE_STOP = Ci.nsIWebProgressListener.STATE_STOP; 52 53 /** 54 * Returns the multiplier used for the unload timer. Useful for tests which 55 * assert the behavior of this timeout. 56 */ 57 export function getUnloadTimeoutMultiplier() { 58 return lazy.UNLOAD_TIMEOUT_MULTIPLIER; 59 } 60 61 // Used to keep weak references of webProgressListeners alive. 62 const webProgressListeners = new Set(); 63 64 /** 65 * Wait until the initial load of the given WebProgress is done. 66 * 67 * @param {WebProgress} webProgress 68 * The WebProgress instance to observe. 69 * @param {object=} options 70 * @param {boolean=} options.resolveWhenStarted 71 * Flag to indicate that the Promise has to be resolved when the 72 * page load has been started. Otherwise wait until the page has 73 * finished loading. Defaults to `false`. 74 * @param {number=} options.unloadTimeout 75 * Time to allow before the page gets unloaded. See ProgressListener options. 76 * @returns {Promise} 77 * Promise which resolves when the page load is in the expected state. 78 * Values as returned: 79 * - {nsIURI} currentURI The current URI of the page 80 * - {nsIURI} targetURI Target URI of the navigation 81 */ 82 export async function waitForInitialNavigationCompleted( 83 webProgress, 84 options = {} 85 ) { 86 const { resolveWhenStarted = false, unloadTimeout } = options; 87 88 const browsingContext = webProgress.browsingContext; 89 90 // Start the listener right away to avoid race conditions. 91 const listener = new ProgressListener(webProgress, { 92 resolveWhenStarted, 93 unloadTimeout, 94 }); 95 const navigated = listener.start(); 96 97 const isUncommittedInitial = 98 lazy.isUncommittedInitialDocument(browsingContext); 99 const isLoadingDocument = listener.isLoadingDocument; 100 lazy.logger.trace( 101 lazy.truncate`[${browsingContext.id}] Wait for initial navigation: isUncommittedInitial=${isUncommittedInitial}, isLoadingDocument=${isLoadingDocument}` 102 ); 103 104 // If the current document is not the initial "about:blank" and is also 105 // no longer loading, assume the navigation is done and return. 106 if (!isUncommittedInitial && !isLoadingDocument) { 107 lazy.logger.trace( 108 lazy.truncate`[${browsingContext.id}] Document already finished loading: ${browsingContext.currentURI?.spec}` 109 ); 110 111 // Will resolve the navigated promise. 112 listener.stop(); 113 } 114 115 try { 116 await navigated; 117 } catch (e) { 118 // Ignore any error if the initial navigation failed. 119 lazy.logger.debug( 120 lazy.truncate`[${browsingContext.id}] Initial Navigation to ${listener.currentURI?.spec} failed: ${e}` 121 ); 122 } 123 124 const result = { 125 currentURI: listener.currentURI, 126 targetURI: listener.targetURI, 127 }; 128 129 listener.destroy(); 130 131 return result; 132 } 133 134 /** 135 * WebProgressListener to observe for page loads. 136 */ 137 export class ProgressListener { 138 #expectNavigation; 139 #resolveWhenCommitted; 140 #resolveWhenStarted; 141 #unloadTimeout; 142 #waitForExplicitStart; 143 #webProgress; 144 145 #deferredNavigation; 146 #errorName; 147 #navigationId; 148 #navigationListener; 149 #seenStartFlag; 150 #targetURI; 151 #unloadTimerId; 152 153 /** 154 * Create a new WebProgressListener instance. 155 * 156 * @param {WebProgress} webProgress 157 * The web progress to attach the listener to. 158 * @param {object=} options 159 * @param {boolean=} options.expectNavigation 160 * Flag to indicate that a navigation is guaranteed to happen. 161 * When set to `true`, the ProgressListener will ignore options.unloadTimeout 162 * and will only resolve when the expected navigation happens. 163 * Defaults to `false`. 164 * @param {NavigationManager=} options.navigationManager 165 * The NavigationManager where navigations for the current session are 166 * monitored. 167 * @param {boolean=} options.resolveWhenCommitted 168 * Flag to indicate that the Promise has to be resolved when the 169 * navigation-committed event is received. Defaults to `false`. 170 * Cannot be used together with resolveWhenStarted. Requires to provide 171 * options.navigationManager. 172 * @param {boolean=} options.resolveWhenStarted 173 * Flag to indicate that the Promise has to be resolved when the 174 * page load has been started. Otherwise wait until the navigation was 175 * committed or the page has finished loading. Defaults to `false`. 176 * Cannot be used together with resolveWhenCommitted. 177 * @param {string=} options.targetURI 178 * The target URI for the navigation. 179 * @param {number=} options.unloadTimeout 180 * Time to allow before the page gets unloaded. Defaults to 200ms on 181 * regular platforms. A multiplier will be applied on slower platforms 182 * (eg. debug, ccov...). 183 * Ignored if options.expectNavigation is set to `true` 184 * @param {boolean=} options.waitForExplicitStart 185 * Flag to indicate that the Promise can only resolve after receiving a 186 * STATE_START state change. In other words, if the webProgress is already 187 * navigating, the Promise will only resolve for the next navigation. 188 * Defaults to `false`. 189 */ 190 constructor(webProgress, options = {}) { 191 const { 192 expectNavigation = false, 193 navigationManager = null, 194 resolveWhenCommitted = false, 195 resolveWhenStarted = false, 196 targetURI, 197 unloadTimeout = DEFAULT_UNLOAD_TIMEOUT, 198 waitForExplicitStart = false, 199 } = options; 200 201 this.#expectNavigation = expectNavigation; 202 this.#resolveWhenCommitted = resolveWhenCommitted; 203 this.#resolveWhenStarted = resolveWhenStarted; 204 this.#unloadTimeout = unloadTimeout * lazy.UNLOAD_TIMEOUT_MULTIPLIER; 205 this.#waitForExplicitStart = waitForExplicitStart; 206 this.#webProgress = webProgress; 207 208 this.#deferredNavigation = null; 209 this.#errorName = null; 210 this.#seenStartFlag = false; 211 this.#targetURI = targetURI; 212 this.#unloadTimerId = null; 213 214 if (resolveWhenCommitted) { 215 if (resolveWhenStarted) { 216 throw new Error( 217 "Cannot use both resolveWhenStarted and resolveWhenCommitted" 218 ); 219 } 220 if (!navigationManager) { 221 throw new Error( 222 "Cannot use resolveWhenCommitted without a navigationManager" 223 ); 224 } 225 } 226 227 if (navigationManager !== null) { 228 this.#navigationListener = new lazy.NavigationListener(navigationManager); 229 this.#navigationListener.on( 230 "navigation-committed", 231 this.#onNavigationCommitted 232 ); 233 this.#navigationListener.on( 234 "navigation-failed", 235 this.#onNavigationFailed 236 ); 237 this.#navigationListener.startListening(); 238 } 239 } 240 241 destroy() { 242 if (this.#navigationListener) { 243 this.#navigationListener.stopListening(); 244 this.#navigationListener.off( 245 "navigation-committed", 246 this.#onNavigationCommitted 247 ); 248 this.#navigationListener.off( 249 "navigation-failed", 250 this.#onNavigationFailed 251 ); 252 this.#navigationListener.destroy(); 253 } 254 } 255 256 get #messagePrefix() { 257 return `[${this.browsingContext.id}] ${this.constructor.name}`; 258 } 259 260 get browsingContext() { 261 return this.#webProgress.browsingContext; 262 } 263 264 get currentURI() { 265 return this.#webProgress.browsingContext.currentURI; 266 } 267 268 get documentURI() { 269 return this.#webProgress.browsingContext.currentWindowGlobal.documentURI; 270 } 271 272 get isLoadingDocument() { 273 return this.#webProgress.isLoadingDocument; 274 } 275 276 get isStarted() { 277 return !!this.#deferredNavigation; 278 } 279 280 get loadType() { 281 return this.#webProgress.loadType; 282 } 283 284 get targetURI() { 285 return this.#targetURI; 286 } 287 288 #checkLoadingState(request, options = {}) { 289 const { isStart = false, isStop = false, status = 0 } = options; 290 291 this.#trace( 292 `Loading state: isStart=${isStart} isStop=${isStop} status=0x${status.toString( 293 16 294 )}, loadType=0x${this.loadType.toString(16)}, seenStartFlag=${this.#seenStartFlag}` 295 ); 296 if (isStart) { 297 if (this.#seenStartFlag) { 298 this.#trace("Skip start state because seenStartFlag is already set"); 299 } else { 300 this.#seenStartFlag = true; 301 302 this.#targetURI = this.#getTargetURI(request); 303 304 this.#trace(lazy.truncate`Started loading ${this.targetURI?.spec}`); 305 306 if (this.#unloadTimerId !== null) { 307 lazy.clearTimeout(this.#unloadTimerId); 308 this.#trace("Cleared the unload timer"); 309 this.#unloadTimerId = null; 310 } 311 312 if (this.#resolveWhenStarted) { 313 this.#trace("Request to stop listening when navigation started"); 314 this.stop(); 315 return; 316 } 317 } 318 } 319 320 if (isStop) { 321 if (!this.#seenStartFlag) { 322 this.#trace("Skip stop state because seenStartFlag is not set"); 323 } else { 324 // Treat NS_ERROR_PARSED_DATA_CACHED as a success code 325 // since navigation happened and content has been loaded. 326 if ( 327 !Components.isSuccessCode(status) && 328 status != Cr.NS_ERROR_PARSED_DATA_CACHED 329 ) { 330 const errorName = ChromeUtils.getXPCOMErrorName(status); 331 332 if (this.loadType & LOAD_FLAG_ERROR_PAGE) { 333 // Wait for the next location change notification to ensure that the 334 // real error page was loaded. 335 this.#trace(`Error=${errorName}, wait for redirect to error page`); 336 this.#errorName = errorName; 337 return; 338 } 339 340 this.stop({ error: new lazy.NavigationError(errorName, status) }); 341 return; 342 } 343 344 // If a page finished loading the navigation is done. 345 this.stop(); 346 } 347 } 348 } 349 350 #getErrorName(documentURI) { 351 try { 352 // Otherwise try to retrieve it from the document URI if it is an 353 // error page like `about:neterror?e=contentEncodingError&u=http%3A//...` 354 const regex = /about:.*error\?e=([^&]*)/; 355 return documentURI.spec.match(regex)[1]; 356 } catch (e) { 357 // Or return a generic name 358 return "Address rejected"; 359 } 360 } 361 362 #getTargetURI(request) { 363 try { 364 return request.QueryInterface(Ci.nsIChannel).originalURI; 365 } catch (e) {} 366 367 return null; 368 } 369 370 #onNavigationCommitted = (eventName, data) => { 371 const { navigationId, url } = data; 372 373 if (this.#resolveWhenCommitted && this.#navigationId === navigationId) { 374 this.#targetURI = Services.io.newURI(url); 375 this.#trace( 376 `Received "navigation-committed" event. Stopping the navigation.` 377 ); 378 this.stop(); 379 } 380 }; 381 382 #onNavigationFailed = (eventName, data) => { 383 const { errorName, navigationId } = data; 384 385 if (this.#navigationId === navigationId) { 386 this.#trace( 387 `Received "navigation-failed" event with error=${errorName}. Stopping the navigation.` 388 ); 389 this.stop({ error: new Error(errorName) }); 390 } 391 }; 392 393 #setUnloadTimer() { 394 if (this.#expectNavigation) { 395 this.#trace("Skip setting the unload timer"); 396 } else { 397 this.#trace(`Setting unload timer (${this.#unloadTimeout}ms)`); 398 399 this.#unloadTimerId = lazy.setTimeout(() => { 400 this.#trace(`No navigation detected: ${this.currentURI?.spec}`); 401 // Assume the target is the currently loaded URI. 402 this.#targetURI = this.currentURI; 403 this.stop(); 404 }, this.#unloadTimeout); 405 } 406 } 407 408 #trace(message) { 409 lazy.logger.trace(lazy.truncate`${this.#messagePrefix} ${message}`); 410 } 411 412 onStateChange(progress, request, flag, status) { 413 this.#checkLoadingState(request, { 414 isStart: !!(flag & STATE_START), 415 isStop: !!(flag & STATE_STOP), 416 status, 417 }); 418 } 419 420 onLocationChange(progress, request, location, flag) { 421 if (flag & Ci.nsIWebProgressListener.LOCATION_CHANGE_ERROR_PAGE) { 422 // If an error page has been loaded abort the navigation. 423 const errorName = this.#errorName || this.#getErrorName(this.documentURI); 424 this.#trace( 425 lazy.truncate`Location=errorPage, error=${errorName}, url=${this.documentURI.spec}` 426 ); 427 this.stop({ error: new Error(errorName) }); 428 return; 429 } 430 431 if (flag & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) { 432 const stop = type => { 433 this.#targetURI = location; 434 this.#trace(`Location=${type}: ${this.#targetURI?.spec}`); 435 this.stop(); 436 }; 437 438 if (location.hasRef) { 439 // If the target URL contains a hash, handle the navigation as a 440 // fragment navigation. 441 stop("fragmentNavigated"); 442 return; 443 } 444 445 stop("sameDocument"); 446 } 447 } 448 449 /** 450 * Start observing web progress changes. 451 * 452 * @param {string=} navigationId 453 * The UUID for the navigation. 454 * @returns {Promise} 455 * A promise that will resolve when the navigation has been finished. 456 */ 457 start(navigationId) { 458 this.#navigationId = navigationId; 459 460 if (this.#deferredNavigation) { 461 throw new Error(`Progress listener already started`); 462 } 463 464 this.#trace( 465 `Start: expectNavigation=${this.#expectNavigation} resolveWhenStarted=${ 466 this.#resolveWhenStarted 467 } unloadTimeout=${this.#unloadTimeout} waitForExplicitStart=${ 468 this.#waitForExplicitStart 469 }` 470 ); 471 472 if (this.#webProgress.isLoadingDocument) { 473 this.#targetURI = this.#getTargetURI(this.#webProgress.documentRequest); 474 this.#trace(`Document already loading ${this.#targetURI?.spec}`); 475 476 if (this.#resolveWhenStarted && !this.#waitForExplicitStart) { 477 this.#trace( 478 "Resolve on document loading if not waiting for a load or a new navigation" 479 ); 480 return Promise.resolve(); 481 } 482 } 483 484 this.#deferredNavigation = lazy.Deferred(); 485 486 // Enable all location change and network state notifications to get 487 // informed about an upcoming load as early as possible. 488 this.#webProgress.addProgressListener( 489 this, 490 Ci.nsIWebProgress.NOTIFY_LOCATION | Ci.nsIWebProgress.NOTIFY_STATE_NETWORK 491 ); 492 493 webProgressListeners.add(this); 494 495 if (this.#webProgress.isLoadingDocument && !this.#waitForExplicitStart) { 496 this.#checkLoadingState(this.#webProgress.documentRequest, { 497 isStart: true, 498 }); 499 } else { 500 // If the document is not loading yet wait some time for the navigation 501 // to be started. 502 this.#setUnloadTimer(); 503 } 504 505 return this.#deferredNavigation.promise; 506 } 507 508 /** 509 * Stop observing web progress changes. 510 * 511 * @param {object=} options 512 * @param {Error=} options.error 513 * If specified the navigation promise will be rejected with this error. 514 */ 515 stop(options = {}) { 516 const { error } = options; 517 518 this.#trace( 519 lazy.truncate`Stop: has error=${!!error} url=${this.currentURI.spec}` 520 ); 521 522 if (!this.#deferredNavigation) { 523 throw new Error("Progress listener not yet started"); 524 } 525 526 lazy.clearTimeout(this.#unloadTimerId); 527 this.#unloadTimerId = null; 528 529 this.#webProgress.removeProgressListener(this); 530 webProgressListeners.delete(this); 531 532 if (!this.#targetURI) { 533 // If no target URI has been set yet it should be the current URI 534 this.#targetURI = this.browsingContext.currentURI; 535 } 536 537 if (error) { 538 this.#deferredNavigation.reject(error); 539 } else { 540 this.#deferredNavigation.resolve(); 541 } 542 543 this.#deferredNavigation = null; 544 } 545 546 /** 547 * Stop the progress listener if and only if we already detected a navigation 548 * start. 549 * 550 * @param {object=} options 551 * @param {Error=} options.error 552 * If specified the navigation promise will be rejected with this error. 553 */ 554 stopIfStarted(options) { 555 this.#trace(`Stop if started: seenStartFlag=${this.#seenStartFlag}`); 556 if (this.#seenStartFlag) { 557 this.stop(options); 558 } 559 } 560 561 toString() { 562 return `[object ${this.constructor.name}]`; 563 } 564 565 // XPCOM 566 567 QueryInterface = ChromeUtils.generateQI([ 568 "nsIWebProgressListener", 569 "nsISupportsWeakReference", 570 ]); 571 }