diff -r 000000000000 -r 6474c204b198 browser/experiments/Experiments.jsm --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/browser/experiments/Experiments.jsm Wed Dec 31 06:09:35 2014 +0100 @@ -0,0 +1,2369 @@ +/* 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"; + +this.EXPORTED_SYMBOLS = [ + "Experiments", + "ExperimentsProvider", +]; + +const {classes: Cc, interfaces: Ci, utils: Cu} = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/Promise.jsm"); +Cu.import("resource://gre/modules/osfile.jsm"); +Cu.import("resource://gre/modules/Log.jsm"); +Cu.import("resource://gre/modules/Preferences.jsm"); +Cu.import("resource://gre/modules/AsyncShutdown.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "UpdateChannel", + "resource://gre/modules/UpdateChannel.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "AddonManager", + "resource://gre/modules/AddonManager.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "AddonManagerPrivate", + "resource://gre/modules/AddonManager.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "TelemetryPing", + "resource://gre/modules/TelemetryPing.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "TelemetryLog", + "resource://gre/modules/TelemetryLog.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "CommonUtils", + "resource://services-common/utils.js"); +XPCOMUtils.defineLazyModuleGetter(this, "Metrics", + "resource://gre/modules/Metrics.jsm"); + +// CertUtils.jsm doesn't expose a single "CertUtils" object like a normal .jsm +// would. +XPCOMUtils.defineLazyGetter(this, "CertUtils", + function() { + var mod = {}; + Cu.import("resource://gre/modules/CertUtils.jsm", mod); + return mod; + }); + +XPCOMUtils.defineLazyServiceGetter(this, "gCrashReporter", + "@mozilla.org/xre/app-info;1", + "nsICrashReporter"); + +const FILE_CACHE = "experiments.json"; +const EXPERIMENTS_CHANGED_TOPIC = "experiments-changed"; +const MANIFEST_VERSION = 1; +const CACHE_VERSION = 1; + +const KEEP_HISTORY_N_DAYS = 180; +const MIN_EXPERIMENT_ACTIVE_SECONDS = 60; + +const PREF_BRANCH = "experiments."; +const PREF_ENABLED = "enabled"; // experiments.enabled +const PREF_ACTIVE_EXPERIMENT = "activeExperiment"; // whether we have an active experiment +const PREF_LOGGING = "logging"; +const PREF_LOGGING_LEVEL = PREF_LOGGING + ".level"; // experiments.logging.level +const PREF_LOGGING_DUMP = PREF_LOGGING + ".dump"; // experiments.logging.dump +const PREF_MANIFEST_URI = "manifest.uri"; // experiments.logging.manifest.uri +const PREF_MANIFEST_CHECKCERT = "manifest.cert.checkAttributes"; // experiments.manifest.cert.checkAttributes +const PREF_MANIFEST_REQUIREBUILTIN = "manifest.cert.requireBuiltin"; // experiments.manifest.cert.requireBuiltin +const PREF_FORCE_SAMPLE = "force-sample-value"; // experiments.force-sample-value + +const PREF_HEALTHREPORT_ENABLED = "datareporting.healthreport.service.enabled"; + +const PREF_BRANCH_TELEMETRY = "toolkit.telemetry."; +const PREF_TELEMETRY_ENABLED = "enabled"; + +const URI_EXTENSION_STRINGS = "chrome://mozapps/locale/extensions/extensions.properties"; +const STRING_TYPE_NAME = "type.%ID%.name"; + +const TELEMETRY_LOG = { + // log(key, [kind, experimentId, details]) + ACTIVATION_KEY: "EXPERIMENT_ACTIVATION", + ACTIVATION: { + // Successfully activated. + ACTIVATED: "ACTIVATED", + // Failed to install the add-on. + INSTALL_FAILURE: "INSTALL_FAILURE", + // Experiment does not meet activation requirements. Details will + // be provided. + REJECTED: "REJECTED", + }, + + // log(key, [kind, experimentId, optionalDetails...]) + TERMINATION_KEY: "EXPERIMENT_TERMINATION", + TERMINATION: { + // The Experiments service was disabled. + SERVICE_DISABLED: "SERVICE_DISABLED", + // Add-on uninstalled. + ADDON_UNINSTALLED: "ADDON_UNINSTALLED", + // The experiment disabled itself. + FROM_API: "FROM_API", + // The experiment expired (e.g. by exceeding the end date). + EXPIRED: "EXPIRED", + // Disabled after re-evaluating conditions. If this is specified, + // details will be provided. + RECHECK: "RECHECK", + }, +}; + +const gPrefs = new Preferences(PREF_BRANCH); +const gPrefsTelemetry = new Preferences(PREF_BRANCH_TELEMETRY); +let gExperimentsEnabled = false; +let gAddonProvider = null; +let gExperiments = null; +let gLogAppenderDump = null; +let gPolicyCounter = 0; +let gExperimentsCounter = 0; +let gExperimentEntryCounter = 0; +let gPreviousProviderCounter = 0; + +// Tracks active AddonInstall we know about so we can deny external +// installs. +let gActiveInstallURLs = new Set(); + +// Tracks add-on IDs that are being uninstalled by us. This allows us +// to differentiate between expected uninstalled and user-driven uninstalls. +let gActiveUninstallAddonIDs = new Set(); + +let gLogger; +let gLogDumping = false; + +function configureLogging() { + if (!gLogger) { + gLogger = Log.repository.getLogger("Browser.Experiments"); + gLogger.addAppender(new Log.ConsoleAppender(new Log.BasicFormatter())); + } + gLogger.level = gPrefs.get(PREF_LOGGING_LEVEL, Log.Level.Warn); + + let logDumping = gPrefs.get(PREF_LOGGING_DUMP, false); + if (logDumping != gLogDumping) { + if (logDumping) { + gLogAppenderDump = new Log.DumpAppender(new Log.BasicFormatter()); + gLogger.addAppender(gLogAppenderDump); + } else { + gLogger.removeAppender(gLogAppenderDump); + gLogAppenderDump = null; + } + gLogDumping = logDumping; + } +} + +// Takes an array of promises and returns a promise that is resolved once all of +// them are rejected or resolved. +function allResolvedOrRejected(promises) { + if (!promises.length) { + return Promise.resolve([]); + } + + let countdown = promises.length; + let deferred = Promise.defer(); + + for (let p of promises) { + let helper = () => { + if (--countdown == 0) { + deferred.resolve(); + } + }; + Promise.resolve(p).then(helper, helper); + } + + return deferred.promise; +} + +// Loads a JSON file using OS.file. file is a string representing the path +// of the file to be read, options contains additional options to pass to +// OS.File.read. +// Returns a Promise resolved with the json payload or rejected with +// OS.File.Error or JSON.parse() errors. +function loadJSONAsync(file, options) { + return Task.spawn(function() { + let rawData = yield OS.File.read(file, options); + // Read json file into a string + let data; + try { + // Obtain a converter to read from a UTF-8 encoded input stream. + let converter = new TextDecoder(); + data = JSON.parse(converter.decode(rawData)); + } catch (ex) { + gLogger.error("Experiments: Could not parse JSON: " + file + " " + ex); + throw ex; + } + throw new Task.Result(data); + }); +} + +function telemetryEnabled() { + return gPrefsTelemetry.get(PREF_TELEMETRY_ENABLED, false); +} + +// Returns a promise that is resolved with the AddonInstall for that URL. +function addonInstallForURL(url, hash) { + let deferred = Promise.defer(); + AddonManager.getInstallForURL(url, install => deferred.resolve(install), + "application/x-xpinstall", hash); + return deferred.promise; +} + +// Returns a promise that is resolved with an Array of the installed +// experiment addons. +function installedExperimentAddons() { + let deferred = Promise.defer(); + AddonManager.getAddonsByTypes(["experiment"], (addons) => { + deferred.resolve([a for (a of addons) if (!a.appDisabled)]); + }); + return deferred.promise; +} + +// Takes an Array and returns a promise that is resolved when the +// addons are uninstalled. +function uninstallAddons(addons) { + let ids = new Set([a.id for (a of addons)]); + let deferred = Promise.defer(); + + let listener = {}; + listener.onUninstalled = addon => { + if (!ids.has(addon.id)) { + return; + } + + ids.delete(addon.id); + if (ids.size == 0) { + AddonManager.removeAddonListener(listener); + deferred.resolve(); + } + }; + + AddonManager.addAddonListener(listener); + + for (let addon of addons) { + // Disabling the add-on before uninstalling is necessary to cause tests to + // pass. This might be indicative of a bug in XPIProvider. + // TODO follow up in bug 992396. + addon.userDisabled = true; + addon.uninstall(); + } + + return deferred.promise; +} + +/** + * The experiments module. + */ + +let Experiments = { + /** + * Provides access to the global `Experiments.Experiments` instance. + */ + instance: function () { + if (!gExperiments) { + gExperiments = new Experiments.Experiments(); + } + + return gExperiments; + }, +}; + +/* + * The policy object allows us to inject fake enviroment data from the + * outside by monkey-patching. + */ + +Experiments.Policy = function () { + this._log = Log.repository.getLoggerWithMessagePrefix( + "Browser.Experiments.Policy", + "Policy #" + gPolicyCounter++ + "::"); + + // Set to true to ignore hash verification on downloaded XPIs. This should + // not be used outside of testing. + this.ignoreHashes = false; +}; + +Experiments.Policy.prototype = { + now: function () { + return new Date(); + }, + + random: function () { + let pref = gPrefs.get(PREF_FORCE_SAMPLE); + if (pref !== undefined) { + let val = Number.parseFloat(pref); + this._log.debug("random sample forced: " + val); + if (isNaN(val) || val < 0) { + return 0; + } + if (val > 1) { + return 1; + } + return val; + } + return Math.random(); + }, + + futureDate: function (offset) { + return new Date(this.now().getTime() + offset); + }, + + oneshotTimer: function (callback, timeout, thisObj, name) { + return CommonUtils.namedTimer(callback, timeout, thisObj, name); + }, + + updatechannel: function () { + return UpdateChannel.get(); + }, + + locale: function () { + let chrome = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(Ci.nsIXULChromeRegistry); + return chrome.getSelectedLocale("global"); + }, + + /* + * @return Promise<> Resolved with the payload data. + */ + healthReportPayload: function () { + return Task.spawn(function*() { + let reporter = Cc["@mozilla.org/datareporting/service;1"] + .getService(Ci.nsISupports) + .wrappedJSObject + .healthReporter; + yield reporter.onInit(); + let payload = yield reporter.collectAndObtainJSONPayload(); + throw new Task.Result(payload); + }); + }, + + telemetryPayload: function () { + return TelemetryPing.getPayload(); + }, +}; + +function AlreadyShutdownError(message="already shut down") { + this.name = "AlreadyShutdownError"; + this.message = message; +} + +AlreadyShutdownError.prototype = new Error(); +AlreadyShutdownError.prototype.constructor = AlreadyShutdownError; + +/** + * Manages the experiments and provides an interface to control them. + */ + +Experiments.Experiments = function (policy=new Experiments.Policy()) { + this._log = Log.repository.getLoggerWithMessagePrefix( + "Browser.Experiments.Experiments", + "Experiments #" + gExperimentsCounter++ + "::"); + this._log.trace("constructor"); + + this._policy = policy; + + // This is a Map of (string -> ExperimentEntry), keyed with the experiment id. + // It holds both the current experiments and history. + // Map() preserves insertion order, which means we preserve the manifest order. + // This is null until we've successfully completed loading the cache from + // disk the first time. + this._experiments = null; + this._refresh = false; + this._terminateReason = null; // or TELEMETRY_LOG.TERMINATION.... + this._dirty = false; + + // Loading the cache happens once asynchronously on startup + this._loadTask = null; + + // The _main task handles all other actions: + // * refreshing the manifest off the network (if _refresh) + // * disabling/enabling experiments + // * saving the cache (if _dirty) + this._mainTask = null; + + // Timer for re-evaluating experiment status. + this._timer = null; + + this._shutdown = false; + + // We need to tell when we first evaluated the experiments to fire an + // experiments-changed notification when we only loaded completed experiments. + this._firstEvaluate = true; + + this.init(); +}; + +Experiments.Experiments.prototype = { + QueryInterface: XPCOMUtils.generateQI([Ci.nsITimerCallback, Ci.nsIObserver]), + + init: function () { + this._shutdown = false; + configureLogging(); + + gExperimentsEnabled = gPrefs.get(PREF_ENABLED, false); + this._log.trace("enabled=" + gExperimentsEnabled + ", " + this.enabled); + + gPrefs.observe(PREF_LOGGING, configureLogging); + gPrefs.observe(PREF_MANIFEST_URI, this.updateManifest, this); + gPrefs.observe(PREF_ENABLED, this._toggleExperimentsEnabled, this); + + gPrefsTelemetry.observe(PREF_TELEMETRY_ENABLED, this._telemetryStatusChanged, this); + + AsyncShutdown.profileBeforeChange.addBlocker("Experiments.jsm shutdown", + this.uninit.bind(this)); + + this._registerWithAddonManager(); + + let deferred = Promise.defer(); + + this._loadTask = this._loadFromCache(); + this._loadTask.then( + () => { + this._log.trace("_loadTask finished ok"); + this._loadTask = null; + this._run().then(deferred.resolve, deferred.reject); + }, + (e) => { + this._log.error("_loadFromCache caught error: " + e); + deferred.reject(e); + } + ); + + return deferred.promise; + }, + + /** + * Uninitialize this instance. + * + * This function is susceptible to race conditions. If it is called multiple + * times before the previous uninit() has completed or if it is called while + * an init() operation is being performed, the object may get in bad state + * and/or deadlock could occur. + * + * @return Promise<> + * The promise is fulfilled when all pending tasks are finished. + */ + uninit: Task.async(function* () { + this._log.trace("uninit: started"); + yield this._loadTask; + this._log.trace("uninit: finished with _loadTask"); + + if (!this._shutdown) { + this._log.trace("uninit: no previous shutdown"); + this._unregisterWithAddonManager(); + + gPrefs.ignore(PREF_LOGGING, configureLogging); + gPrefs.ignore(PREF_MANIFEST_URI, this.updateManifest, this); + gPrefs.ignore(PREF_ENABLED, this._toggleExperimentsEnabled, this); + + gPrefsTelemetry.ignore(PREF_TELEMETRY_ENABLED, this._telemetryStatusChanged, this); + + if (this._timer) { + this._timer.clear(); + } + } + + this._shutdown = true; + if (this._mainTask) { + try { + this._log.trace("uninit: waiting on _mainTask"); + yield this._mainTask; + } catch (e if e instanceof AlreadyShutdownError) { + // We error out of tasks after shutdown via that exception. + } + } + + this._log.info("Completed uninitialization."); + }), + + _registerWithAddonManager: function (previousExperimentsProvider) { + this._log.trace("Registering instance with Addon Manager."); + + AddonManager.addAddonListener(this); + AddonManager.addInstallListener(this); + + if (!gAddonProvider) { + // The properties of this AddonType should be kept in sync with the + // experiment AddonType registered in XPIProvider. + this._log.trace("Registering previous experiment add-on provider."); + gAddonProvider = previousExperimentsProvider || new Experiments.PreviousExperimentProvider(this); + AddonManagerPrivate.registerProvider(gAddonProvider, [ + new AddonManagerPrivate.AddonType("experiment", + URI_EXTENSION_STRINGS, + STRING_TYPE_NAME, + AddonManager.VIEW_TYPE_LIST, + 11000, + AddonManager.TYPE_UI_HIDE_EMPTY), + ]); + } + + }, + + _unregisterWithAddonManager: function () { + this._log.trace("Unregistering instance with Addon Manager."); + + if (gAddonProvider) { + this._log.trace("Unregistering previous experiment add-on provider."); + AddonManagerPrivate.unregisterProvider(gAddonProvider); + gAddonProvider = null; + } + + AddonManager.removeInstallListener(this); + AddonManager.removeAddonListener(this); + }, + + /* + * Change the PreviousExperimentsProvider that this instance uses. + * For testing only. + */ + _setPreviousExperimentsProvider: function (provider) { + this._unregisterWithAddonManager(); + this._registerWithAddonManager(provider); + }, + + /** + * Throws an exception if we've already shut down. + */ + _checkForShutdown: function() { + if (this._shutdown) { + throw new AlreadyShutdownError("uninit() already called"); + } + }, + + /** + * Whether the experiments feature is enabled. + */ + get enabled() { + return gExperimentsEnabled; + }, + + /** + * Toggle whether the experiments feature is enabled or not. + */ + set enabled(enabled) { + this._log.trace("set enabled(" + enabled + ")"); + gPrefs.set(PREF_ENABLED, enabled); + }, + + _toggleExperimentsEnabled: Task.async(function* (enabled) { + this._log.trace("_toggleExperimentsEnabled(" + enabled + ")"); + let wasEnabled = gExperimentsEnabled; + gExperimentsEnabled = enabled && telemetryEnabled(); + + if (wasEnabled == gExperimentsEnabled) { + return; + } + + if (gExperimentsEnabled) { + yield this.updateManifest(); + } else { + yield this.disableExperiment(TELEMETRY_LOG.TERMINATION.SERVICE_DISABLED); + if (this._timer) { + this._timer.clear(); + } + } + }), + + _telemetryStatusChanged: function () { + this._toggleExperimentsEnabled(gExperimentsEnabled); + }, + + /** + * Returns a promise that is resolved with an array of `ExperimentInfo` objects, + * which provide info on the currently and recently active experiments. + * The array is in chronological order. + * + * The experiment info is of the form: + * { + * id: , + * name: , + * description: , + * active: , + * endDate: , // epoch ms + * detailURL: , + * ... // possibly extended later + * } + * + * @return Promise> Array of experiment info objects. + */ + getExperiments: function () { + return Task.spawn(function*() { + yield this._loadTask; + let list = []; + + for (let [id, experiment] of this._experiments) { + if (!experiment.startDate) { + // We only collect experiments that are or were active. + continue; + } + + list.push({ + id: id, + name: experiment._name, + description: experiment._description, + active: experiment.enabled, + endDate: experiment.endDate.getTime(), + detailURL: experiment._homepageURL, + branch: experiment.branch, + }); + } + + // Sort chronologically, descending. + list.sort((a, b) => b.endDate - a.endDate); + return list; + }.bind(this)); + }, + + /** + * Returns the ExperimentInfo for the active experiment, or null + * if there is none. + */ + getActiveExperiment: function () { + let experiment = this._getActiveExperiment(); + if (!experiment) { + return null; + } + + let info = { + id: experiment.id, + name: experiment._name, + description: experiment._description, + active: experiment.enabled, + endDate: experiment.endDate.getTime(), + detailURL: experiment._homepageURL, + }; + + return info; + }, + + /** + * Experiment "branch" support. If an experiment has multiple branches, it + * can record the branch with the experiment system and it will + * automatically be included in data reporting (FHR/telemetry payloads). + */ + + /** + * Set the experiment branch for the specified experiment ID. + * @returns Promise<> + */ + setExperimentBranch: Task.async(function*(id, branchstr) { + yield this._loadTask; + let e = this._experiments.get(id); + if (!e) { + throw new Error("Experiment not found"); + } + e.branch = String(branchstr); + this._dirty = true; + Services.obs.notifyObservers(null, EXPERIMENTS_CHANGED_TOPIC, null); + yield this._run(); + }), + /** + * Get the branch of the specified experiment. If the experiment is unknown, + * throws an error. + * + * @param id The ID of the experiment. Pass null for the currently running + * experiment. + * @returns Promise + * @throws Error if the specified experiment ID is unknown, or if there is no + * current experiment. + */ + getExperimentBranch: Task.async(function*(id=null) { + yield this._loadTask; + let e; + if (id) { + e = this._experiments.get(id); + if (!e) { + throw new Error("Experiment not found"); + } + } else { + e = this._getActiveExperiment(); + if (e === null) { + throw new Error("No active experiment"); + } + } + return e.branch; + }), + + /** + * Determine whether another date has the same UTC day as now(). + */ + _dateIsTodayUTC: function (d) { + let now = this._policy.now(); + + return stripDateToMidnight(now).getTime() == stripDateToMidnight(d).getTime(); + }, + + /** + * Obtain the entry of the most recent active experiment that was active + * today. + * + * If no experiment was active today, this resolves to nothing. + * + * Assumption: Only a single experiment can be active at a time. + * + * @return Promise + */ + lastActiveToday: function () { + return Task.spawn(function* getMostRecentActiveExperimentTask() { + let experiments = yield this.getExperiments(); + + // Assumption: Ordered chronologically, descending, with active always + // first. + for (let experiment of experiments) { + if (experiment.active) { + return experiment; + } + + if (experiment.endDate && this._dateIsTodayUTC(experiment.endDate)) { + return experiment; + } + } + return null; + }.bind(this)); + }, + + _run: function() { + this._log.trace("_run"); + this._checkForShutdown(); + if (!this._mainTask) { + this._mainTask = Task.spawn(this._main.bind(this)); + this._mainTask.then( + () => { + this._log.trace("_main finished, scheduling next run"); + this._mainTask = null; + this._scheduleNextRun(); + }, + (e) => { + this._log.error("_main caught error: " + e); + this._mainTask = null; + } + ); + } + return this._mainTask; + }, + + _main: function*() { + do { + this._log.trace("_main iteration"); + yield this._loadTask; + if (!gExperimentsEnabled) { + this._refresh = false; + } + + if (this._refresh) { + yield this._loadManifest(); + } + yield this._evaluateExperiments(); + if (this._dirty) { + yield this._saveToCache(); + } + // If somebody called .updateManifest() or disableExperiment() + // while we were running, go again right now. + } + while (this._refresh || this._terminateReason); + }, + + _loadManifest: function*() { + this._log.trace("_loadManifest"); + let uri = Services.urlFormatter.formatURLPref(PREF_BRANCH + PREF_MANIFEST_URI); + + this._checkForShutdown(); + + this._refresh = false; + try { + let responseText = yield this._httpGetRequest(uri); + this._log.trace("_loadManifest() - responseText=\"" + responseText + "\""); + + if (this._shutdown) { + return; + } + + let data = JSON.parse(responseText); + this._updateExperiments(data); + } catch (e) { + this._log.error("_loadManifest - failure to fetch/parse manifest (continuing anyway): " + e); + } + }, + + /** + * Fetch an updated list of experiments and trigger experiment updates. + * Do only use when experiments are enabled. + * + * @return Promise<> + * The promise is resolved when the manifest and experiment list is updated. + */ + updateManifest: function () { + this._log.trace("updateManifest()"); + + if (!gExperimentsEnabled) { + return Promise.reject(new Error("experiments are disabled")); + } + + if (this._shutdown) { + return Promise.reject(Error("uninit() alrady called")); + } + + this._refresh = true; + return this._run(); + }, + + notify: function (timer) { + this._log.trace("notify()"); + this._checkForShutdown(); + return this._run(); + }, + + // START OF ADD-ON LISTENERS + + onUninstalled: function (addon) { + this._log.trace("onUninstalled() - addon id: " + addon.id); + if (gActiveUninstallAddonIDs.has(addon.id)) { + this._log.trace("matches pending uninstall"); + return; + } + let activeExperiment = this._getActiveExperiment(); + if (!activeExperiment || activeExperiment._addonId != addon.id) { + return; + } + + this.disableExperiment(TELEMETRY_LOG.TERMINATION.ADDON_UNINSTALLED); + }, + + onInstallStarted: function (install) { + if (install.addon.type != "experiment") { + return; + } + + this._log.trace("onInstallStarted() - " + install.addon.id); + if (install.addon.appDisabled) { + // This is a PreviousExperiment + return; + } + + // We want to be in control of all experiment add-ons: reject installs + // for add-ons that we don't know about. + + // We have a race condition of sorts to worry about here. We have 2 + // onInstallStarted listeners. This one (the global one) and the one + // created as part of ExperimentEntry._installAddon. Because of the order + // they are registered in, this one likely executes first. Unfortunately, + // this means that the add-on ID is not yet set on the ExperimentEntry. + // So, we can't just look at this._trackedAddonIds because the new experiment + // will have its add-on ID set to null. We work around this by storing a + // identifying field - the source URL of the install - in a module-level + // variable (so multiple Experiments instances doesn't cancel each other + // out). + + if (this._trackedAddonIds.has(install.addon.id)) { + this._log.info("onInstallStarted allowing install because add-on ID " + + "tracked by us."); + return; + } + + if (gActiveInstallURLs.has(install.sourceURI.spec)) { + this._log.info("onInstallStarted allowing install because install " + + "tracked by us."); + return; + } + + this._log.warn("onInstallStarted cancelling install of unknown " + + "experiment add-on: " + install.addon.id); + return false; + }, + + // END OF ADD-ON LISTENERS. + + _getExperimentByAddonId: function (addonId) { + for (let [, entry] of this._experiments) { + if (entry._addonId === addonId) { + return entry; + } + } + + return null; + }, + + /* + * Helper function to make HTTP GET requests. Returns a promise that is resolved with + * the responseText when the request is complete. + */ + _httpGetRequest: function (url) { + this._log.trace("httpGetRequest(" + url + ")"); + let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].createInstance(Ci.nsIXMLHttpRequest); + try { + xhr.open("GET", url); + } catch (e) { + this._log.error("httpGetRequest() - Error opening request to " + url + ": " + e); + return Promise.reject(new Error("Experiments - Error opening XHR for " + url)); + } + + let deferred = Promise.defer(); + + let log = this._log; + xhr.onerror = function (e) { + log.error("httpGetRequest::onError() - Error making request to " + url + ": " + e.error); + deferred.reject(new Error("Experiments - XHR error for " + url + " - " + e.error)); + }; + + xhr.onload = function (event) { + if (xhr.status !== 200 && xhr.state !== 0) { + log.error("httpGetRequest::onLoad() - Request to " + url + " returned status " + xhr.status); + deferred.reject(new Error("Experiments - XHR status for " + url + " is " + xhr.status)); + return; + } + + let certs = null; + if (gPrefs.get(PREF_MANIFEST_CHECKCERT, true)) { + certs = CertUtils.readCertPrefs(PREF_BRANCH + "manifest.certs."); + } + try { + let allowNonBuiltin = !gPrefs.get(PREF_MANIFEST_REQUIREBUILTIN, true); + CertUtils.checkCert(xhr.channel, allowNonBuiltin, certs); + } + catch (e) { + log.error("manifest fetch failed certificate checks", [e]); + deferred.reject(new Error("Experiments - manifest fetch failed certificate checks: " + e)); + return; + } + + deferred.resolve(xhr.responseText); + }; + + if (xhr.channel instanceof Ci.nsISupportsPriority) { + xhr.channel.priority = Ci.nsISupportsPriority.PRIORITY_LOWEST; + } + + xhr.send(null); + return deferred.promise; + }, + + /* + * Path of the cache file we use in the profile. + */ + get _cacheFilePath() { + return OS.Path.join(OS.Constants.Path.profileDir, FILE_CACHE); + }, + + /* + * Part of the main task to save the cache to disk, called from _main. + */ + _saveToCache: function* () { + this._log.trace("_saveToCache"); + let path = this._cacheFilePath; + let textData = JSON.stringify({ + version: CACHE_VERSION, + data: [e[1].toJSON() for (e of this._experiments.entries())], + }); + + let encoder = new TextEncoder(); + let data = encoder.encode(textData); + let options = { tmpPath: path + ".tmp", compression: "lz4" }; + yield OS.File.writeAtomic(path, data, options); + this._dirty = false; + this._log.debug("_saveToCache saved to " + path); + }, + + /* + * Task function, load the cached experiments manifest file from disk. + */ + _loadFromCache: Task.async(function* () { + this._log.trace("_loadFromCache"); + let path = this._cacheFilePath; + try { + let result = yield loadJSONAsync(path, { compression: "lz4" }); + this._populateFromCache(result); + } catch (e if e instanceof OS.File.Error && e.becauseNoSuchFile) { + // No cached manifest yet. + this._experiments = new Map(); + } + }), + + _populateFromCache: function (data) { + this._log.trace("populateFromCache() - data: " + JSON.stringify(data)); + + // If the user has a newer cache version than we can understand, we fail + // hard; no experiments should be active in this older client. + if (CACHE_VERSION !== data.version) { + throw new Error("Experiments::_populateFromCache() - invalid cache version"); + } + + let experiments = new Map(); + for (let item of data.data) { + let entry = new Experiments.ExperimentEntry(this._policy); + if (!entry.initFromCacheData(item)) { + continue; + } + experiments.set(entry.id, entry); + } + + this._experiments = experiments; + }, + + /* + * Update the experiment entries from the experiments + * array in the manifest + */ + _updateExperiments: function (manifestObject) { + this._log.trace("_updateExperiments() - experiments: " + JSON.stringify(manifestObject)); + + if (manifestObject.version !== MANIFEST_VERSION) { + this._log.warning("updateExperiments() - unsupported version " + manifestObject.version); + } + + let experiments = new Map(); // The new experiments map + + // Collect new and updated experiments. + for (let data of manifestObject.experiments) { + let entry = this._experiments.get(data.id); + + if (entry) { + if (!entry.updateFromManifestData(data)) { + this._log.error("updateExperiments() - Invalid manifest data for " + data.id); + continue; + } + } else { + entry = new Experiments.ExperimentEntry(this._policy); + if (!entry.initFromManifestData(data)) { + continue; + } + } + + if (entry.shouldDiscard()) { + continue; + } + + experiments.set(entry.id, entry); + } + + // Make sure we keep experiments that are or were running. + // We remove them after KEEP_HISTORY_N_DAYS. + for (let [id, entry] of this._experiments) { + if (experiments.has(id)) { + continue; + } + + if (!entry.startDate || entry.shouldDiscard()) { + this._log.trace("updateExperiments() - discarding entry for " + id); + continue; + } + + experiments.set(id, entry); + } + + this._experiments = experiments; + this._dirty = true; + }, + + getActiveExperimentID: function() { + if (!this._experiments) { + return null; + } + let e = this._getActiveExperiment(); + if (!e) { + return null; + } + return e.id; + }, + + getActiveExperimentBranch: function() { + if (!this._experiments) { + return null; + } + let e = this._getActiveExperiment(); + if (!e) { + return null; + } + return e.branch; + }, + + _getActiveExperiment: function () { + let enabled = [experiment for ([,experiment] of this._experiments) if (experiment._enabled)]; + + if (enabled.length == 1) { + return enabled[0]; + } + + if (enabled.length > 1) { + this._log.error("getActiveExperimentId() - should not have more than 1 active experiment"); + throw new Error("have more than 1 active experiment"); + } + + return null; + }, + + /** + * Disables all active experiments. + * + * @return Promise<> Promise that will get resolved once the task is done or failed. + */ + disableExperiment: function (reason) { + if (!reason) { + throw new Error("Must specify a termination reason."); + } + + this._log.trace("disableExperiment()"); + this._terminateReason = reason; + return this._run(); + }, + + /** + * The Set of add-on IDs that we know about from manifests. + */ + get _trackedAddonIds() { + if (!this._experiments) { + return new Set(); + } + + return new Set([e._addonId for ([,e] of this._experiments) if (e._addonId)]); + }, + + /* + * Task function to check applicability of experiments, disable the active + * experiment if needed and activate the first applicable candidate. + */ + _evaluateExperiments: function*() { + this._log.trace("_evaluateExperiments"); + + this._checkForShutdown(); + + // The first thing we do is reconcile our state against what's in the + // Addon Manager. It's possible that the Addon Manager knows of experiment + // add-ons that we don't. This could happen if an experiment gets installed + // when we're not listening or if there is a bug in our synchronization + // code. + // + // We have a few options of what to do with unknown experiment add-ons + // coming from the Addon Manager. Ideally, we'd convert these to + // ExperimentEntry instances and stuff them inside this._experiments. + // However, since ExperimentEntry contain lots of metadata from the + // manifest and trying to make up data could be error prone, it's safer + // to not try. Furthermore, if an experiment really did come from us, we + // should have some record of it. In the end, we decide to discard all + // knowledge for these unknown experiment add-ons. + let installedExperiments = yield installedExperimentAddons(); + let expectedAddonIds = this._trackedAddonIds; + let unknownAddons = [a for (a of installedExperiments) if (!expectedAddonIds.has(a.id))]; + if (unknownAddons.length) { + this._log.warn("_evaluateExperiments() - unknown add-ons in AddonManager: " + + [a.id for (a of unknownAddons)].join(", ")); + + yield uninstallAddons(unknownAddons); + } + + let activeExperiment = this._getActiveExperiment(); + let activeChanged = false; + let now = this._policy.now(); + + if (!activeExperiment) { + // Avoid this pref staying out of sync if there were e.g. crashes. + gPrefs.set(PREF_ACTIVE_EXPERIMENT, false); + } + + // Ensure the active experiment is in the proper state. This may install, + // uninstall, upgrade, or enable the experiment add-on. What exactly is + // abstracted away from us by design. + if (activeExperiment) { + let changes; + let shouldStopResult = yield activeExperiment.shouldStop(); + if (shouldStopResult.shouldStop) { + let expireReasons = ["endTime", "maxActiveSeconds"]; + let kind, reason; + + if (expireReasons.indexOf(shouldStopResult.reason[0]) != -1) { + kind = TELEMETRY_LOG.TERMINATION.EXPIRED; + reason = null; + } else { + kind = TELEMETRY_LOG.TERMINATION.RECHECK; + reason = shouldStopResult.reason; + } + changes = yield activeExperiment.stop(kind, reason); + } + else if (this._terminateReason) { + changes = yield activeExperiment.stop(this._terminateReason); + } + else { + changes = yield activeExperiment.reconcileAddonState(); + } + + if (changes) { + this._dirty = true; + activeChanged = true; + } + + if (!activeExperiment._enabled) { + activeExperiment = null; + activeChanged = true; + } + } + + this._terminateReason = null; + + if (!activeExperiment && gExperimentsEnabled) { + for (let [id, experiment] of this._experiments) { + let applicable; + let reason = null; + try { + applicable = yield experiment.isApplicable(); + } + catch (e) { + applicable = false; + reason = e; + } + + if (!applicable && reason && reason[0] != "was-active") { + // Report this from here to avoid over-reporting. + let desc = TELEMETRY_LOG.ACTIVATION; + let data = [TELEMETRY_LOG.ACTIVATION.REJECTED, id]; + data = data.concat(reason); + TelemetryLog.log(TELEMETRY_LOG.ACTIVATION_KEY, data); + } + + if (!applicable) { + continue; + } + + this._log.debug("evaluateExperiments() - activating experiment " + id); + try { + yield experiment.start(); + activeChanged = true; + activeExperiment = experiment; + this._dirty = true; + break; + } catch (e) { + // On failure, clean up the best we can and try the next experiment. + this._log.error("evaluateExperiments() - Unable to start experiment: " + e.message); + experiment._enabled = false; + yield experiment.reconcileAddonState(); + } + } + } + + gPrefs.set(PREF_ACTIVE_EXPERIMENT, activeExperiment != null); + + if (activeChanged || this._firstEvaluate) { + Services.obs.notifyObservers(null, EXPERIMENTS_CHANGED_TOPIC, null); + this._firstEvaluate = false; + } + + if ("@mozilla.org/toolkit/crash-reporter;1" in Cc && activeExperiment) { + try { + gCrashReporter.annotateCrashReport("ActiveExperiment", activeExperiment.id); + } catch (e) { + // It's ok if crash reporting is disabled. + } + } + }, + + /* + * Schedule the soonest re-check of experiment applicability that is needed. + */ + _scheduleNextRun: function () { + this._checkForShutdown(); + + if (this._timer) { + this._timer.clear(); + } + + if (!gExperimentsEnabled || this._experiments.length == 0) { + return; + } + + let time = null; + let now = this._policy.now().getTime(); + + for (let [id, experiment] of this._experiments) { + let scheduleTime = experiment.getScheduleTime(); + if (scheduleTime > now) { + if (time !== null) { + time = Math.min(time, scheduleTime); + } else { + time = scheduleTime; + } + } + } + + if (time === null) { + // No schedule time found. + return; + } + + this._log.trace("scheduleExperimentEvaluation() - scheduling for "+time+", now: "+now); + this._policy.oneshotTimer(this.notify, time - now, this, "_timer"); + }, +}; + + +/* + * Represents a single experiment. + */ + +Experiments.ExperimentEntry = function (policy) { + this._policy = policy || new Experiments.Policy(); + this._log = Log.repository.getLoggerWithMessagePrefix( + "Browser.Experiments.Experiments", + "ExperimentEntry #" + gExperimentEntryCounter++ + "::"); + + // Is the experiment supposed to be running. + this._enabled = false; + // When this experiment was started, if ever. + this._startDate = null; + // When this experiment was ended, if ever. + this._endDate = null; + // The condition data from the manifest. + this._manifestData = null; + // For an active experiment, signifies whether we need to update the xpi. + this._needsUpdate = false; + // A random sample value for comparison against the manifest conditions. + this._randomValue = null; + // When this entry was last changed for respecting history retention duration. + this._lastChangedDate = null; + // Has this experiment failed to activate before? + this._failedStart = false; + // The experiment branch + this._branch = null; + + // We grab these from the addon after download. + this._name = null; + this._description = null; + this._homepageURL = null; + this._addonId = null; +}; + +Experiments.ExperimentEntry.prototype = { + MANIFEST_REQUIRED_FIELDS: new Set([ + "id", + "xpiURL", + "xpiHash", + "startTime", + "endTime", + "maxActiveSeconds", + "appName", + "channel", + ]), + + MANIFEST_OPTIONAL_FIELDS: new Set([ + "maxStartTime", + "minVersion", + "maxVersion", + "version", + "minBuildID", + "maxBuildID", + "buildIDs", + "os", + "locale", + "sample", + "disabled", + "frozen", + "jsfilter", + ]), + + SERIALIZE_KEYS: new Set([ + "_enabled", + "_manifestData", + "_needsUpdate", + "_randomValue", + "_failedStart", + "_name", + "_description", + "_homepageURL", + "_addonId", + "_startDate", + "_endDate", + "_branch", + ]), + + DATE_KEYS: new Set([ + "_startDate", + "_endDate", + ]), + + UPGRADE_KEYS: new Map([ + ["_branch", null], + ]), + + ADDON_CHANGE_NONE: 0, + ADDON_CHANGE_INSTALL: 1, + ADDON_CHANGE_UNINSTALL: 2, + ADDON_CHANGE_ENABLE: 4, + + /* + * Initialize entry from the manifest. + * @param data The experiment data from the manifest. + * @return boolean Whether initialization succeeded. + */ + initFromManifestData: function (data) { + if (!this._isManifestDataValid(data)) { + return false; + } + + this._manifestData = data; + + this._randomValue = this._policy.random(); + this._lastChangedDate = this._policy.now(); + + return true; + }, + + get enabled() { + return this._enabled; + }, + + get id() { + return this._manifestData.id; + }, + + get branch() { + return this._branch; + }, + + set branch(v) { + this._branch = v; + }, + + get startDate() { + return this._startDate; + }, + + get endDate() { + if (!this._startDate) { + return null; + } + + let endTime = 0; + + if (!this._enabled) { + return this._endDate; + } + + let maxActiveMs = 1000 * this._manifestData.maxActiveSeconds; + endTime = Math.min(1000 * this._manifestData.endTime, + this._startDate.getTime() + maxActiveMs); + + return new Date(endTime); + }, + + get needsUpdate() { + return this._needsUpdate; + }, + + /* + * Initialize entry from the cache. + * @param data The entry data from the cache. + * @return boolean Whether initialization succeeded. + */ + initFromCacheData: function (data) { + for (let [key, dval] of this.UPGRADE_KEYS) { + if (!(key in data)) { + data[key] = dval; + } + } + + for (let key of this.SERIALIZE_KEYS) { + if (!(key in data) && !this.DATE_KEYS.has(key)) { + this._log.error("initFromCacheData() - missing required key " + key); + return false; + } + }; + + if (!this._isManifestDataValid(data._manifestData)) { + return false; + } + + // Dates are restored separately from epoch ms, everything else is just + // copied in. + + this.SERIALIZE_KEYS.forEach(key => { + if (!this.DATE_KEYS.has(key)) { + this[key] = data[key]; + } + }); + + this.DATE_KEYS.forEach(key => { + if (key in data) { + let date = new Date(); + date.setTime(data[key]); + this[key] = date; + } + }); + + this._lastChangedDate = this._policy.now(); + + return true; + }, + + /* + * Returns a JSON representation of this object. + */ + toJSON: function () { + let obj = {}; + + // Dates are serialized separately as epoch ms. + + this.SERIALIZE_KEYS.forEach(key => { + if (!this.DATE_KEYS.has(key)) { + obj[key] = this[key]; + } + }); + + this.DATE_KEYS.forEach(key => { + if (this[key]) { + obj[key] = this[key].getTime(); + } + }); + + return obj; + }, + + /* + * Update from the experiment data from the manifest. + * @param data The experiment data from the manifest. + * @return boolean Whether updating succeeded. + */ + updateFromManifestData: function (data) { + let old = this._manifestData; + + if (!this._isManifestDataValid(data)) { + return false; + } + + if (this._enabled) { + if (old.xpiHash !== data.xpiHash) { + // A changed hash means we need to update active experiments. + this._needsUpdate = true; + } + } else if (this._failedStart && + (old.xpiHash !== data.xpiHash) || + (old.xpiURL !== data.xpiURL)) { + // Retry installation of previously invalid experiments + // if hash or url changed. + this._failedStart = false; + } + + this._manifestData = data; + this._lastChangedDate = this._policy.now(); + + return true; + }, + + /* + * Is this experiment applicable? + * @return Promise<> Resolved if the experiment is applicable. + * If it is not applicable it is rejected with + * a Promise which contains the reason. + */ + isApplicable: function () { + let versionCmp = Cc["@mozilla.org/xpcom/version-comparator;1"] + .getService(Ci.nsIVersionComparator); + let app = Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULAppInfo); + let runtime = Cc["@mozilla.org/xre/app-info;1"] + .getService(Ci.nsIXULRuntime); + + let locale = this._policy.locale(); + let channel = this._policy.updatechannel(); + let data = this._manifestData; + + let now = this._policy.now() / 1000; // The manifest times are in seconds. + let minActive = MIN_EXPERIMENT_ACTIVE_SECONDS; + let maxActive = data.maxActiveSeconds || 0; + let startSec = (this.startDate || 0) / 1000; + + this._log.trace("isApplicable() - now=" + now + + ", randomValue=" + this._randomValue + + ", data=" + JSON.stringify(this._manifestData)); + + // Not applicable if it already ran. + + if (!this.enabled && this._endDate) { + return Promise.reject(["was-active"]); + } + + // Define and run the condition checks. + + let simpleChecks = [ + { name: "failedStart", + condition: () => !this._failedStart }, + { name: "disabled", + condition: () => !data.disabled }, + { name: "frozen", + condition: () => !data.frozen || this._enabled }, + { name: "startTime", + condition: () => now >= data.startTime }, + { name: "endTime", + condition: () => now < data.endTime }, + { name: "maxStartTime", + condition: () => !data.maxStartTime || now <= data.maxStartTime }, + { name: "maxActiveSeconds", + condition: () => !this._startDate || now <= (startSec + maxActive) }, + { name: "appName", + condition: () => !data.appName || data.appName.indexOf(app.name) != -1 }, + { name: "minBuildID", + condition: () => !data.minBuildID || app.platformBuildID >= data.minBuildID }, + { name: "maxBuildID", + condition: () => !data.maxBuildID || app.platformBuildID <= data.maxBuildID }, + { name: "buildIDs", + condition: () => !data.buildIDs || data.buildIDs.indexOf(app.platformBuildID) != -1 }, + { name: "os", + condition: () => !data.os || data.os.indexOf(runtime.OS) != -1 }, + { name: "channel", + condition: () => !data.channel || data.channel.indexOf(channel) != -1 }, + { name: "locale", + condition: () => !data.locale || data.locale.indexOf(locale) != -1 }, + { name: "sample", + condition: () => data.sample === undefined || this._randomValue <= data.sample }, + { name: "version", + condition: () => !data.version || data.version.indexOf(app.version) != -1 }, + { name: "minVersion", + condition: () => !data.minVersion || versionCmp.compare(app.version, data.minVersion) >= 0 }, + { name: "maxVersion", + condition: () => !data.maxVersion || versionCmp.compare(app.version, data.maxVersion) <= 0 }, + ]; + + for (let check of simpleChecks) { + let result = check.condition(); + if (!result) { + this._log.debug("isApplicable() - id=" + + data.id + " - test '" + check.name + "' failed"); + return Promise.reject([check.name]); + } + } + + if (data.jsfilter) { + return this._runFilterFunction(data.jsfilter); + } + + return Promise.resolve(true); + }, + + /* + * Run the jsfilter function from the manifest in a sandbox and return the + * result (forced to boolean). + */ + _runFilterFunction: function (jsfilter) { + this._log.trace("runFilterFunction() - filter: " + jsfilter); + + return Task.spawn(function ExperimentEntry_runFilterFunction_task() { + const nullprincipal = Cc["@mozilla.org/nullprincipal;1"].createInstance(Ci.nsIPrincipal); + let options = { + sandboxName: "telemetry experiments jsfilter sandbox", + wantComponents: false, + }; + + let sandbox = Cu.Sandbox(nullprincipal); + let context = {}; + context.healthReportPayload = yield this._policy.healthReportPayload(); + context.telemetryPayload = yield this._policy.telemetryPayload(); + + try { + Cu.evalInSandbox(jsfilter, sandbox); + } catch (e) { + this._log.error("runFilterFunction() - failed to eval jsfilter: " + e.message); + throw ["jsfilter-evalfailed"]; + } + + // You can't insert arbitrarily complex objects into a sandbox, so + // we serialize everything through JSON. + sandbox._hr = JSON.stringify(yield this._policy.healthReportPayload()); + Object.defineProperty(sandbox, "_t", + { get: () => JSON.stringify(this._policy.telemetryPayload()) }); + + let result = false; + try { + result = !!Cu.evalInSandbox("filter({healthReportPayload: JSON.parse(_hr), telemetryPayload: JSON.parse(_t)})", sandbox); + } + catch (e) { + this._log.debug("runFilterFunction() - filter function failed: " + + e.message + ", " + e.stack); + throw ["jsfilter-threw", e.message]; + } + finally { + Cu.nukeSandbox(sandbox); + } + + if (!result) { + throw ["jsfilter-false"]; + } + + throw new Task.Result(true); + }.bind(this)); + }, + + /* + * Start running the experiment. + * + * @return Promise<> Resolved when the operation is complete. + */ + start: Task.async(function* () { + this._log.trace("start() for " + this.id); + + this._enabled = true; + return yield this.reconcileAddonState(); + }), + + // Async install of the addon for this experiment, part of the start task above. + _installAddon: Task.async(function* () { + let deferred = Promise.defer(); + + let hash = this._policy.ignoreHashes ? null : this._manifestData.xpiHash; + + let install = yield addonInstallForURL(this._manifestData.xpiURL, hash); + gActiveInstallURLs.add(install.sourceURI.spec); + + let failureHandler = (install, handler) => { + let message = "AddonInstall " + handler + " for " + this.id + ", state=" + + (install.state || "?") + ", error=" + install.error; + this._log.error("_installAddon() - " + message); + this._failedStart = true; + gActiveInstallURLs.delete(install.sourceURI.spec); + + TelemetryLog.log(TELEMETRY_LOG.ACTIVATION_KEY, + [TELEMETRY_LOG.ACTIVATION.INSTALL_FAILURE, this.id]); + + deferred.reject(new Error(message)); + }; + + let listener = { + _expectedID: null, + + onDownloadEnded: install => { + this._log.trace("_installAddon() - onDownloadEnded for " + this.id); + + if (install.existingAddon) { + this._log.warn("_installAddon() - onDownloadEnded, addon already installed"); + } + + if (install.addon.type !== "experiment") { + this._log.error("_installAddon() - onDownloadEnded, wrong addon type"); + install.cancel(); + } + }, + + onInstallStarted: install => { + this._log.trace("_installAddon() - onInstallStarted for " + this.id); + + if (install.existingAddon) { + this._log.warn("_installAddon() - onInstallStarted, addon already installed"); + } + + if (install.addon.type !== "experiment") { + this._log.error("_installAddon() - onInstallStarted, wrong addon type"); + return false; + } + }, + + onInstallEnded: install => { + this._log.trace("_installAddon() - install ended for " + this.id); + gActiveInstallURLs.delete(install.sourceURI.spec); + + this._lastChangedDate = this._policy.now(); + this._startDate = this._policy.now(); + this._enabled = true; + + TelemetryLog.log(TELEMETRY_LOG.ACTIVATION_KEY, + [TELEMETRY_LOG.ACTIVATION.ACTIVATED, this.id]); + + let addon = install.addon; + this._name = addon.name; + this._addonId = addon.id; + this._description = addon.description || ""; + this._homepageURL = addon.homepageURL || ""; + + // Experiment add-ons default to userDisabled=true. Enable if needed. + if (addon.userDisabled) { + this._log.trace("Add-on is disabled. Enabling."); + listener._expectedID = addon.id; + AddonManager.addAddonListener(listener); + addon.userDisabled = false; + } else { + this._log.trace("Add-on is enabled. start() completed."); + deferred.resolve(); + } + }, + + onEnabled: addon => { + this._log.info("onEnabled() for " + addon.id); + + if (addon.id != listener._expectedID) { + return; + } + + AddonManager.removeAddonListener(listener); + deferred.resolve(); + }, + }; + + ["onDownloadCancelled", "onDownloadFailed", "onInstallCancelled", "onInstallFailed"] + .forEach(what => { + listener[what] = install => failureHandler(install, what) + }); + + install.addListener(listener); + install.install(); + + return yield deferred.promise; + }), + + /** + * Stop running the experiment if it is active. + * + * @param terminationKind (optional) + * The termination kind, e.g. ADDON_UNINSTALLED or EXPIRED. + * @param terminationReason (optional) + * The termination reason details for termination kind RECHECK. + * @return Promise<> Resolved when the operation is complete. + */ + stop: Task.async(function* (terminationKind, terminationReason) { + this._log.trace("stop() - id=" + this.id + ", terminationKind=" + terminationKind); + if (!this._enabled) { + throw new Error("Must not call stop() on an inactive experiment."); + } + + this._enabled = false; + let now = this._policy.now(); + this._lastChangedDate = now; + this._endDate = now; + + let changes = yield this.reconcileAddonState(); + this._logTermination(terminationKind, terminationReason); + + if (terminationKind == TELEMETRY_LOG.TERMINATION.ADDON_UNINSTALLED) { + changes |= this.ADDON_CHANGE_UNINSTALL; + } + + return changes; + }), + + /** + * Reconcile the state of the add-on against what it's supposed to be. + * + * If we are active, ensure the add-on is enabled and up to date. + * + * If we are inactive, ensure the add-on is not installed. + */ + reconcileAddonState: Task.async(function* () { + this._log.trace("reconcileAddonState()"); + + if (!this._enabled) { + if (!this._addonId) { + this._log.trace("reconcileAddonState() - Experiment is not enabled and " + + "has no add-on. Doing nothing."); + return this.ADDON_CHANGE_NONE; + } + + let addon = yield this._getAddon(); + if (!addon) { + this._log.trace("reconcileAddonState() - Inactive experiment has no " + + "add-on. Doing nothing."); + return this.ADDON_CHANGE_NONE; + } + + this._log.info("reconcileAddonState() - Uninstalling add-on for inactive " + + "experiment: " + addon.id); + gActiveUninstallAddonIDs.add(addon.id); + yield uninstallAddons([addon]); + gActiveUninstallAddonIDs.delete(addon.id); + return this.ADDON_CHANGE_UNINSTALL; + } + + // If we get here, we're supposed to be active. + + let changes = 0; + + // That requires an add-on. + let currentAddon = yield this._getAddon(); + + // If we have an add-on but it isn't up to date, uninstall it + // (to prepare for reinstall). + if (currentAddon && this._needsUpdate) { + this._log.info("reconcileAddonState() - Uninstalling add-on because update " + + "needed: " + currentAddon.id); + gActiveUninstallAddonIDs.add(currentAddon.id); + yield uninstallAddons([currentAddon]); + gActiveUninstallAddonIDs.delete(currentAddon.id); + changes |= this.ADDON_CHANGE_UNINSTALL; + } + + if (!currentAddon || this._needsUpdate) { + this._log.info("reconcileAddonState() - Installing add-on."); + yield this._installAddon(); + changes |= this.ADDON_CHANGE_INSTALL; + } + + let addon = yield this._getAddon(); + if (!addon) { + throw new Error("Could not obtain add-on for experiment that should be " + + "enabled."); + } + + // If we have the add-on and it is enabled, we are done. + if (!addon.userDisabled) { + return changes; + } + + let deferred = Promise.defer(); + + // Else we need to enable it. + let listener = { + onEnabled: enabledAddon => { + if (enabledAddon.id != addon.id) { + return; + } + + AddonManager.removeAddonListener(listener); + deferred.resolve(); + }, + }; + + this._log.info("Activating add-on: " + addon.id); + AddonManager.addAddonListener(listener); + addon.userDisabled = false; + yield deferred.promise; + changes |= this.ADDON_CHANGE_ENABLE; + + this._log.info("Add-on has been enabled: " + addon.id); + return changes; + }), + + /** + * Obtain the underlying Addon from the Addon Manager. + * + * @return Promise + */ + _getAddon: function () { + if (!this._addonId) { + return Promise.resolve(null); + } + + let deferred = Promise.defer(); + + AddonManager.getAddonByID(this._addonId, (addon) => { + if (addon && addon.appDisabled) { + // Don't return PreviousExperiments. + addon = null; + } + + deferred.resolve(addon); + }); + + return deferred.promise; + }, + + _logTermination: function (terminationKind, terminationReason) { + if (terminationKind === undefined) { + return; + } + + if (!(terminationKind in TELEMETRY_LOG.TERMINATION)) { + this._log.warn("stop() - unknown terminationKind " + terminationKind); + return; + } + + let data = [terminationKind, this.id]; + if (terminationReason) { + data = data.concat(terminationReason); + } + + TelemetryLog.log(TELEMETRY_LOG.TERMINATION_KEY, data); + }, + + /** + * Determine whether an active experiment should be stopped. + */ + shouldStop: function () { + if (!this._enabled) { + throw new Error("shouldStop must not be called on disabled experiments."); + } + + let data = this._manifestData; + let now = this._policy.now() / 1000; // The manifest times are in seconds. + let maxActiveSec = data.maxActiveSeconds || 0; + + let deferred = Promise.defer(); + this.isApplicable().then( + () => deferred.resolve({shouldStop: false}), + reason => deferred.resolve({shouldStop: true, reason: reason}) + ); + + return deferred.promise; + }, + + /* + * Should this be discarded from the cache due to age? + */ + shouldDiscard: function () { + let limit = this._policy.now(); + limit.setDate(limit.getDate() - KEEP_HISTORY_N_DAYS); + return (this._lastChangedDate < limit); + }, + + /* + * Get next date (in epoch-ms) to schedule a re-evaluation for this. + * Returns 0 if it doesn't need one. + */ + getScheduleTime: function () { + if (this._enabled) { + let now = this._policy.now(); + let startTime = this._startDate.getTime(); + let maxActiveTime = startTime + 1000 * this._manifestData.maxActiveSeconds; + return Math.min(1000 * this._manifestData.endTime, maxActiveTime); + } + + if (this._endDate) { + return this._endDate.getTime(); + } + + return 1000 * this._manifestData.startTime; + }, + + /* + * Perform sanity checks on the experiment data. + */ + _isManifestDataValid: function (data) { + this._log.trace("isManifestDataValid() - data: " + JSON.stringify(data)); + + for (let key of this.MANIFEST_REQUIRED_FIELDS) { + if (!(key in data)) { + this._log.error("isManifestDataValid() - missing required key: " + key); + return false; + } + } + + for (let key in data) { + if (!this.MANIFEST_OPTIONAL_FIELDS.has(key) && + !this.MANIFEST_REQUIRED_FIELDS.has(key)) { + this._log.error("isManifestDataValid() - unknown key: " + key); + return false; + } + } + + return true; + }, +}; + + + +/** + * Strip a Date down to its UTC midnight. + * + * This will return a cloned Date object. The original is unchanged. + */ +let stripDateToMidnight = function (d) { + let m = new Date(d); + m.setUTCHours(0, 0, 0, 0); + + return m; +}; + +function ExperimentsLastActiveMeasurement1() { + Metrics.Measurement.call(this); +} +function ExperimentsLastActiveMeasurement2() { + Metrics.Measurement.call(this); +} + +const FIELD_DAILY_LAST_TEXT = {type: Metrics.Storage.FIELD_DAILY_LAST_TEXT}; + +ExperimentsLastActiveMeasurement1.prototype = Object.freeze({ + __proto__: Metrics.Measurement.prototype, + + name: "info", + version: 1, + + fields: { + lastActive: FIELD_DAILY_LAST_TEXT, + } +}); +ExperimentsLastActiveMeasurement2.prototype = Object.freeze({ + __proto__: Metrics.Measurement.prototype, + + name: "info", + version: 2, + + fields: { + lastActive: FIELD_DAILY_LAST_TEXT, + lastActiveBranch: FIELD_DAILY_LAST_TEXT, + } +}); + +this.ExperimentsProvider = function () { + Metrics.Provider.call(this); + + this._experiments = null; +}; + +ExperimentsProvider.prototype = Object.freeze({ + __proto__: Metrics.Provider.prototype, + + name: "org.mozilla.experiments", + + measurementTypes: [ + ExperimentsLastActiveMeasurement1, + ExperimentsLastActiveMeasurement2, + ], + + _OBSERVERS: [ + EXPERIMENTS_CHANGED_TOPIC, + ], + + postInit: function () { + for (let o of this._OBSERVERS) { + Services.obs.addObserver(this, o, false); + } + + return Promise.resolve(); + }, + + onShutdown: function () { + for (let o of this._OBSERVERS) { + Services.obs.removeObserver(this, o); + } + + return Promise.resolve(); + }, + + observe: function (subject, topic, data) { + switch (topic) { + case EXPERIMENTS_CHANGED_TOPIC: + this.recordLastActiveExperiment(); + break; + } + }, + + collectDailyData: function () { + return this.recordLastActiveExperiment(); + }, + + recordLastActiveExperiment: function () { + if (!gExperimentsEnabled) { + return Promise.resolve(); + } + + if (!this._experiments) { + this._experiments = Experiments.instance(); + } + + let m = this.getMeasurement(ExperimentsLastActiveMeasurement2.prototype.name, + ExperimentsLastActiveMeasurement2.prototype.version); + + return this.enqueueStorageOperation(() => { + return Task.spawn(function* recordTask() { + let todayActive = yield this._experiments.lastActiveToday(); + if (!todayActive) { + this._log.info("No active experiment on this day: " + + this._experiments._policy.now()); + return; + } + + this._log.info("Recording last active experiment: " + todayActive.id); + yield m.setDailyLastText("lastActive", todayActive.id, + this._experiments._policy.now()); + let branch = todayActive.branch; + if (branch) { + yield m.setDailyLastText("lastActiveBranch", branch, + this._experiments._policy.now()); + } + }.bind(this)); + }); + }, +}); + +/** + * An Add-ons Manager provider that knows about old experiments. + * + * This provider exposes read-only add-ons corresponding to previously-active + * experiments. The existence of this provider (and the add-ons it knows about) + * facilitates the display of old experiments in the Add-ons Manager UI with + * very little custom code in that component. + */ +this.Experiments.PreviousExperimentProvider = function (experiments) { + this._experiments = experiments; + this._experimentList = []; + this._log = Log.repository.getLoggerWithMessagePrefix( + "Browser.Experiments.Experiments", + "PreviousExperimentProvider #" + gPreviousProviderCounter++ + "::"); +} + +this.Experiments.PreviousExperimentProvider.prototype = Object.freeze({ + startup: function () { + this._log.trace("startup()"); + Services.obs.addObserver(this, EXPERIMENTS_CHANGED_TOPIC, false); + }, + + shutdown: function () { + this._log.trace("shutdown()"); + Services.obs.removeObserver(this, EXPERIMENTS_CHANGED_TOPIC); + }, + + observe: function (subject, topic, data) { + switch (topic) { + case EXPERIMENTS_CHANGED_TOPIC: + this._updateExperimentList(); + break; + } + }, + + getAddonByID: function (id, cb) { + for (let experiment of this._experimentList) { + if (experiment.id == id) { + cb(new PreviousExperimentAddon(experiment)); + return; + } + } + + cb(null); + }, + + getAddonsByTypes: function (types, cb) { + if (types && types.length > 0 && types.indexOf("experiment") == -1) { + cb([]); + return; + } + + cb([new PreviousExperimentAddon(e) for (e of this._experimentList)]); + }, + + _updateExperimentList: function () { + return this._experiments.getExperiments().then((experiments) => { + let list = [e for (e of experiments) if (!e.active)]; + + let newMap = new Map([[e.id, e] for (e of list)]); + let oldMap = new Map([[e.id, e] for (e of this._experimentList)]); + + let added = [e.id for (e of list) if (!oldMap.has(e.id))]; + let removed = [e.id for (e of this._experimentList) if (!newMap.has(e.id))]; + + for (let id of added) { + this._log.trace("updateExperimentList() - adding " + id); + let wrapper = new PreviousExperimentAddon(newMap.get(id)); + AddonManagerPrivate.callInstallListeners("onExternalInstall", null, wrapper, null, false); + AddonManagerPrivate.callAddonListeners("onInstalling", wrapper, false); + } + + for (let id of removed) { + this._log.trace("updateExperimentList() - removing " + id); + let wrapper = new PreviousExperimentAddon(oldMap.get(id)); + AddonManagerPrivate.callAddonListeners("onUninstalling", plugin, false); + } + + this._experimentList = list; + + for (let id of added) { + let wrapper = new PreviousExperimentAddon(newMap.get(id)); + AddonManagerPrivate.callAddonListeners("onInstalled", wrapper); + } + + for (let id of removed) { + let wrapper = new PreviousExperimentAddon(oldMap.get(id)); + AddonManagerPrivate.callAddonListeners("onUninstalled", wrapper); + } + + return this._experimentList; + }); + }, +}); + +/** + * An add-on that represents a previously-installed experiment. + */ +function PreviousExperimentAddon(experiment) { + this._id = experiment.id; + this._name = experiment.name; + this._endDate = experiment.endDate; + this._description = experiment.description; +} + +PreviousExperimentAddon.prototype = Object.freeze({ + // BEGIN REQUIRED ADDON PROPERTIES + + get appDisabled() { + return true; + }, + + get blocklistState() { + Ci.nsIBlocklistService.STATE_NOT_BLOCKED + }, + + get creator() { + return new AddonManagerPrivate.AddonAuthor(""); + }, + + get foreignInstall() { + return false; + }, + + get id() { + return this._id; + }, + + get isActive() { + return false; + }, + + get isCompatible() { + return true; + }, + + get isPlatformCompatible() { + return true; + }, + + get name() { + return this._name; + }, + + get pendingOperations() { + return AddonManager.PENDING_NONE; + }, + + get permissions() { + return 0; + }, + + get providesUpdatesSecurely() { + return true; + }, + + get scope() { + return AddonManager.SCOPE_PROFILE; + }, + + get type() { + return "experiment"; + }, + + get userDisabled() { + return true; + }, + + get version() { + return null; + }, + + // END REQUIRED PROPERTIES + + // BEGIN OPTIONAL PROPERTIES + + get description() { + return this._description; + }, + + get updateDate() { + return new Date(this._endDate); + }, + + // END OPTIONAL PROPERTIES + + // BEGIN REQUIRED METHODS + + isCompatibleWith: function (appVersion, platformVersion) { + return true; + }, + + findUpdates: function (listener, reason, appVersion, platformVersion) { + AddonManagerPrivate.callNoUpdateListeners(this, listener, reason, + appVersion, platformVersion); + }, + + // END REQUIRED METHODS + + /** + * The end-date of the experiment, required for the Addon Manager UI. + */ + + get endDate() { + return this._endDate; + }, + +});