Source code

Revision control

Copy as Markdown

Other Tools

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
// Keep this synchronized with the value of the same name in
// toolkit/xre/nsAppRunner.cpp.
const BROWSER_TOOLBOX_WINDOW_URL =
"chrome://devtools/content/framework/browser-toolbox/window.html";
const CHROME_DEBUGGER_PROFILE_NAME = "chrome_debugger_profile";
import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
import { require } from "resource://devtools/shared/loader/Loader.sys.mjs";
import { Subprocess } from "resource://gre/modules/Subprocess.sys.mjs";
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
const {
useDistinctSystemPrincipalLoader,
releaseDistinctSystemPrincipalLoader,
} = ChromeUtils.importESModule(
"resource://devtools/shared/loader/DistinctSystemPrincipalLoader.sys.mjs",
{ global: "shared" }
);
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
BackgroundTasksUtils: "resource://gre/modules/BackgroundTasksUtils.sys.mjs",
});
XPCOMUtils.defineLazyServiceGetters(lazy, {
XreDirProvider: [
"@mozilla.org/xre/directory-provider;1",
"nsIXREDirProvider",
],
});
const Telemetry = require("resource://devtools/client/shared/telemetry.js");
const EventEmitter = require("resource://devtools/shared/event-emitter.js");
const processes = new Set();
/**
* @typedef {Object} BrowserToolboxLauncherArgs
* @property {function} onRun - A function called when the process starts running.
* @property {boolean} overwritePreferences - Set to force overwriting the toolbox
* profile's preferences with the current set of preferences.
* @property {boolean} forceMultiprocess - Set to force the Browser Toolbox to be in
* multiprocess mode.
*/
export class BrowserToolboxLauncher extends EventEmitter {
/**
* Initializes and starts a chrome toolbox process if the appropriated prefs are enabled
*
* @param {BrowserToolboxLauncherArgs} args
* @return {BrowserToolboxLauncher|null} The created instance, or null if the required prefs
* are not set.
*/
static init(args) {
if (
!Services.prefs.getBoolPref("devtools.chrome.enabled") ||
!Services.prefs.getBoolPref("devtools.debugger.remote-enabled")
) {
console.error("Could not start Browser Toolbox, you need to enable it.");
return null;
}
return new BrowserToolboxLauncher(args);
}
/**
* Figure out if there are any open Browser Toolboxes that'll need to be restored.
* @return {boolean}
*/
static getBrowserToolboxSessionState() {
return processes.size !== 0;
}
#closed;
#devToolsServer;
#dbgProfilePath;
#dbgProcess;
#listener;
#loader;
#port;
#telemetry = new Telemetry();
/**
* Constructor for creating a process that will hold a chrome toolbox.
*
* @param {...BrowserToolboxLauncherArgs} args
*/
constructor({ forceMultiprocess, onRun, overwritePreferences } = {}) {
super();
if (onRun) {
this.once("run", onRun);
}
this.close = this.close.bind(this);
Services.obs.addObserver(this.close, "quit-application");
this.#initServer();
this.#initProfile(overwritePreferences);
this.#create({ forceMultiprocess });
processes.add(this);
}
/**
* Initializes the devtools server.
*/
#initServer() {
if (this.#devToolsServer) {
dumpn("The chrome toolbox server is already running.");
return;
}
dumpn("Initializing the chrome toolbox server.");
// Create a separate loader instance, so that we can be sure to receive a
// separate instance of the DebuggingServer from the rest of the devtools.
// This allows us to safely use the tools against even the actors and
// DebuggingServer itself, especially since we can mark this loader as
// invisible to the debugger (unlike the usual loader settings).
this.#loader = useDistinctSystemPrincipalLoader(this);
const { DevToolsServer } = this.#loader.require(
"resource://devtools/server/devtools-server.js"
);
const { SocketListener } = this.#loader.require(
"resource://devtools/shared/security/socket.js"
);
this.#devToolsServer = DevToolsServer;
dumpn("Created a separate loader instance for the DevToolsServer.");
this.#devToolsServer.init();
// We mainly need a root actor and target actors for opening a toolbox, even
// against chrome/content. But the "no auto hide" button uses the
// preference actor, so also register the browser actors.
this.#devToolsServer.registerAllActors();
this.#devToolsServer.allowChromeProcess = true;
dumpn("initialized and added the browser actors for the DevToolsServer.");
const bts = Cc["@mozilla.org/backgroundtasks;1"]?.getService(
Ci.nsIBackgroundTasks
);
if (bts?.isBackgroundTaskMode) {
// A special root actor, just for background tasks invoked with
// `--backgroundtask TASK --jsdebugger`.
const { createRootActor } = this.#loader.require(
"resource://gre/modules/backgroundtasks/dbg-actors.js"
);
this.#devToolsServer.setRootActor(createRootActor);
}
const chromeDebuggingWebSocket = Services.prefs.getBoolPref(
"devtools.debugger.chrome-debugging-websocket"
);
const socketOptions = {
fromBrowserToolbox: true,
portOrPath: -1,
webSocket: chromeDebuggingWebSocket,
};
const listener = new SocketListener(this.#devToolsServer, socketOptions);
listener.open();
this.#listener = listener;
this.#port = listener.port;
if (!this.#port) {
throw new Error("No devtools server port");
}
dumpn("Finished initializing the chrome toolbox server.");
dump(
`DevTools Server for Browser Toolbox listening on port: ${this.#port}\n`
);
}
/**
* Initializes a profile for the remote debugger process.
*/
#initProfile(overwritePreferences) {
dumpn("Initializing the chrome toolbox user profile.");
const bts = Cc["@mozilla.org/backgroundtasks;1"]?.getService(
Ci.nsIBackgroundTasks
);
let debuggingProfileDir = Services.dirsvc.get("ProfD", Ci.nsIFile);
if (bts?.isBackgroundTaskMode) {
// Background tasks run with a temporary ephemeral profile. We move the
// browser toolbox profile out of that ephemeral profile so that it has
// alonger life then the background task profile. This preserves
// breakpoints, etc, across repeated debugging invocations. This
// directory is close to the background task temporary profile name(s),
// but doesn't match the prefix that will get purged by the stale
// ephemeral profile cleanup mechanism.
//
// For example, the invocation
// `firefox --backgroundtask success --jsdebugger --wait-for-jsdebugger`
// might run with ephemeral profile
// `/tmp/MozillaBackgroundTask-<HASH>-success`
// and sibling directory browser toolbox profile
// `/tmp/MozillaBackgroundTask-<HASH>-chrome_debugger_profile-success`
//
// See `BackgroundTasks::Shutdown` for ephemeral profile cleanup details.
debuggingProfileDir = debuggingProfileDir.parent;
debuggingProfileDir.append(
`${Services.appinfo.vendor}BackgroundTask-` +
`${lazy.XreDirProvider.getInstallHash()}-${CHROME_DEBUGGER_PROFILE_NAME}-${bts.backgroundTaskName()}`
);
} else {
debuggingProfileDir.append(CHROME_DEBUGGER_PROFILE_NAME);
}
try {
debuggingProfileDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755);
} catch (ex) {
if (ex.result === Cr.NS_ERROR_FILE_ALREADY_EXISTS) {
if (!overwritePreferences) {
this.#dbgProfilePath = debuggingProfileDir.path;
return;
}
// Fall through and copy the current set of prefs to the profile.
} else {
dumpn("Error trying to create a profile directory, failing.");
dumpn("Error: " + (ex.message || ex));
return;
}
}
this.#dbgProfilePath = debuggingProfileDir.path;
// We would like to copy prefs into this new profile...
const prefsFile = debuggingProfileDir.clone();
prefsFile.append("prefs.js");
if (bts?.isBackgroundTaskMode) {
// Background tasks run under a temporary profile. In order to set
// preferences for the launched browser toolbox, take the preferences from
// the default profile. This is the standard pattern for controlling
// background task settings. Without this, there'd be no way to increase
// logging in the browser toolbox process, etc.
const defaultProfile = lazy.BackgroundTasksUtils.getDefaultProfile();
if (!defaultProfile) {
throw new Error(
"Cannot start Browser Toolbox from background task with no default profile"
);
}
const defaultPrefsFile = defaultProfile.rootDir.clone();
defaultPrefsFile.append("prefs.js");
defaultPrefsFile.copyTo(prefsFile.parent, prefsFile.leafName);
dumpn(
`Copied browser toolbox prefs at '${prefsFile.path}'` +
` from default profiles prefs at '${defaultPrefsFile.path}'`
);
} else {
// ... but unfortunately, when we run tests, it seems the starting profile
// clears out the prefs file before re-writing it, and in practice the
// file is empty when we get here. So just copying doesn't work in that
// case.
// We could force a sync pref flush and then copy it... but if we're doing
// that, we might as well just flush directly to the new profile, which
// always works:
Services.prefs.savePrefFile(prefsFile);
}
dumpn(
"Finished creating the chrome toolbox user profile at: " +
this.#dbgProfilePath
);
}
/**
* Creates and initializes the profile & process for the remote debugger.
*
* @param {Object} options
* @param {boolean} options.forceMultiprocess: Set to true to force the Browser Toolbox to be in
* multiprocess mode.
*/
#create({ forceMultiprocess } = {}) {
dumpn("Initializing chrome debugging process.");
let command = Services.dirsvc.get("XREExeF", Ci.nsIFile).path;
let profilePath = this.#dbgProfilePath;
// MOZ_BROWSER_TOOLBOX_BINARY is an absolute file path to a custom firefox binary.
// This is especially useful when debugging debug builds which are really slow
// so that you could pass an optimized build for the browser toolbox.
// This is also useful when debugging a patch that break devtools,
// so that you could use a build that works for the browser toolbox.
const customBinaryPath = Services.env.get("MOZ_BROWSER_TOOLBOX_BINARY");
if (customBinaryPath) {
command = customBinaryPath;
profilePath = PathUtils.join(PathUtils.tempDir, "browserToolboxProfile");
}
dumpn("Running chrome debugging process.");
const args = [
"-foreground",
"-profile",
profilePath,
"-chrome",
BROWSER_TOOLBOX_WINDOW_URL,
];
const isInputContextEnabled = Services.prefs.getBoolPref(
"devtools.webconsole.input.context",
false
);
const environment = {
// Allow recording the startup of the browser toolbox when setting
// MOZ_BROWSER_TOOLBOX_PROFILER_STARTUP=1 when running firefox.
MOZ_PROFILER_STARTUP: Services.env.get(
"MOZ_BROWSER_TOOLBOX_PROFILER_STARTUP"
),
// And prevent profiling any subsequent toolbox
MOZ_BROWSER_TOOLBOX_PROFILER_STARTUP: "0",
MOZ_BROWSER_TOOLBOX_FORCE_MULTIPROCESS: forceMultiprocess ? "1" : "0",
// Similar, but for the WebConsole input context dropdown.
MOZ_BROWSER_TOOLBOX_INPUT_CONTEXT: isInputContextEnabled ? "1" : "0",
// Disable safe mode for the new process in case this was opened via the
// keyboard shortcut.
MOZ_DISABLE_SAFE_MODE_KEY: "1",
MOZ_BROWSER_TOOLBOX_PORT: String(this.#port),
MOZ_HEADLESS: null,
// Never enable Marionette for the new process.
MOZ_MARIONETTE: null,
// Don't inherit debug settings from the process launching us. This can
// cause errors when log files collide.
MOZ_LOG: null,
MOZ_LOG_FILE: null,
XPCOM_MEM_BLOAT_LOG: null,
XPCOM_MEM_LEAK_LOG: null,
XPCOM_MEM_LOG_CLASSES: null,
XPCOM_MEM_REFCNT_LOG: null,
XRE_PROFILE_PATH: null,
XRE_PROFILE_LOCAL_PATH: null,
};
// During local development, incremental builds can trigger the main process
// to clear its startup cache with the "flag file" .purgecaches, but this
// file is removed during app startup time, so we aren't able to know if it
// was present in order to also clear the child profile's startup cache as
// well.
//
// As an approximation of "isLocalBuild", check for an unofficial build.
if (!AppConstants.MOZILLA_OFFICIAL) {
args.push("-purgecaches");
}
dump(`Starting Browser Toolbox ${command} ${args.join(" ")}\n`);
IOUtils.makeDirectory(profilePath, { ignoreExisting: true })
.then(() =>
Subprocess.call({
command,
arguments: args,
environmentAppend: true,
stderr: "stdout",
environment,
})
)
.then(proc => {
this.#dbgProcess = proc;
this.#telemetry.toolOpened("jsbrowserdebugger", this);
dumpn("Chrome toolbox is now running...");
this.emit("run", this, proc, this.#dbgProfilePath);
proc.stdin.close();
const dumpPipe = async pipe => {
let leftover = "";
let data = await pipe.readString();
while (data) {
data = leftover + data;
const lines = data.split(/\r\n|\r|\n/);
if (lines.length) {
for (const line of lines.slice(0, -1)) {
dump(`${proc.pid}> ${line}\n`);
}
leftover = lines[lines.length - 1];
}
data = await pipe.readString();
}
if (leftover) {
dump(`${proc.pid}> ${leftover}\n`);
}
};
dumpPipe(proc.stdout);
proc.wait().then(() => this.close());
return proc;
})
.catch(err => {
console.log(
`Error loading Browser Toolbox: ${command} ${args.join(" ")}`,
err
);
});
}
/**
* Closes the remote debugging server and kills the toolbox process.
*/
async close() {
if (this.#closed) {
return;
}
this.#closed = true;
dumpn("Cleaning up the chrome debugging process.");
Services.obs.removeObserver(this.close, "quit-application");
// We tear down before killing the browser toolbox process to avoid leaking
// socket connection objects.
if (this.#listener) {
this.#listener.close();
}
// Note that the DevToolsServer can be shared with the DevToolsServer
// spawned by DevToolsFrameChild. We shouldn't destroy it from here.
// Instead we should let it auto-destroy itself once the last connection is closed.
this.#devToolsServer = null;
this.#dbgProcess.stdout.close();
await this.#dbgProcess.kill();
this.#telemetry.toolClosed("jsbrowserdebugger", this);
dumpn("Chrome toolbox is now closed...");
processes.delete(this);
this.#dbgProcess = null;
if (this.#loader) {
releaseDistinctSystemPrincipalLoader(this);
}
this.#loader = null;
this.#telemetry = null;
}
}
/**
* Helper method for debugging.
* @param string
*/
function dumpn(str) {
if (wantLogging) {
dump("DBG-FRONTEND: " + str + "\n");
}
}
var wantLogging = Services.prefs.getBoolPref("devtools.debugger.log");
const prefObserver = {
observe: (...args) => {
wantLogging = Services.prefs.getBoolPref(args.pop());
},
};
Services.prefs.addObserver("devtools.debugger.log", prefObserver);
const unloadObserver = function (subject) {
if (subject.wrappedJSObject == require("@loader/unload")) {
Services.prefs.removeObserver("devtools.debugger.log", prefObserver);
Services.obs.removeObserver(unloadObserver, "devtools:loader:destroy");
}
};
Services.obs.addObserver(unloadObserver, "devtools:loader:destroy");