Launcher.sys.mjs (16160B)
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 "use strict"; 6 7 // Keep this synchronized with the value of the same name in 8 // toolkit/xre/nsAppRunner.cpp. 9 const BROWSER_TOOLBOX_WINDOW_URL = 10 "chrome://devtools/content/framework/browser-toolbox/window.html"; 11 const CHROME_DEBUGGER_PROFILE_NAME = "chrome_debugger_profile"; 12 13 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; 14 import { require } from "resource://devtools/shared/loader/Loader.sys.mjs"; 15 import { Subprocess } from "resource://gre/modules/Subprocess.sys.mjs"; 16 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; 17 18 const { 19 useDistinctSystemPrincipalLoader, 20 releaseDistinctSystemPrincipalLoader, 21 } = ChromeUtils.importESModule( 22 "resource://devtools/shared/loader/DistinctSystemPrincipalLoader.sys.mjs", 23 { global: "shared" } 24 ); 25 const lazy = {}; 26 ChromeUtils.defineESModuleGetters(lazy, { 27 BackgroundTasksUtils: "resource://gre/modules/BackgroundTasksUtils.sys.mjs", 28 }); 29 30 XPCOMUtils.defineLazyServiceGetters(lazy, { 31 XreDirProvider: [ 32 "@mozilla.org/xre/directory-provider;1", 33 Ci.nsIXREDirProvider, 34 ], 35 }); 36 37 const Telemetry = require("resource://devtools/client/shared/telemetry.js"); 38 const EventEmitter = require("resource://devtools/shared/event-emitter.js"); 39 40 const processes = new Set(); 41 42 /** 43 * @typedef {object} BrowserToolboxLauncherArgs 44 * @property {function} onRun - A function called when the process starts running. 45 * @property {boolean} overwritePreferences - Set to force overwriting the toolbox 46 * profile's preferences with the current set of preferences. 47 * @property {boolean} forceMultiprocess - Set to force the Browser Toolbox to be in 48 * multiprocess mode. 49 */ 50 51 export class BrowserToolboxLauncher extends EventEmitter { 52 /** 53 * Initializes and starts a chrome toolbox process if the appropriated prefs are enabled 54 * 55 * @param {BrowserToolboxLauncherArgs} args 56 * @return {BrowserToolboxLauncher|null} The created instance, or null if the required prefs 57 * are not set. 58 */ 59 static init(args) { 60 if ( 61 !Services.prefs.getBoolPref("devtools.chrome.enabled") || 62 !Services.prefs.getBoolPref("devtools.debugger.remote-enabled") 63 ) { 64 console.error("Could not start Browser Toolbox, you need to enable it."); 65 return null; 66 } 67 return new BrowserToolboxLauncher(args); 68 } 69 70 /** 71 * Figure out if there are any open Browser Toolboxes that'll need to be restored. 72 * 73 * @return {boolean} 74 */ 75 static getBrowserToolboxSessionState() { 76 return processes.size !== 0; 77 } 78 79 #closed; 80 #devToolsServer; 81 #dbgProfilePath; 82 #dbgProcess; 83 #listener; 84 #loader; 85 #port; 86 #telemetry = new Telemetry(); 87 88 /** 89 * Constructor for creating a process that will hold a chrome toolbox. 90 * 91 * @param {...BrowserToolboxLauncherArgs} args 92 */ 93 constructor({ forceMultiprocess, onRun, overwritePreferences } = {}) { 94 super(); 95 96 if (onRun) { 97 this.once("run", onRun); 98 } 99 100 this.close = this.close.bind(this); 101 Services.obs.addObserver(this.close, "quit-application"); 102 this.#initServer(); 103 this.#initProfile(overwritePreferences); 104 this.#create({ forceMultiprocess }); 105 106 processes.add(this); 107 } 108 109 /** 110 * Initializes the devtools server. 111 */ 112 #initServer() { 113 if (this.#devToolsServer) { 114 dumpn("The chrome toolbox server is already running."); 115 return; 116 } 117 118 dumpn("Initializing the chrome toolbox server."); 119 120 // Create a separate loader instance, so that we can be sure to receive a 121 // separate instance of the DebuggingServer from the rest of the devtools. 122 // This allows us to safely use the tools against even the actors and 123 // DebuggingServer itself, especially since we can mark this loader as 124 // invisible to the debugger (unlike the usual loader settings). 125 this.#loader = useDistinctSystemPrincipalLoader(this); 126 const { DevToolsServer } = this.#loader.require( 127 "resource://devtools/server/devtools-server.js" 128 ); 129 const { SocketListener } = this.#loader.require( 130 "resource://devtools/shared/security/socket.js" 131 ); 132 this.#devToolsServer = DevToolsServer; 133 dumpn("Created a separate loader instance for the DevToolsServer."); 134 135 this.#devToolsServer.init(); 136 // We mainly need a root actor and target actors for opening a toolbox, even 137 // against chrome/content. But the "no auto hide" button uses the 138 // preference actor, so also register the browser actors. 139 this.#devToolsServer.registerAllActors(); 140 this.#devToolsServer.allowChromeProcess = true; 141 dumpn("initialized and added the browser actors for the DevToolsServer."); 142 143 const bts = Cc["@mozilla.org/backgroundtasks;1"]?.getService( 144 Ci.nsIBackgroundTasks 145 ); 146 if (bts?.isBackgroundTaskMode) { 147 // A special root actor, just for background tasks invoked with 148 // `--backgroundtask TASK --jsdebugger`. 149 const { createRootActor } = this.#loader.require( 150 "resource://gre/modules/backgroundtasks/dbg-actors.js" 151 ); 152 this.#devToolsServer.setRootActor(createRootActor); 153 } 154 155 const chromeDebuggingWebSocket = Services.prefs.getBoolPref( 156 "devtools.debugger.chrome-debugging-websocket" 157 ); 158 const socketOptions = { 159 fromBrowserToolbox: true, 160 portOrPath: -1, 161 webSocket: chromeDebuggingWebSocket, 162 }; 163 const listener = new SocketListener(this.#devToolsServer, socketOptions); 164 listener.open(); 165 this.#listener = listener; 166 this.#port = listener.port; 167 168 if (!this.#port) { 169 throw new Error("No devtools server port"); 170 } 171 172 dumpn("Finished initializing the chrome toolbox server."); 173 dump( 174 `DevTools Server for Browser Toolbox listening on port: ${this.#port}\n` 175 ); 176 } 177 178 /** 179 * Initializes a profile for the remote debugger process. 180 */ 181 #initProfile(overwritePreferences) { 182 dumpn("Initializing the chrome toolbox user profile."); 183 184 const bts = Cc["@mozilla.org/backgroundtasks;1"]?.getService( 185 Ci.nsIBackgroundTasks 186 ); 187 188 let debuggingProfileDir = Services.dirsvc.get("ProfD", Ci.nsIFile); 189 if (bts?.isBackgroundTaskMode) { 190 // Background tasks run with a temporary ephemeral profile. We move the 191 // browser toolbox profile out of that ephemeral profile so that it has 192 // alonger life then the background task profile. This preserves 193 // breakpoints, etc, across repeated debugging invocations. This 194 // directory is close to the background task temporary profile name(s), 195 // but doesn't match the prefix that will get purged by the stale 196 // ephemeral profile cleanup mechanism. 197 // 198 // For example, the invocation 199 // `firefox --backgroundtask success --jsdebugger --wait-for-jsdebugger` 200 // might run with ephemeral profile 201 // `/tmp/MozillaBackgroundTask-<HASH>-success` 202 // and sibling directory browser toolbox profile 203 // `/tmp/MozillaBackgroundTask-<HASH>-chrome_debugger_profile-success` 204 // 205 // See `BackgroundTasks::Shutdown` for ephemeral profile cleanup details. 206 debuggingProfileDir = debuggingProfileDir.parent; 207 debuggingProfileDir.append( 208 `${Services.appinfo.vendor}BackgroundTask-` + 209 `${lazy.XreDirProvider.getInstallHash()}-${CHROME_DEBUGGER_PROFILE_NAME}-${bts.backgroundTaskName()}` 210 ); 211 } else { 212 debuggingProfileDir.append(CHROME_DEBUGGER_PROFILE_NAME); 213 } 214 try { 215 debuggingProfileDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755); 216 } catch (ex) { 217 if (ex.result === Cr.NS_ERROR_FILE_ALREADY_EXISTS) { 218 if (!overwritePreferences) { 219 this.#dbgProfilePath = debuggingProfileDir.path; 220 return; 221 } 222 // Fall through and copy the current set of prefs to the profile. 223 } else { 224 dumpn("Error trying to create a profile directory, failing."); 225 dumpn("Error: " + (ex.message || ex)); 226 return; 227 } 228 } 229 230 this.#dbgProfilePath = debuggingProfileDir.path; 231 232 // We would like to copy prefs into this new profile... 233 const prefsFile = debuggingProfileDir.clone(); 234 prefsFile.append("prefs.js"); 235 236 if (bts?.isBackgroundTaskMode) { 237 // Background tasks run under a temporary profile. In order to set 238 // preferences for the launched browser toolbox, take the preferences from 239 // the default profile. This is the standard pattern for controlling 240 // background task settings. Without this, there'd be no way to increase 241 // logging in the browser toolbox process, etc. 242 const defaultProfile = lazy.BackgroundTasksUtils.getDefaultProfile(); 243 if (!defaultProfile) { 244 throw new Error( 245 "Cannot start Browser Toolbox from background task with no default profile" 246 ); 247 } 248 249 const defaultPrefsFile = defaultProfile.rootDir.clone(); 250 defaultPrefsFile.append("prefs.js"); 251 defaultPrefsFile.copyTo(prefsFile.parent, prefsFile.leafName); 252 253 dumpn( 254 `Copied browser toolbox prefs at '${prefsFile.path}'` + 255 ` from default profiles prefs at '${defaultPrefsFile.path}'` 256 ); 257 } else { 258 // ... but unfortunately, when we run tests, it seems the starting profile 259 // clears out the prefs file before re-writing it, and in practice the 260 // file is empty when we get here. So just copying doesn't work in that 261 // case. 262 // We could force a sync pref flush and then copy it... but if we're doing 263 // that, we might as well just flush directly to the new profile, which 264 // always works: 265 Services.prefs.savePrefFile(prefsFile); 266 } 267 268 dumpn( 269 "Finished creating the chrome toolbox user profile at: " + 270 this.#dbgProfilePath 271 ); 272 } 273 274 /** 275 * Creates and initializes the profile & process for the remote debugger. 276 * 277 * @param {object} options 278 * @param {boolean} options.forceMultiprocess: Set to true to force the Browser Toolbox to be in 279 * multiprocess mode. 280 */ 281 #create({ forceMultiprocess } = {}) { 282 dumpn("Initializing chrome debugging process."); 283 284 let command = Services.dirsvc.get("XREExeF", Ci.nsIFile).path; 285 let profilePath = this.#dbgProfilePath; 286 287 // MOZ_BROWSER_TOOLBOX_BINARY is an absolute file path to a custom firefox binary. 288 // This is especially useful when debugging debug builds which are really slow 289 // so that you could pass an optimized build for the browser toolbox. 290 // This is also useful when debugging a patch that break devtools, 291 // so that you could use a build that works for the browser toolbox. 292 const customBinaryPath = Services.env.get("MOZ_BROWSER_TOOLBOX_BINARY"); 293 if (customBinaryPath) { 294 command = customBinaryPath; 295 profilePath = PathUtils.join(PathUtils.tempDir, "browserToolboxProfile"); 296 } 297 298 dumpn("Running chrome debugging process."); 299 const args = [ 300 "-foreground", 301 "-profile", 302 profilePath, 303 "-chrome", 304 BROWSER_TOOLBOX_WINDOW_URL, 305 ]; 306 307 const environment = { 308 // Allow recording the startup of the browser toolbox when setting 309 // MOZ_BROWSER_TOOLBOX_PROFILER_STARTUP=1 when running firefox. 310 MOZ_PROFILER_STARTUP: Services.env.get( 311 "MOZ_BROWSER_TOOLBOX_PROFILER_STARTUP" 312 ), 313 // And prevent profiling any subsequent toolbox 314 MOZ_BROWSER_TOOLBOX_PROFILER_STARTUP: "0", 315 316 MOZ_BROWSER_TOOLBOX_FORCE_MULTIPROCESS: forceMultiprocess ? "1" : "0", 317 // Disable safe mode for the new process in case this was opened via the 318 // keyboard shortcut. 319 MOZ_DISABLE_SAFE_MODE_KEY: "1", 320 MOZ_BROWSER_TOOLBOX_PORT: String(this.#port), 321 MOZ_HEADLESS: null, 322 // Never enable Marionette for the new process. 323 MOZ_MARIONETTE: null, 324 // Don't inherit debug settings from the process launching us. This can 325 // cause errors when log files collide. 326 MOZ_LOG: null, 327 MOZ_LOG_FILE: null, 328 XPCOM_MEM_BLOAT_LOG: null, 329 XPCOM_MEM_LEAK_LOG: null, 330 XPCOM_MEM_LOG_CLASSES: null, 331 XPCOM_MEM_REFCNT_LOG: null, 332 XRE_PROFILE_PATH: null, 333 XRE_PROFILE_LOCAL_PATH: null, 334 }; 335 336 // During local development, incremental builds can trigger the main process 337 // to clear its startup cache with the "flag file" .purgecaches, but this 338 // file is removed during app startup time, so we aren't able to know if it 339 // was present in order to also clear the child profile's startup cache as 340 // well. 341 // 342 // As an approximation of "isLocalBuild", check for an unofficial build. 343 if (!AppConstants.MOZILLA_OFFICIAL) { 344 args.push("-purgecaches"); 345 } 346 347 dump(`Starting Browser Toolbox ${command} ${args.join(" ")}\n`); 348 IOUtils.makeDirectory(profilePath, { ignoreExisting: true }) 349 .then(() => 350 Subprocess.call({ 351 command, 352 arguments: args, 353 environmentAppend: true, 354 stderr: "stdout", 355 environment, 356 }) 357 ) 358 .then(proc => { 359 this.#dbgProcess = proc; 360 361 this.#telemetry.toolOpened("jsbrowserdebugger", this); 362 363 dumpn("Chrome toolbox is now running..."); 364 this.emit("run", this, proc, this.#dbgProfilePath); 365 366 proc.stdin.close(); 367 const dumpPipe = async pipe => { 368 let leftover = ""; 369 let data = await pipe.readString(); 370 while (data) { 371 data = leftover + data; 372 const lines = data.split(/\r\n|\r|\n/); 373 if (lines.length) { 374 for (const line of lines.slice(0, -1)) { 375 dump(`${proc.pid}> ${line}\n`); 376 } 377 leftover = lines[lines.length - 1]; 378 } 379 data = await pipe.readString(); 380 } 381 if (leftover) { 382 dump(`${proc.pid}> ${leftover}\n`); 383 } 384 }; 385 dumpPipe(proc.stdout); 386 387 proc.wait().then(() => this.close()); 388 389 return proc; 390 }) 391 .catch(err => { 392 console.log( 393 `Error loading Browser Toolbox: ${command} ${args.join(" ")}`, 394 err 395 ); 396 }); 397 } 398 399 /** 400 * Closes the remote debugging server and kills the toolbox process. 401 */ 402 async close() { 403 if (this.#closed) { 404 return; 405 } 406 407 this.#closed = true; 408 409 dumpn("Cleaning up the chrome debugging process."); 410 411 Services.obs.removeObserver(this.close, "quit-application"); 412 413 // We tear down before killing the browser toolbox process to avoid leaking 414 // socket connection objects. 415 if (this.#listener) { 416 this.#listener.close(); 417 } 418 419 // Note that the DevToolsServer can be shared with the DevToolsServer 420 // spawned by DevToolsFrameChild. We shouldn't destroy it from here. 421 // Instead we should let it auto-destroy itself once the last connection is closed. 422 this.#devToolsServer = null; 423 424 this.#dbgProcess.stdout.close(); 425 await this.#dbgProcess.kill(); 426 427 this.#telemetry.toolClosed("jsbrowserdebugger", this); 428 429 dumpn("Chrome toolbox is now closed..."); 430 processes.delete(this); 431 432 this.#dbgProcess = null; 433 if (this.#loader) { 434 releaseDistinctSystemPrincipalLoader(this); 435 } 436 this.#loader = null; 437 this.#telemetry = null; 438 } 439 } 440 441 /** 442 * Helper method for debugging. 443 * 444 * @param string 445 */ 446 function dumpn(str) { 447 if (wantLogging) { 448 dump("DBG-FRONTEND: " + str + "\n"); 449 } 450 } 451 452 var wantLogging = Services.prefs.getBoolPref("devtools.debugger.log"); 453 const prefObserver = { 454 observe: (...args) => { 455 wantLogging = Services.prefs.getBoolPref(args.pop()); 456 }, 457 }; 458 Services.prefs.addObserver("devtools.debugger.log", prefObserver); 459 const unloadObserver = function (subject) { 460 if (subject.wrappedJSObject == require("@loader/unload")) { 461 Services.prefs.removeObserver("devtools.debugger.log", prefObserver); 462 Services.obs.removeObserver(unloadObserver, "devtools:loader:destroy"); 463 } 464 }; 465 Services.obs.addObserver(unloadObserver, "devtools:loader:destroy");