1.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 1.2 +++ b/browser/experiments/Experiments.jsm Wed Dec 31 06:09:35 2014 +0100 1.3 @@ -0,0 +1,2369 @@ 1.4 +/* This Source Code Form is subject to the terms of the Mozilla Public 1.5 + * License, v. 2.0. If a copy of the MPL was not distributed with this 1.6 + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 1.7 + 1.8 +"use strict"; 1.9 + 1.10 +this.EXPORTED_SYMBOLS = [ 1.11 + "Experiments", 1.12 + "ExperimentsProvider", 1.13 +]; 1.14 + 1.15 +const {classes: Cc, interfaces: Ci, utils: Cu} = Components; 1.16 + 1.17 +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); 1.18 +Cu.import("resource://gre/modules/Services.jsm"); 1.19 +Cu.import("resource://gre/modules/Task.jsm"); 1.20 +Cu.import("resource://gre/modules/Promise.jsm"); 1.21 +Cu.import("resource://gre/modules/osfile.jsm"); 1.22 +Cu.import("resource://gre/modules/Log.jsm"); 1.23 +Cu.import("resource://gre/modules/Preferences.jsm"); 1.24 +Cu.import("resource://gre/modules/AsyncShutdown.jsm"); 1.25 + 1.26 +XPCOMUtils.defineLazyModuleGetter(this, "UpdateChannel", 1.27 + "resource://gre/modules/UpdateChannel.jsm"); 1.28 +XPCOMUtils.defineLazyModuleGetter(this, "AddonManager", 1.29 + "resource://gre/modules/AddonManager.jsm"); 1.30 +XPCOMUtils.defineLazyModuleGetter(this, "AddonManagerPrivate", 1.31 + "resource://gre/modules/AddonManager.jsm"); 1.32 +XPCOMUtils.defineLazyModuleGetter(this, "TelemetryPing", 1.33 + "resource://gre/modules/TelemetryPing.jsm"); 1.34 +XPCOMUtils.defineLazyModuleGetter(this, "TelemetryLog", 1.35 + "resource://gre/modules/TelemetryLog.jsm"); 1.36 +XPCOMUtils.defineLazyModuleGetter(this, "CommonUtils", 1.37 + "resource://services-common/utils.js"); 1.38 +XPCOMUtils.defineLazyModuleGetter(this, "Metrics", 1.39 + "resource://gre/modules/Metrics.jsm"); 1.40 + 1.41 +// CertUtils.jsm doesn't expose a single "CertUtils" object like a normal .jsm 1.42 +// would. 1.43 +XPCOMUtils.defineLazyGetter(this, "CertUtils", 1.44 + function() { 1.45 + var mod = {}; 1.46 + Cu.import("resource://gre/modules/CertUtils.jsm", mod); 1.47 + return mod; 1.48 + }); 1.49 + 1.50 +XPCOMUtils.defineLazyServiceGetter(this, "gCrashReporter", 1.51 + "@mozilla.org/xre/app-info;1", 1.52 + "nsICrashReporter"); 1.53 + 1.54 +const FILE_CACHE = "experiments.json"; 1.55 +const EXPERIMENTS_CHANGED_TOPIC = "experiments-changed"; 1.56 +const MANIFEST_VERSION = 1; 1.57 +const CACHE_VERSION = 1; 1.58 + 1.59 +const KEEP_HISTORY_N_DAYS = 180; 1.60 +const MIN_EXPERIMENT_ACTIVE_SECONDS = 60; 1.61 + 1.62 +const PREF_BRANCH = "experiments."; 1.63 +const PREF_ENABLED = "enabled"; // experiments.enabled 1.64 +const PREF_ACTIVE_EXPERIMENT = "activeExperiment"; // whether we have an active experiment 1.65 +const PREF_LOGGING = "logging"; 1.66 +const PREF_LOGGING_LEVEL = PREF_LOGGING + ".level"; // experiments.logging.level 1.67 +const PREF_LOGGING_DUMP = PREF_LOGGING + ".dump"; // experiments.logging.dump 1.68 +const PREF_MANIFEST_URI = "manifest.uri"; // experiments.logging.manifest.uri 1.69 +const PREF_MANIFEST_CHECKCERT = "manifest.cert.checkAttributes"; // experiments.manifest.cert.checkAttributes 1.70 +const PREF_MANIFEST_REQUIREBUILTIN = "manifest.cert.requireBuiltin"; // experiments.manifest.cert.requireBuiltin 1.71 +const PREF_FORCE_SAMPLE = "force-sample-value"; // experiments.force-sample-value 1.72 + 1.73 +const PREF_HEALTHREPORT_ENABLED = "datareporting.healthreport.service.enabled"; 1.74 + 1.75 +const PREF_BRANCH_TELEMETRY = "toolkit.telemetry."; 1.76 +const PREF_TELEMETRY_ENABLED = "enabled"; 1.77 + 1.78 +const URI_EXTENSION_STRINGS = "chrome://mozapps/locale/extensions/extensions.properties"; 1.79 +const STRING_TYPE_NAME = "type.%ID%.name"; 1.80 + 1.81 +const TELEMETRY_LOG = { 1.82 + // log(key, [kind, experimentId, details]) 1.83 + ACTIVATION_KEY: "EXPERIMENT_ACTIVATION", 1.84 + ACTIVATION: { 1.85 + // Successfully activated. 1.86 + ACTIVATED: "ACTIVATED", 1.87 + // Failed to install the add-on. 1.88 + INSTALL_FAILURE: "INSTALL_FAILURE", 1.89 + // Experiment does not meet activation requirements. Details will 1.90 + // be provided. 1.91 + REJECTED: "REJECTED", 1.92 + }, 1.93 + 1.94 + // log(key, [kind, experimentId, optionalDetails...]) 1.95 + TERMINATION_KEY: "EXPERIMENT_TERMINATION", 1.96 + TERMINATION: { 1.97 + // The Experiments service was disabled. 1.98 + SERVICE_DISABLED: "SERVICE_DISABLED", 1.99 + // Add-on uninstalled. 1.100 + ADDON_UNINSTALLED: "ADDON_UNINSTALLED", 1.101 + // The experiment disabled itself. 1.102 + FROM_API: "FROM_API", 1.103 + // The experiment expired (e.g. by exceeding the end date). 1.104 + EXPIRED: "EXPIRED", 1.105 + // Disabled after re-evaluating conditions. If this is specified, 1.106 + // details will be provided. 1.107 + RECHECK: "RECHECK", 1.108 + }, 1.109 +}; 1.110 + 1.111 +const gPrefs = new Preferences(PREF_BRANCH); 1.112 +const gPrefsTelemetry = new Preferences(PREF_BRANCH_TELEMETRY); 1.113 +let gExperimentsEnabled = false; 1.114 +let gAddonProvider = null; 1.115 +let gExperiments = null; 1.116 +let gLogAppenderDump = null; 1.117 +let gPolicyCounter = 0; 1.118 +let gExperimentsCounter = 0; 1.119 +let gExperimentEntryCounter = 0; 1.120 +let gPreviousProviderCounter = 0; 1.121 + 1.122 +// Tracks active AddonInstall we know about so we can deny external 1.123 +// installs. 1.124 +let gActiveInstallURLs = new Set(); 1.125 + 1.126 +// Tracks add-on IDs that are being uninstalled by us. This allows us 1.127 +// to differentiate between expected uninstalled and user-driven uninstalls. 1.128 +let gActiveUninstallAddonIDs = new Set(); 1.129 + 1.130 +let gLogger; 1.131 +let gLogDumping = false; 1.132 + 1.133 +function configureLogging() { 1.134 + if (!gLogger) { 1.135 + gLogger = Log.repository.getLogger("Browser.Experiments"); 1.136 + gLogger.addAppender(new Log.ConsoleAppender(new Log.BasicFormatter())); 1.137 + } 1.138 + gLogger.level = gPrefs.get(PREF_LOGGING_LEVEL, Log.Level.Warn); 1.139 + 1.140 + let logDumping = gPrefs.get(PREF_LOGGING_DUMP, false); 1.141 + if (logDumping != gLogDumping) { 1.142 + if (logDumping) { 1.143 + gLogAppenderDump = new Log.DumpAppender(new Log.BasicFormatter()); 1.144 + gLogger.addAppender(gLogAppenderDump); 1.145 + } else { 1.146 + gLogger.removeAppender(gLogAppenderDump); 1.147 + gLogAppenderDump = null; 1.148 + } 1.149 + gLogDumping = logDumping; 1.150 + } 1.151 +} 1.152 + 1.153 +// Takes an array of promises and returns a promise that is resolved once all of 1.154 +// them are rejected or resolved. 1.155 +function allResolvedOrRejected(promises) { 1.156 + if (!promises.length) { 1.157 + return Promise.resolve([]); 1.158 + } 1.159 + 1.160 + let countdown = promises.length; 1.161 + let deferred = Promise.defer(); 1.162 + 1.163 + for (let p of promises) { 1.164 + let helper = () => { 1.165 + if (--countdown == 0) { 1.166 + deferred.resolve(); 1.167 + } 1.168 + }; 1.169 + Promise.resolve(p).then(helper, helper); 1.170 + } 1.171 + 1.172 + return deferred.promise; 1.173 +} 1.174 + 1.175 +// Loads a JSON file using OS.file. file is a string representing the path 1.176 +// of the file to be read, options contains additional options to pass to 1.177 +// OS.File.read. 1.178 +// Returns a Promise resolved with the json payload or rejected with 1.179 +// OS.File.Error or JSON.parse() errors. 1.180 +function loadJSONAsync(file, options) { 1.181 + return Task.spawn(function() { 1.182 + let rawData = yield OS.File.read(file, options); 1.183 + // Read json file into a string 1.184 + let data; 1.185 + try { 1.186 + // Obtain a converter to read from a UTF-8 encoded input stream. 1.187 + let converter = new TextDecoder(); 1.188 + data = JSON.parse(converter.decode(rawData)); 1.189 + } catch (ex) { 1.190 + gLogger.error("Experiments: Could not parse JSON: " + file + " " + ex); 1.191 + throw ex; 1.192 + } 1.193 + throw new Task.Result(data); 1.194 + }); 1.195 +} 1.196 + 1.197 +function telemetryEnabled() { 1.198 + return gPrefsTelemetry.get(PREF_TELEMETRY_ENABLED, false); 1.199 +} 1.200 + 1.201 +// Returns a promise that is resolved with the AddonInstall for that URL. 1.202 +function addonInstallForURL(url, hash) { 1.203 + let deferred = Promise.defer(); 1.204 + AddonManager.getInstallForURL(url, install => deferred.resolve(install), 1.205 + "application/x-xpinstall", hash); 1.206 + return deferred.promise; 1.207 +} 1.208 + 1.209 +// Returns a promise that is resolved with an Array<Addon> of the installed 1.210 +// experiment addons. 1.211 +function installedExperimentAddons() { 1.212 + let deferred = Promise.defer(); 1.213 + AddonManager.getAddonsByTypes(["experiment"], (addons) => { 1.214 + deferred.resolve([a for (a of addons) if (!a.appDisabled)]); 1.215 + }); 1.216 + return deferred.promise; 1.217 +} 1.218 + 1.219 +// Takes an Array<Addon> and returns a promise that is resolved when the 1.220 +// addons are uninstalled. 1.221 +function uninstallAddons(addons) { 1.222 + let ids = new Set([a.id for (a of addons)]); 1.223 + let deferred = Promise.defer(); 1.224 + 1.225 + let listener = {}; 1.226 + listener.onUninstalled = addon => { 1.227 + if (!ids.has(addon.id)) { 1.228 + return; 1.229 + } 1.230 + 1.231 + ids.delete(addon.id); 1.232 + if (ids.size == 0) { 1.233 + AddonManager.removeAddonListener(listener); 1.234 + deferred.resolve(); 1.235 + } 1.236 + }; 1.237 + 1.238 + AddonManager.addAddonListener(listener); 1.239 + 1.240 + for (let addon of addons) { 1.241 + // Disabling the add-on before uninstalling is necessary to cause tests to 1.242 + // pass. This might be indicative of a bug in XPIProvider. 1.243 + // TODO follow up in bug 992396. 1.244 + addon.userDisabled = true; 1.245 + addon.uninstall(); 1.246 + } 1.247 + 1.248 + return deferred.promise; 1.249 +} 1.250 + 1.251 +/** 1.252 + * The experiments module. 1.253 + */ 1.254 + 1.255 +let Experiments = { 1.256 + /** 1.257 + * Provides access to the global `Experiments.Experiments` instance. 1.258 + */ 1.259 + instance: function () { 1.260 + if (!gExperiments) { 1.261 + gExperiments = new Experiments.Experiments(); 1.262 + } 1.263 + 1.264 + return gExperiments; 1.265 + }, 1.266 +}; 1.267 + 1.268 +/* 1.269 + * The policy object allows us to inject fake enviroment data from the 1.270 + * outside by monkey-patching. 1.271 + */ 1.272 + 1.273 +Experiments.Policy = function () { 1.274 + this._log = Log.repository.getLoggerWithMessagePrefix( 1.275 + "Browser.Experiments.Policy", 1.276 + "Policy #" + gPolicyCounter++ + "::"); 1.277 + 1.278 + // Set to true to ignore hash verification on downloaded XPIs. This should 1.279 + // not be used outside of testing. 1.280 + this.ignoreHashes = false; 1.281 +}; 1.282 + 1.283 +Experiments.Policy.prototype = { 1.284 + now: function () { 1.285 + return new Date(); 1.286 + }, 1.287 + 1.288 + random: function () { 1.289 + let pref = gPrefs.get(PREF_FORCE_SAMPLE); 1.290 + if (pref !== undefined) { 1.291 + let val = Number.parseFloat(pref); 1.292 + this._log.debug("random sample forced: " + val); 1.293 + if (isNaN(val) || val < 0) { 1.294 + return 0; 1.295 + } 1.296 + if (val > 1) { 1.297 + return 1; 1.298 + } 1.299 + return val; 1.300 + } 1.301 + return Math.random(); 1.302 + }, 1.303 + 1.304 + futureDate: function (offset) { 1.305 + return new Date(this.now().getTime() + offset); 1.306 + }, 1.307 + 1.308 + oneshotTimer: function (callback, timeout, thisObj, name) { 1.309 + return CommonUtils.namedTimer(callback, timeout, thisObj, name); 1.310 + }, 1.311 + 1.312 + updatechannel: function () { 1.313 + return UpdateChannel.get(); 1.314 + }, 1.315 + 1.316 + locale: function () { 1.317 + let chrome = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(Ci.nsIXULChromeRegistry); 1.318 + return chrome.getSelectedLocale("global"); 1.319 + }, 1.320 + 1.321 + /* 1.322 + * @return Promise<> Resolved with the payload data. 1.323 + */ 1.324 + healthReportPayload: function () { 1.325 + return Task.spawn(function*() { 1.326 + let reporter = Cc["@mozilla.org/datareporting/service;1"] 1.327 + .getService(Ci.nsISupports) 1.328 + .wrappedJSObject 1.329 + .healthReporter; 1.330 + yield reporter.onInit(); 1.331 + let payload = yield reporter.collectAndObtainJSONPayload(); 1.332 + throw new Task.Result(payload); 1.333 + }); 1.334 + }, 1.335 + 1.336 + telemetryPayload: function () { 1.337 + return TelemetryPing.getPayload(); 1.338 + }, 1.339 +}; 1.340 + 1.341 +function AlreadyShutdownError(message="already shut down") { 1.342 + this.name = "AlreadyShutdownError"; 1.343 + this.message = message; 1.344 +} 1.345 + 1.346 +AlreadyShutdownError.prototype = new Error(); 1.347 +AlreadyShutdownError.prototype.constructor = AlreadyShutdownError; 1.348 + 1.349 +/** 1.350 + * Manages the experiments and provides an interface to control them. 1.351 + */ 1.352 + 1.353 +Experiments.Experiments = function (policy=new Experiments.Policy()) { 1.354 + this._log = Log.repository.getLoggerWithMessagePrefix( 1.355 + "Browser.Experiments.Experiments", 1.356 + "Experiments #" + gExperimentsCounter++ + "::"); 1.357 + this._log.trace("constructor"); 1.358 + 1.359 + this._policy = policy; 1.360 + 1.361 + // This is a Map of (string -> ExperimentEntry), keyed with the experiment id. 1.362 + // It holds both the current experiments and history. 1.363 + // Map() preserves insertion order, which means we preserve the manifest order. 1.364 + // This is null until we've successfully completed loading the cache from 1.365 + // disk the first time. 1.366 + this._experiments = null; 1.367 + this._refresh = false; 1.368 + this._terminateReason = null; // or TELEMETRY_LOG.TERMINATION.... 1.369 + this._dirty = false; 1.370 + 1.371 + // Loading the cache happens once asynchronously on startup 1.372 + this._loadTask = null; 1.373 + 1.374 + // The _main task handles all other actions: 1.375 + // * refreshing the manifest off the network (if _refresh) 1.376 + // * disabling/enabling experiments 1.377 + // * saving the cache (if _dirty) 1.378 + this._mainTask = null; 1.379 + 1.380 + // Timer for re-evaluating experiment status. 1.381 + this._timer = null; 1.382 + 1.383 + this._shutdown = false; 1.384 + 1.385 + // We need to tell when we first evaluated the experiments to fire an 1.386 + // experiments-changed notification when we only loaded completed experiments. 1.387 + this._firstEvaluate = true; 1.388 + 1.389 + this.init(); 1.390 +}; 1.391 + 1.392 +Experiments.Experiments.prototype = { 1.393 + QueryInterface: XPCOMUtils.generateQI([Ci.nsITimerCallback, Ci.nsIObserver]), 1.394 + 1.395 + init: function () { 1.396 + this._shutdown = false; 1.397 + configureLogging(); 1.398 + 1.399 + gExperimentsEnabled = gPrefs.get(PREF_ENABLED, false); 1.400 + this._log.trace("enabled=" + gExperimentsEnabled + ", " + this.enabled); 1.401 + 1.402 + gPrefs.observe(PREF_LOGGING, configureLogging); 1.403 + gPrefs.observe(PREF_MANIFEST_URI, this.updateManifest, this); 1.404 + gPrefs.observe(PREF_ENABLED, this._toggleExperimentsEnabled, this); 1.405 + 1.406 + gPrefsTelemetry.observe(PREF_TELEMETRY_ENABLED, this._telemetryStatusChanged, this); 1.407 + 1.408 + AsyncShutdown.profileBeforeChange.addBlocker("Experiments.jsm shutdown", 1.409 + this.uninit.bind(this)); 1.410 + 1.411 + this._registerWithAddonManager(); 1.412 + 1.413 + let deferred = Promise.defer(); 1.414 + 1.415 + this._loadTask = this._loadFromCache(); 1.416 + this._loadTask.then( 1.417 + () => { 1.418 + this._log.trace("_loadTask finished ok"); 1.419 + this._loadTask = null; 1.420 + this._run().then(deferred.resolve, deferred.reject); 1.421 + }, 1.422 + (e) => { 1.423 + this._log.error("_loadFromCache caught error: " + e); 1.424 + deferred.reject(e); 1.425 + } 1.426 + ); 1.427 + 1.428 + return deferred.promise; 1.429 + }, 1.430 + 1.431 + /** 1.432 + * Uninitialize this instance. 1.433 + * 1.434 + * This function is susceptible to race conditions. If it is called multiple 1.435 + * times before the previous uninit() has completed or if it is called while 1.436 + * an init() operation is being performed, the object may get in bad state 1.437 + * and/or deadlock could occur. 1.438 + * 1.439 + * @return Promise<> 1.440 + * The promise is fulfilled when all pending tasks are finished. 1.441 + */ 1.442 + uninit: Task.async(function* () { 1.443 + this._log.trace("uninit: started"); 1.444 + yield this._loadTask; 1.445 + this._log.trace("uninit: finished with _loadTask"); 1.446 + 1.447 + if (!this._shutdown) { 1.448 + this._log.trace("uninit: no previous shutdown"); 1.449 + this._unregisterWithAddonManager(); 1.450 + 1.451 + gPrefs.ignore(PREF_LOGGING, configureLogging); 1.452 + gPrefs.ignore(PREF_MANIFEST_URI, this.updateManifest, this); 1.453 + gPrefs.ignore(PREF_ENABLED, this._toggleExperimentsEnabled, this); 1.454 + 1.455 + gPrefsTelemetry.ignore(PREF_TELEMETRY_ENABLED, this._telemetryStatusChanged, this); 1.456 + 1.457 + if (this._timer) { 1.458 + this._timer.clear(); 1.459 + } 1.460 + } 1.461 + 1.462 + this._shutdown = true; 1.463 + if (this._mainTask) { 1.464 + try { 1.465 + this._log.trace("uninit: waiting on _mainTask"); 1.466 + yield this._mainTask; 1.467 + } catch (e if e instanceof AlreadyShutdownError) { 1.468 + // We error out of tasks after shutdown via that exception. 1.469 + } 1.470 + } 1.471 + 1.472 + this._log.info("Completed uninitialization."); 1.473 + }), 1.474 + 1.475 + _registerWithAddonManager: function (previousExperimentsProvider) { 1.476 + this._log.trace("Registering instance with Addon Manager."); 1.477 + 1.478 + AddonManager.addAddonListener(this); 1.479 + AddonManager.addInstallListener(this); 1.480 + 1.481 + if (!gAddonProvider) { 1.482 + // The properties of this AddonType should be kept in sync with the 1.483 + // experiment AddonType registered in XPIProvider. 1.484 + this._log.trace("Registering previous experiment add-on provider."); 1.485 + gAddonProvider = previousExperimentsProvider || new Experiments.PreviousExperimentProvider(this); 1.486 + AddonManagerPrivate.registerProvider(gAddonProvider, [ 1.487 + new AddonManagerPrivate.AddonType("experiment", 1.488 + URI_EXTENSION_STRINGS, 1.489 + STRING_TYPE_NAME, 1.490 + AddonManager.VIEW_TYPE_LIST, 1.491 + 11000, 1.492 + AddonManager.TYPE_UI_HIDE_EMPTY), 1.493 + ]); 1.494 + } 1.495 + 1.496 + }, 1.497 + 1.498 + _unregisterWithAddonManager: function () { 1.499 + this._log.trace("Unregistering instance with Addon Manager."); 1.500 + 1.501 + if (gAddonProvider) { 1.502 + this._log.trace("Unregistering previous experiment add-on provider."); 1.503 + AddonManagerPrivate.unregisterProvider(gAddonProvider); 1.504 + gAddonProvider = null; 1.505 + } 1.506 + 1.507 + AddonManager.removeInstallListener(this); 1.508 + AddonManager.removeAddonListener(this); 1.509 + }, 1.510 + 1.511 + /* 1.512 + * Change the PreviousExperimentsProvider that this instance uses. 1.513 + * For testing only. 1.514 + */ 1.515 + _setPreviousExperimentsProvider: function (provider) { 1.516 + this._unregisterWithAddonManager(); 1.517 + this._registerWithAddonManager(provider); 1.518 + }, 1.519 + 1.520 + /** 1.521 + * Throws an exception if we've already shut down. 1.522 + */ 1.523 + _checkForShutdown: function() { 1.524 + if (this._shutdown) { 1.525 + throw new AlreadyShutdownError("uninit() already called"); 1.526 + } 1.527 + }, 1.528 + 1.529 + /** 1.530 + * Whether the experiments feature is enabled. 1.531 + */ 1.532 + get enabled() { 1.533 + return gExperimentsEnabled; 1.534 + }, 1.535 + 1.536 + /** 1.537 + * Toggle whether the experiments feature is enabled or not. 1.538 + */ 1.539 + set enabled(enabled) { 1.540 + this._log.trace("set enabled(" + enabled + ")"); 1.541 + gPrefs.set(PREF_ENABLED, enabled); 1.542 + }, 1.543 + 1.544 + _toggleExperimentsEnabled: Task.async(function* (enabled) { 1.545 + this._log.trace("_toggleExperimentsEnabled(" + enabled + ")"); 1.546 + let wasEnabled = gExperimentsEnabled; 1.547 + gExperimentsEnabled = enabled && telemetryEnabled(); 1.548 + 1.549 + if (wasEnabled == gExperimentsEnabled) { 1.550 + return; 1.551 + } 1.552 + 1.553 + if (gExperimentsEnabled) { 1.554 + yield this.updateManifest(); 1.555 + } else { 1.556 + yield this.disableExperiment(TELEMETRY_LOG.TERMINATION.SERVICE_DISABLED); 1.557 + if (this._timer) { 1.558 + this._timer.clear(); 1.559 + } 1.560 + } 1.561 + }), 1.562 + 1.563 + _telemetryStatusChanged: function () { 1.564 + this._toggleExperimentsEnabled(gExperimentsEnabled); 1.565 + }, 1.566 + 1.567 + /** 1.568 + * Returns a promise that is resolved with an array of `ExperimentInfo` objects, 1.569 + * which provide info on the currently and recently active experiments. 1.570 + * The array is in chronological order. 1.571 + * 1.572 + * The experiment info is of the form: 1.573 + * { 1.574 + * id: <string>, 1.575 + * name: <string>, 1.576 + * description: <string>, 1.577 + * active: <boolean>, 1.578 + * endDate: <integer>, // epoch ms 1.579 + * detailURL: <string>, 1.580 + * ... // possibly extended later 1.581 + * } 1.582 + * 1.583 + * @return Promise<Array<ExperimentInfo>> Array of experiment info objects. 1.584 + */ 1.585 + getExperiments: function () { 1.586 + return Task.spawn(function*() { 1.587 + yield this._loadTask; 1.588 + let list = []; 1.589 + 1.590 + for (let [id, experiment] of this._experiments) { 1.591 + if (!experiment.startDate) { 1.592 + // We only collect experiments that are or were active. 1.593 + continue; 1.594 + } 1.595 + 1.596 + list.push({ 1.597 + id: id, 1.598 + name: experiment._name, 1.599 + description: experiment._description, 1.600 + active: experiment.enabled, 1.601 + endDate: experiment.endDate.getTime(), 1.602 + detailURL: experiment._homepageURL, 1.603 + branch: experiment.branch, 1.604 + }); 1.605 + } 1.606 + 1.607 + // Sort chronologically, descending. 1.608 + list.sort((a, b) => b.endDate - a.endDate); 1.609 + return list; 1.610 + }.bind(this)); 1.611 + }, 1.612 + 1.613 + /** 1.614 + * Returns the ExperimentInfo for the active experiment, or null 1.615 + * if there is none. 1.616 + */ 1.617 + getActiveExperiment: function () { 1.618 + let experiment = this._getActiveExperiment(); 1.619 + if (!experiment) { 1.620 + return null; 1.621 + } 1.622 + 1.623 + let info = { 1.624 + id: experiment.id, 1.625 + name: experiment._name, 1.626 + description: experiment._description, 1.627 + active: experiment.enabled, 1.628 + endDate: experiment.endDate.getTime(), 1.629 + detailURL: experiment._homepageURL, 1.630 + }; 1.631 + 1.632 + return info; 1.633 + }, 1.634 + 1.635 + /** 1.636 + * Experiment "branch" support. If an experiment has multiple branches, it 1.637 + * can record the branch with the experiment system and it will 1.638 + * automatically be included in data reporting (FHR/telemetry payloads). 1.639 + */ 1.640 + 1.641 + /** 1.642 + * Set the experiment branch for the specified experiment ID. 1.643 + * @returns Promise<> 1.644 + */ 1.645 + setExperimentBranch: Task.async(function*(id, branchstr) { 1.646 + yield this._loadTask; 1.647 + let e = this._experiments.get(id); 1.648 + if (!e) { 1.649 + throw new Error("Experiment not found"); 1.650 + } 1.651 + e.branch = String(branchstr); 1.652 + this._dirty = true; 1.653 + Services.obs.notifyObservers(null, EXPERIMENTS_CHANGED_TOPIC, null); 1.654 + yield this._run(); 1.655 + }), 1.656 + /** 1.657 + * Get the branch of the specified experiment. If the experiment is unknown, 1.658 + * throws an error. 1.659 + * 1.660 + * @param id The ID of the experiment. Pass null for the currently running 1.661 + * experiment. 1.662 + * @returns Promise<string|null> 1.663 + * @throws Error if the specified experiment ID is unknown, or if there is no 1.664 + * current experiment. 1.665 + */ 1.666 + getExperimentBranch: Task.async(function*(id=null) { 1.667 + yield this._loadTask; 1.668 + let e; 1.669 + if (id) { 1.670 + e = this._experiments.get(id); 1.671 + if (!e) { 1.672 + throw new Error("Experiment not found"); 1.673 + } 1.674 + } else { 1.675 + e = this._getActiveExperiment(); 1.676 + if (e === null) { 1.677 + throw new Error("No active experiment"); 1.678 + } 1.679 + } 1.680 + return e.branch; 1.681 + }), 1.682 + 1.683 + /** 1.684 + * Determine whether another date has the same UTC day as now(). 1.685 + */ 1.686 + _dateIsTodayUTC: function (d) { 1.687 + let now = this._policy.now(); 1.688 + 1.689 + return stripDateToMidnight(now).getTime() == stripDateToMidnight(d).getTime(); 1.690 + }, 1.691 + 1.692 + /** 1.693 + * Obtain the entry of the most recent active experiment that was active 1.694 + * today. 1.695 + * 1.696 + * If no experiment was active today, this resolves to nothing. 1.697 + * 1.698 + * Assumption: Only a single experiment can be active at a time. 1.699 + * 1.700 + * @return Promise<object> 1.701 + */ 1.702 + lastActiveToday: function () { 1.703 + return Task.spawn(function* getMostRecentActiveExperimentTask() { 1.704 + let experiments = yield this.getExperiments(); 1.705 + 1.706 + // Assumption: Ordered chronologically, descending, with active always 1.707 + // first. 1.708 + for (let experiment of experiments) { 1.709 + if (experiment.active) { 1.710 + return experiment; 1.711 + } 1.712 + 1.713 + if (experiment.endDate && this._dateIsTodayUTC(experiment.endDate)) { 1.714 + return experiment; 1.715 + } 1.716 + } 1.717 + return null; 1.718 + }.bind(this)); 1.719 + }, 1.720 + 1.721 + _run: function() { 1.722 + this._log.trace("_run"); 1.723 + this._checkForShutdown(); 1.724 + if (!this._mainTask) { 1.725 + this._mainTask = Task.spawn(this._main.bind(this)); 1.726 + this._mainTask.then( 1.727 + () => { 1.728 + this._log.trace("_main finished, scheduling next run"); 1.729 + this._mainTask = null; 1.730 + this._scheduleNextRun(); 1.731 + }, 1.732 + (e) => { 1.733 + this._log.error("_main caught error: " + e); 1.734 + this._mainTask = null; 1.735 + } 1.736 + ); 1.737 + } 1.738 + return this._mainTask; 1.739 + }, 1.740 + 1.741 + _main: function*() { 1.742 + do { 1.743 + this._log.trace("_main iteration"); 1.744 + yield this._loadTask; 1.745 + if (!gExperimentsEnabled) { 1.746 + this._refresh = false; 1.747 + } 1.748 + 1.749 + if (this._refresh) { 1.750 + yield this._loadManifest(); 1.751 + } 1.752 + yield this._evaluateExperiments(); 1.753 + if (this._dirty) { 1.754 + yield this._saveToCache(); 1.755 + } 1.756 + // If somebody called .updateManifest() or disableExperiment() 1.757 + // while we were running, go again right now. 1.758 + } 1.759 + while (this._refresh || this._terminateReason); 1.760 + }, 1.761 + 1.762 + _loadManifest: function*() { 1.763 + this._log.trace("_loadManifest"); 1.764 + let uri = Services.urlFormatter.formatURLPref(PREF_BRANCH + PREF_MANIFEST_URI); 1.765 + 1.766 + this._checkForShutdown(); 1.767 + 1.768 + this._refresh = false; 1.769 + try { 1.770 + let responseText = yield this._httpGetRequest(uri); 1.771 + this._log.trace("_loadManifest() - responseText=\"" + responseText + "\""); 1.772 + 1.773 + if (this._shutdown) { 1.774 + return; 1.775 + } 1.776 + 1.777 + let data = JSON.parse(responseText); 1.778 + this._updateExperiments(data); 1.779 + } catch (e) { 1.780 + this._log.error("_loadManifest - failure to fetch/parse manifest (continuing anyway): " + e); 1.781 + } 1.782 + }, 1.783 + 1.784 + /** 1.785 + * Fetch an updated list of experiments and trigger experiment updates. 1.786 + * Do only use when experiments are enabled. 1.787 + * 1.788 + * @return Promise<> 1.789 + * The promise is resolved when the manifest and experiment list is updated. 1.790 + */ 1.791 + updateManifest: function () { 1.792 + this._log.trace("updateManifest()"); 1.793 + 1.794 + if (!gExperimentsEnabled) { 1.795 + return Promise.reject(new Error("experiments are disabled")); 1.796 + } 1.797 + 1.798 + if (this._shutdown) { 1.799 + return Promise.reject(Error("uninit() alrady called")); 1.800 + } 1.801 + 1.802 + this._refresh = true; 1.803 + return this._run(); 1.804 + }, 1.805 + 1.806 + notify: function (timer) { 1.807 + this._log.trace("notify()"); 1.808 + this._checkForShutdown(); 1.809 + return this._run(); 1.810 + }, 1.811 + 1.812 + // START OF ADD-ON LISTENERS 1.813 + 1.814 + onUninstalled: function (addon) { 1.815 + this._log.trace("onUninstalled() - addon id: " + addon.id); 1.816 + if (gActiveUninstallAddonIDs.has(addon.id)) { 1.817 + this._log.trace("matches pending uninstall"); 1.818 + return; 1.819 + } 1.820 + let activeExperiment = this._getActiveExperiment(); 1.821 + if (!activeExperiment || activeExperiment._addonId != addon.id) { 1.822 + return; 1.823 + } 1.824 + 1.825 + this.disableExperiment(TELEMETRY_LOG.TERMINATION.ADDON_UNINSTALLED); 1.826 + }, 1.827 + 1.828 + onInstallStarted: function (install) { 1.829 + if (install.addon.type != "experiment") { 1.830 + return; 1.831 + } 1.832 + 1.833 + this._log.trace("onInstallStarted() - " + install.addon.id); 1.834 + if (install.addon.appDisabled) { 1.835 + // This is a PreviousExperiment 1.836 + return; 1.837 + } 1.838 + 1.839 + // We want to be in control of all experiment add-ons: reject installs 1.840 + // for add-ons that we don't know about. 1.841 + 1.842 + // We have a race condition of sorts to worry about here. We have 2 1.843 + // onInstallStarted listeners. This one (the global one) and the one 1.844 + // created as part of ExperimentEntry._installAddon. Because of the order 1.845 + // they are registered in, this one likely executes first. Unfortunately, 1.846 + // this means that the add-on ID is not yet set on the ExperimentEntry. 1.847 + // So, we can't just look at this._trackedAddonIds because the new experiment 1.848 + // will have its add-on ID set to null. We work around this by storing a 1.849 + // identifying field - the source URL of the install - in a module-level 1.850 + // variable (so multiple Experiments instances doesn't cancel each other 1.851 + // out). 1.852 + 1.853 + if (this._trackedAddonIds.has(install.addon.id)) { 1.854 + this._log.info("onInstallStarted allowing install because add-on ID " + 1.855 + "tracked by us."); 1.856 + return; 1.857 + } 1.858 + 1.859 + if (gActiveInstallURLs.has(install.sourceURI.spec)) { 1.860 + this._log.info("onInstallStarted allowing install because install " + 1.861 + "tracked by us."); 1.862 + return; 1.863 + } 1.864 + 1.865 + this._log.warn("onInstallStarted cancelling install of unknown " + 1.866 + "experiment add-on: " + install.addon.id); 1.867 + return false; 1.868 + }, 1.869 + 1.870 + // END OF ADD-ON LISTENERS. 1.871 + 1.872 + _getExperimentByAddonId: function (addonId) { 1.873 + for (let [, entry] of this._experiments) { 1.874 + if (entry._addonId === addonId) { 1.875 + return entry; 1.876 + } 1.877 + } 1.878 + 1.879 + return null; 1.880 + }, 1.881 + 1.882 + /* 1.883 + * Helper function to make HTTP GET requests. Returns a promise that is resolved with 1.884 + * the responseText when the request is complete. 1.885 + */ 1.886 + _httpGetRequest: function (url) { 1.887 + this._log.trace("httpGetRequest(" + url + ")"); 1.888 + let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].createInstance(Ci.nsIXMLHttpRequest); 1.889 + try { 1.890 + xhr.open("GET", url); 1.891 + } catch (e) { 1.892 + this._log.error("httpGetRequest() - Error opening request to " + url + ": " + e); 1.893 + return Promise.reject(new Error("Experiments - Error opening XHR for " + url)); 1.894 + } 1.895 + 1.896 + let deferred = Promise.defer(); 1.897 + 1.898 + let log = this._log; 1.899 + xhr.onerror = function (e) { 1.900 + log.error("httpGetRequest::onError() - Error making request to " + url + ": " + e.error); 1.901 + deferred.reject(new Error("Experiments - XHR error for " + url + " - " + e.error)); 1.902 + }; 1.903 + 1.904 + xhr.onload = function (event) { 1.905 + if (xhr.status !== 200 && xhr.state !== 0) { 1.906 + log.error("httpGetRequest::onLoad() - Request to " + url + " returned status " + xhr.status); 1.907 + deferred.reject(new Error("Experiments - XHR status for " + url + " is " + xhr.status)); 1.908 + return; 1.909 + } 1.910 + 1.911 + let certs = null; 1.912 + if (gPrefs.get(PREF_MANIFEST_CHECKCERT, true)) { 1.913 + certs = CertUtils.readCertPrefs(PREF_BRANCH + "manifest.certs."); 1.914 + } 1.915 + try { 1.916 + let allowNonBuiltin = !gPrefs.get(PREF_MANIFEST_REQUIREBUILTIN, true); 1.917 + CertUtils.checkCert(xhr.channel, allowNonBuiltin, certs); 1.918 + } 1.919 + catch (e) { 1.920 + log.error("manifest fetch failed certificate checks", [e]); 1.921 + deferred.reject(new Error("Experiments - manifest fetch failed certificate checks: " + e)); 1.922 + return; 1.923 + } 1.924 + 1.925 + deferred.resolve(xhr.responseText); 1.926 + }; 1.927 + 1.928 + if (xhr.channel instanceof Ci.nsISupportsPriority) { 1.929 + xhr.channel.priority = Ci.nsISupportsPriority.PRIORITY_LOWEST; 1.930 + } 1.931 + 1.932 + xhr.send(null); 1.933 + return deferred.promise; 1.934 + }, 1.935 + 1.936 + /* 1.937 + * Path of the cache file we use in the profile. 1.938 + */ 1.939 + get _cacheFilePath() { 1.940 + return OS.Path.join(OS.Constants.Path.profileDir, FILE_CACHE); 1.941 + }, 1.942 + 1.943 + /* 1.944 + * Part of the main task to save the cache to disk, called from _main. 1.945 + */ 1.946 + _saveToCache: function* () { 1.947 + this._log.trace("_saveToCache"); 1.948 + let path = this._cacheFilePath; 1.949 + let textData = JSON.stringify({ 1.950 + version: CACHE_VERSION, 1.951 + data: [e[1].toJSON() for (e of this._experiments.entries())], 1.952 + }); 1.953 + 1.954 + let encoder = new TextEncoder(); 1.955 + let data = encoder.encode(textData); 1.956 + let options = { tmpPath: path + ".tmp", compression: "lz4" }; 1.957 + yield OS.File.writeAtomic(path, data, options); 1.958 + this._dirty = false; 1.959 + this._log.debug("_saveToCache saved to " + path); 1.960 + }, 1.961 + 1.962 + /* 1.963 + * Task function, load the cached experiments manifest file from disk. 1.964 + */ 1.965 + _loadFromCache: Task.async(function* () { 1.966 + this._log.trace("_loadFromCache"); 1.967 + let path = this._cacheFilePath; 1.968 + try { 1.969 + let result = yield loadJSONAsync(path, { compression: "lz4" }); 1.970 + this._populateFromCache(result); 1.971 + } catch (e if e instanceof OS.File.Error && e.becauseNoSuchFile) { 1.972 + // No cached manifest yet. 1.973 + this._experiments = new Map(); 1.974 + } 1.975 + }), 1.976 + 1.977 + _populateFromCache: function (data) { 1.978 + this._log.trace("populateFromCache() - data: " + JSON.stringify(data)); 1.979 + 1.980 + // If the user has a newer cache version than we can understand, we fail 1.981 + // hard; no experiments should be active in this older client. 1.982 + if (CACHE_VERSION !== data.version) { 1.983 + throw new Error("Experiments::_populateFromCache() - invalid cache version"); 1.984 + } 1.985 + 1.986 + let experiments = new Map(); 1.987 + for (let item of data.data) { 1.988 + let entry = new Experiments.ExperimentEntry(this._policy); 1.989 + if (!entry.initFromCacheData(item)) { 1.990 + continue; 1.991 + } 1.992 + experiments.set(entry.id, entry); 1.993 + } 1.994 + 1.995 + this._experiments = experiments; 1.996 + }, 1.997 + 1.998 + /* 1.999 + * Update the experiment entries from the experiments 1.1000 + * array in the manifest 1.1001 + */ 1.1002 + _updateExperiments: function (manifestObject) { 1.1003 + this._log.trace("_updateExperiments() - experiments: " + JSON.stringify(manifestObject)); 1.1004 + 1.1005 + if (manifestObject.version !== MANIFEST_VERSION) { 1.1006 + this._log.warning("updateExperiments() - unsupported version " + manifestObject.version); 1.1007 + } 1.1008 + 1.1009 + let experiments = new Map(); // The new experiments map 1.1010 + 1.1011 + // Collect new and updated experiments. 1.1012 + for (let data of manifestObject.experiments) { 1.1013 + let entry = this._experiments.get(data.id); 1.1014 + 1.1015 + if (entry) { 1.1016 + if (!entry.updateFromManifestData(data)) { 1.1017 + this._log.error("updateExperiments() - Invalid manifest data for " + data.id); 1.1018 + continue; 1.1019 + } 1.1020 + } else { 1.1021 + entry = new Experiments.ExperimentEntry(this._policy); 1.1022 + if (!entry.initFromManifestData(data)) { 1.1023 + continue; 1.1024 + } 1.1025 + } 1.1026 + 1.1027 + if (entry.shouldDiscard()) { 1.1028 + continue; 1.1029 + } 1.1030 + 1.1031 + experiments.set(entry.id, entry); 1.1032 + } 1.1033 + 1.1034 + // Make sure we keep experiments that are or were running. 1.1035 + // We remove them after KEEP_HISTORY_N_DAYS. 1.1036 + for (let [id, entry] of this._experiments) { 1.1037 + if (experiments.has(id)) { 1.1038 + continue; 1.1039 + } 1.1040 + 1.1041 + if (!entry.startDate || entry.shouldDiscard()) { 1.1042 + this._log.trace("updateExperiments() - discarding entry for " + id); 1.1043 + continue; 1.1044 + } 1.1045 + 1.1046 + experiments.set(id, entry); 1.1047 + } 1.1048 + 1.1049 + this._experiments = experiments; 1.1050 + this._dirty = true; 1.1051 + }, 1.1052 + 1.1053 + getActiveExperimentID: function() { 1.1054 + if (!this._experiments) { 1.1055 + return null; 1.1056 + } 1.1057 + let e = this._getActiveExperiment(); 1.1058 + if (!e) { 1.1059 + return null; 1.1060 + } 1.1061 + return e.id; 1.1062 + }, 1.1063 + 1.1064 + getActiveExperimentBranch: function() { 1.1065 + if (!this._experiments) { 1.1066 + return null; 1.1067 + } 1.1068 + let e = this._getActiveExperiment(); 1.1069 + if (!e) { 1.1070 + return null; 1.1071 + } 1.1072 + return e.branch; 1.1073 + }, 1.1074 + 1.1075 + _getActiveExperiment: function () { 1.1076 + let enabled = [experiment for ([,experiment] of this._experiments) if (experiment._enabled)]; 1.1077 + 1.1078 + if (enabled.length == 1) { 1.1079 + return enabled[0]; 1.1080 + } 1.1081 + 1.1082 + if (enabled.length > 1) { 1.1083 + this._log.error("getActiveExperimentId() - should not have more than 1 active experiment"); 1.1084 + throw new Error("have more than 1 active experiment"); 1.1085 + } 1.1086 + 1.1087 + return null; 1.1088 + }, 1.1089 + 1.1090 + /** 1.1091 + * Disables all active experiments. 1.1092 + * 1.1093 + * @return Promise<> Promise that will get resolved once the task is done or failed. 1.1094 + */ 1.1095 + disableExperiment: function (reason) { 1.1096 + if (!reason) { 1.1097 + throw new Error("Must specify a termination reason."); 1.1098 + } 1.1099 + 1.1100 + this._log.trace("disableExperiment()"); 1.1101 + this._terminateReason = reason; 1.1102 + return this._run(); 1.1103 + }, 1.1104 + 1.1105 + /** 1.1106 + * The Set of add-on IDs that we know about from manifests. 1.1107 + */ 1.1108 + get _trackedAddonIds() { 1.1109 + if (!this._experiments) { 1.1110 + return new Set(); 1.1111 + } 1.1112 + 1.1113 + return new Set([e._addonId for ([,e] of this._experiments) if (e._addonId)]); 1.1114 + }, 1.1115 + 1.1116 + /* 1.1117 + * Task function to check applicability of experiments, disable the active 1.1118 + * experiment if needed and activate the first applicable candidate. 1.1119 + */ 1.1120 + _evaluateExperiments: function*() { 1.1121 + this._log.trace("_evaluateExperiments"); 1.1122 + 1.1123 + this._checkForShutdown(); 1.1124 + 1.1125 + // The first thing we do is reconcile our state against what's in the 1.1126 + // Addon Manager. It's possible that the Addon Manager knows of experiment 1.1127 + // add-ons that we don't. This could happen if an experiment gets installed 1.1128 + // when we're not listening or if there is a bug in our synchronization 1.1129 + // code. 1.1130 + // 1.1131 + // We have a few options of what to do with unknown experiment add-ons 1.1132 + // coming from the Addon Manager. Ideally, we'd convert these to 1.1133 + // ExperimentEntry instances and stuff them inside this._experiments. 1.1134 + // However, since ExperimentEntry contain lots of metadata from the 1.1135 + // manifest and trying to make up data could be error prone, it's safer 1.1136 + // to not try. Furthermore, if an experiment really did come from us, we 1.1137 + // should have some record of it. In the end, we decide to discard all 1.1138 + // knowledge for these unknown experiment add-ons. 1.1139 + let installedExperiments = yield installedExperimentAddons(); 1.1140 + let expectedAddonIds = this._trackedAddonIds; 1.1141 + let unknownAddons = [a for (a of installedExperiments) if (!expectedAddonIds.has(a.id))]; 1.1142 + if (unknownAddons.length) { 1.1143 + this._log.warn("_evaluateExperiments() - unknown add-ons in AddonManager: " + 1.1144 + [a.id for (a of unknownAddons)].join(", ")); 1.1145 + 1.1146 + yield uninstallAddons(unknownAddons); 1.1147 + } 1.1148 + 1.1149 + let activeExperiment = this._getActiveExperiment(); 1.1150 + let activeChanged = false; 1.1151 + let now = this._policy.now(); 1.1152 + 1.1153 + if (!activeExperiment) { 1.1154 + // Avoid this pref staying out of sync if there were e.g. crashes. 1.1155 + gPrefs.set(PREF_ACTIVE_EXPERIMENT, false); 1.1156 + } 1.1157 + 1.1158 + // Ensure the active experiment is in the proper state. This may install, 1.1159 + // uninstall, upgrade, or enable the experiment add-on. What exactly is 1.1160 + // abstracted away from us by design. 1.1161 + if (activeExperiment) { 1.1162 + let changes; 1.1163 + let shouldStopResult = yield activeExperiment.shouldStop(); 1.1164 + if (shouldStopResult.shouldStop) { 1.1165 + let expireReasons = ["endTime", "maxActiveSeconds"]; 1.1166 + let kind, reason; 1.1167 + 1.1168 + if (expireReasons.indexOf(shouldStopResult.reason[0]) != -1) { 1.1169 + kind = TELEMETRY_LOG.TERMINATION.EXPIRED; 1.1170 + reason = null; 1.1171 + } else { 1.1172 + kind = TELEMETRY_LOG.TERMINATION.RECHECK; 1.1173 + reason = shouldStopResult.reason; 1.1174 + } 1.1175 + changes = yield activeExperiment.stop(kind, reason); 1.1176 + } 1.1177 + else if (this._terminateReason) { 1.1178 + changes = yield activeExperiment.stop(this._terminateReason); 1.1179 + } 1.1180 + else { 1.1181 + changes = yield activeExperiment.reconcileAddonState(); 1.1182 + } 1.1183 + 1.1184 + if (changes) { 1.1185 + this._dirty = true; 1.1186 + activeChanged = true; 1.1187 + } 1.1188 + 1.1189 + if (!activeExperiment._enabled) { 1.1190 + activeExperiment = null; 1.1191 + activeChanged = true; 1.1192 + } 1.1193 + } 1.1194 + 1.1195 + this._terminateReason = null; 1.1196 + 1.1197 + if (!activeExperiment && gExperimentsEnabled) { 1.1198 + for (let [id, experiment] of this._experiments) { 1.1199 + let applicable; 1.1200 + let reason = null; 1.1201 + try { 1.1202 + applicable = yield experiment.isApplicable(); 1.1203 + } 1.1204 + catch (e) { 1.1205 + applicable = false; 1.1206 + reason = e; 1.1207 + } 1.1208 + 1.1209 + if (!applicable && reason && reason[0] != "was-active") { 1.1210 + // Report this from here to avoid over-reporting. 1.1211 + let desc = TELEMETRY_LOG.ACTIVATION; 1.1212 + let data = [TELEMETRY_LOG.ACTIVATION.REJECTED, id]; 1.1213 + data = data.concat(reason); 1.1214 + TelemetryLog.log(TELEMETRY_LOG.ACTIVATION_KEY, data); 1.1215 + } 1.1216 + 1.1217 + if (!applicable) { 1.1218 + continue; 1.1219 + } 1.1220 + 1.1221 + this._log.debug("evaluateExperiments() - activating experiment " + id); 1.1222 + try { 1.1223 + yield experiment.start(); 1.1224 + activeChanged = true; 1.1225 + activeExperiment = experiment; 1.1226 + this._dirty = true; 1.1227 + break; 1.1228 + } catch (e) { 1.1229 + // On failure, clean up the best we can and try the next experiment. 1.1230 + this._log.error("evaluateExperiments() - Unable to start experiment: " + e.message); 1.1231 + experiment._enabled = false; 1.1232 + yield experiment.reconcileAddonState(); 1.1233 + } 1.1234 + } 1.1235 + } 1.1236 + 1.1237 + gPrefs.set(PREF_ACTIVE_EXPERIMENT, activeExperiment != null); 1.1238 + 1.1239 + if (activeChanged || this._firstEvaluate) { 1.1240 + Services.obs.notifyObservers(null, EXPERIMENTS_CHANGED_TOPIC, null); 1.1241 + this._firstEvaluate = false; 1.1242 + } 1.1243 + 1.1244 + if ("@mozilla.org/toolkit/crash-reporter;1" in Cc && activeExperiment) { 1.1245 + try { 1.1246 + gCrashReporter.annotateCrashReport("ActiveExperiment", activeExperiment.id); 1.1247 + } catch (e) { 1.1248 + // It's ok if crash reporting is disabled. 1.1249 + } 1.1250 + } 1.1251 + }, 1.1252 + 1.1253 + /* 1.1254 + * Schedule the soonest re-check of experiment applicability that is needed. 1.1255 + */ 1.1256 + _scheduleNextRun: function () { 1.1257 + this._checkForShutdown(); 1.1258 + 1.1259 + if (this._timer) { 1.1260 + this._timer.clear(); 1.1261 + } 1.1262 + 1.1263 + if (!gExperimentsEnabled || this._experiments.length == 0) { 1.1264 + return; 1.1265 + } 1.1266 + 1.1267 + let time = null; 1.1268 + let now = this._policy.now().getTime(); 1.1269 + 1.1270 + for (let [id, experiment] of this._experiments) { 1.1271 + let scheduleTime = experiment.getScheduleTime(); 1.1272 + if (scheduleTime > now) { 1.1273 + if (time !== null) { 1.1274 + time = Math.min(time, scheduleTime); 1.1275 + } else { 1.1276 + time = scheduleTime; 1.1277 + } 1.1278 + } 1.1279 + } 1.1280 + 1.1281 + if (time === null) { 1.1282 + // No schedule time found. 1.1283 + return; 1.1284 + } 1.1285 + 1.1286 + this._log.trace("scheduleExperimentEvaluation() - scheduling for "+time+", now: "+now); 1.1287 + this._policy.oneshotTimer(this.notify, time - now, this, "_timer"); 1.1288 + }, 1.1289 +}; 1.1290 + 1.1291 + 1.1292 +/* 1.1293 + * Represents a single experiment. 1.1294 + */ 1.1295 + 1.1296 +Experiments.ExperimentEntry = function (policy) { 1.1297 + this._policy = policy || new Experiments.Policy(); 1.1298 + this._log = Log.repository.getLoggerWithMessagePrefix( 1.1299 + "Browser.Experiments.Experiments", 1.1300 + "ExperimentEntry #" + gExperimentEntryCounter++ + "::"); 1.1301 + 1.1302 + // Is the experiment supposed to be running. 1.1303 + this._enabled = false; 1.1304 + // When this experiment was started, if ever. 1.1305 + this._startDate = null; 1.1306 + // When this experiment was ended, if ever. 1.1307 + this._endDate = null; 1.1308 + // The condition data from the manifest. 1.1309 + this._manifestData = null; 1.1310 + // For an active experiment, signifies whether we need to update the xpi. 1.1311 + this._needsUpdate = false; 1.1312 + // A random sample value for comparison against the manifest conditions. 1.1313 + this._randomValue = null; 1.1314 + // When this entry was last changed for respecting history retention duration. 1.1315 + this._lastChangedDate = null; 1.1316 + // Has this experiment failed to activate before? 1.1317 + this._failedStart = false; 1.1318 + // The experiment branch 1.1319 + this._branch = null; 1.1320 + 1.1321 + // We grab these from the addon after download. 1.1322 + this._name = null; 1.1323 + this._description = null; 1.1324 + this._homepageURL = null; 1.1325 + this._addonId = null; 1.1326 +}; 1.1327 + 1.1328 +Experiments.ExperimentEntry.prototype = { 1.1329 + MANIFEST_REQUIRED_FIELDS: new Set([ 1.1330 + "id", 1.1331 + "xpiURL", 1.1332 + "xpiHash", 1.1333 + "startTime", 1.1334 + "endTime", 1.1335 + "maxActiveSeconds", 1.1336 + "appName", 1.1337 + "channel", 1.1338 + ]), 1.1339 + 1.1340 + MANIFEST_OPTIONAL_FIELDS: new Set([ 1.1341 + "maxStartTime", 1.1342 + "minVersion", 1.1343 + "maxVersion", 1.1344 + "version", 1.1345 + "minBuildID", 1.1346 + "maxBuildID", 1.1347 + "buildIDs", 1.1348 + "os", 1.1349 + "locale", 1.1350 + "sample", 1.1351 + "disabled", 1.1352 + "frozen", 1.1353 + "jsfilter", 1.1354 + ]), 1.1355 + 1.1356 + SERIALIZE_KEYS: new Set([ 1.1357 + "_enabled", 1.1358 + "_manifestData", 1.1359 + "_needsUpdate", 1.1360 + "_randomValue", 1.1361 + "_failedStart", 1.1362 + "_name", 1.1363 + "_description", 1.1364 + "_homepageURL", 1.1365 + "_addonId", 1.1366 + "_startDate", 1.1367 + "_endDate", 1.1368 + "_branch", 1.1369 + ]), 1.1370 + 1.1371 + DATE_KEYS: new Set([ 1.1372 + "_startDate", 1.1373 + "_endDate", 1.1374 + ]), 1.1375 + 1.1376 + UPGRADE_KEYS: new Map([ 1.1377 + ["_branch", null], 1.1378 + ]), 1.1379 + 1.1380 + ADDON_CHANGE_NONE: 0, 1.1381 + ADDON_CHANGE_INSTALL: 1, 1.1382 + ADDON_CHANGE_UNINSTALL: 2, 1.1383 + ADDON_CHANGE_ENABLE: 4, 1.1384 + 1.1385 + /* 1.1386 + * Initialize entry from the manifest. 1.1387 + * @param data The experiment data from the manifest. 1.1388 + * @return boolean Whether initialization succeeded. 1.1389 + */ 1.1390 + initFromManifestData: function (data) { 1.1391 + if (!this._isManifestDataValid(data)) { 1.1392 + return false; 1.1393 + } 1.1394 + 1.1395 + this._manifestData = data; 1.1396 + 1.1397 + this._randomValue = this._policy.random(); 1.1398 + this._lastChangedDate = this._policy.now(); 1.1399 + 1.1400 + return true; 1.1401 + }, 1.1402 + 1.1403 + get enabled() { 1.1404 + return this._enabled; 1.1405 + }, 1.1406 + 1.1407 + get id() { 1.1408 + return this._manifestData.id; 1.1409 + }, 1.1410 + 1.1411 + get branch() { 1.1412 + return this._branch; 1.1413 + }, 1.1414 + 1.1415 + set branch(v) { 1.1416 + this._branch = v; 1.1417 + }, 1.1418 + 1.1419 + get startDate() { 1.1420 + return this._startDate; 1.1421 + }, 1.1422 + 1.1423 + get endDate() { 1.1424 + if (!this._startDate) { 1.1425 + return null; 1.1426 + } 1.1427 + 1.1428 + let endTime = 0; 1.1429 + 1.1430 + if (!this._enabled) { 1.1431 + return this._endDate; 1.1432 + } 1.1433 + 1.1434 + let maxActiveMs = 1000 * this._manifestData.maxActiveSeconds; 1.1435 + endTime = Math.min(1000 * this._manifestData.endTime, 1.1436 + this._startDate.getTime() + maxActiveMs); 1.1437 + 1.1438 + return new Date(endTime); 1.1439 + }, 1.1440 + 1.1441 + get needsUpdate() { 1.1442 + return this._needsUpdate; 1.1443 + }, 1.1444 + 1.1445 + /* 1.1446 + * Initialize entry from the cache. 1.1447 + * @param data The entry data from the cache. 1.1448 + * @return boolean Whether initialization succeeded. 1.1449 + */ 1.1450 + initFromCacheData: function (data) { 1.1451 + for (let [key, dval] of this.UPGRADE_KEYS) { 1.1452 + if (!(key in data)) { 1.1453 + data[key] = dval; 1.1454 + } 1.1455 + } 1.1456 + 1.1457 + for (let key of this.SERIALIZE_KEYS) { 1.1458 + if (!(key in data) && !this.DATE_KEYS.has(key)) { 1.1459 + this._log.error("initFromCacheData() - missing required key " + key); 1.1460 + return false; 1.1461 + } 1.1462 + }; 1.1463 + 1.1464 + if (!this._isManifestDataValid(data._manifestData)) { 1.1465 + return false; 1.1466 + } 1.1467 + 1.1468 + // Dates are restored separately from epoch ms, everything else is just 1.1469 + // copied in. 1.1470 + 1.1471 + this.SERIALIZE_KEYS.forEach(key => { 1.1472 + if (!this.DATE_KEYS.has(key)) { 1.1473 + this[key] = data[key]; 1.1474 + } 1.1475 + }); 1.1476 + 1.1477 + this.DATE_KEYS.forEach(key => { 1.1478 + if (key in data) { 1.1479 + let date = new Date(); 1.1480 + date.setTime(data[key]); 1.1481 + this[key] = date; 1.1482 + } 1.1483 + }); 1.1484 + 1.1485 + this._lastChangedDate = this._policy.now(); 1.1486 + 1.1487 + return true; 1.1488 + }, 1.1489 + 1.1490 + /* 1.1491 + * Returns a JSON representation of this object. 1.1492 + */ 1.1493 + toJSON: function () { 1.1494 + let obj = {}; 1.1495 + 1.1496 + // Dates are serialized separately as epoch ms. 1.1497 + 1.1498 + this.SERIALIZE_KEYS.forEach(key => { 1.1499 + if (!this.DATE_KEYS.has(key)) { 1.1500 + obj[key] = this[key]; 1.1501 + } 1.1502 + }); 1.1503 + 1.1504 + this.DATE_KEYS.forEach(key => { 1.1505 + if (this[key]) { 1.1506 + obj[key] = this[key].getTime(); 1.1507 + } 1.1508 + }); 1.1509 + 1.1510 + return obj; 1.1511 + }, 1.1512 + 1.1513 + /* 1.1514 + * Update from the experiment data from the manifest. 1.1515 + * @param data The experiment data from the manifest. 1.1516 + * @return boolean Whether updating succeeded. 1.1517 + */ 1.1518 + updateFromManifestData: function (data) { 1.1519 + let old = this._manifestData; 1.1520 + 1.1521 + if (!this._isManifestDataValid(data)) { 1.1522 + return false; 1.1523 + } 1.1524 + 1.1525 + if (this._enabled) { 1.1526 + if (old.xpiHash !== data.xpiHash) { 1.1527 + // A changed hash means we need to update active experiments. 1.1528 + this._needsUpdate = true; 1.1529 + } 1.1530 + } else if (this._failedStart && 1.1531 + (old.xpiHash !== data.xpiHash) || 1.1532 + (old.xpiURL !== data.xpiURL)) { 1.1533 + // Retry installation of previously invalid experiments 1.1534 + // if hash or url changed. 1.1535 + this._failedStart = false; 1.1536 + } 1.1537 + 1.1538 + this._manifestData = data; 1.1539 + this._lastChangedDate = this._policy.now(); 1.1540 + 1.1541 + return true; 1.1542 + }, 1.1543 + 1.1544 + /* 1.1545 + * Is this experiment applicable? 1.1546 + * @return Promise<> Resolved if the experiment is applicable. 1.1547 + * If it is not applicable it is rejected with 1.1548 + * a Promise<string> which contains the reason. 1.1549 + */ 1.1550 + isApplicable: function () { 1.1551 + let versionCmp = Cc["@mozilla.org/xpcom/version-comparator;1"] 1.1552 + .getService(Ci.nsIVersionComparator); 1.1553 + let app = Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULAppInfo); 1.1554 + let runtime = Cc["@mozilla.org/xre/app-info;1"] 1.1555 + .getService(Ci.nsIXULRuntime); 1.1556 + 1.1557 + let locale = this._policy.locale(); 1.1558 + let channel = this._policy.updatechannel(); 1.1559 + let data = this._manifestData; 1.1560 + 1.1561 + let now = this._policy.now() / 1000; // The manifest times are in seconds. 1.1562 + let minActive = MIN_EXPERIMENT_ACTIVE_SECONDS; 1.1563 + let maxActive = data.maxActiveSeconds || 0; 1.1564 + let startSec = (this.startDate || 0) / 1000; 1.1565 + 1.1566 + this._log.trace("isApplicable() - now=" + now 1.1567 + + ", randomValue=" + this._randomValue 1.1568 + + ", data=" + JSON.stringify(this._manifestData)); 1.1569 + 1.1570 + // Not applicable if it already ran. 1.1571 + 1.1572 + if (!this.enabled && this._endDate) { 1.1573 + return Promise.reject(["was-active"]); 1.1574 + } 1.1575 + 1.1576 + // Define and run the condition checks. 1.1577 + 1.1578 + let simpleChecks = [ 1.1579 + { name: "failedStart", 1.1580 + condition: () => !this._failedStart }, 1.1581 + { name: "disabled", 1.1582 + condition: () => !data.disabled }, 1.1583 + { name: "frozen", 1.1584 + condition: () => !data.frozen || this._enabled }, 1.1585 + { name: "startTime", 1.1586 + condition: () => now >= data.startTime }, 1.1587 + { name: "endTime", 1.1588 + condition: () => now < data.endTime }, 1.1589 + { name: "maxStartTime", 1.1590 + condition: () => !data.maxStartTime || now <= data.maxStartTime }, 1.1591 + { name: "maxActiveSeconds", 1.1592 + condition: () => !this._startDate || now <= (startSec + maxActive) }, 1.1593 + { name: "appName", 1.1594 + condition: () => !data.appName || data.appName.indexOf(app.name) != -1 }, 1.1595 + { name: "minBuildID", 1.1596 + condition: () => !data.minBuildID || app.platformBuildID >= data.minBuildID }, 1.1597 + { name: "maxBuildID", 1.1598 + condition: () => !data.maxBuildID || app.platformBuildID <= data.maxBuildID }, 1.1599 + { name: "buildIDs", 1.1600 + condition: () => !data.buildIDs || data.buildIDs.indexOf(app.platformBuildID) != -1 }, 1.1601 + { name: "os", 1.1602 + condition: () => !data.os || data.os.indexOf(runtime.OS) != -1 }, 1.1603 + { name: "channel", 1.1604 + condition: () => !data.channel || data.channel.indexOf(channel) != -1 }, 1.1605 + { name: "locale", 1.1606 + condition: () => !data.locale || data.locale.indexOf(locale) != -1 }, 1.1607 + { name: "sample", 1.1608 + condition: () => data.sample === undefined || this._randomValue <= data.sample }, 1.1609 + { name: "version", 1.1610 + condition: () => !data.version || data.version.indexOf(app.version) != -1 }, 1.1611 + { name: "minVersion", 1.1612 + condition: () => !data.minVersion || versionCmp.compare(app.version, data.minVersion) >= 0 }, 1.1613 + { name: "maxVersion", 1.1614 + condition: () => !data.maxVersion || versionCmp.compare(app.version, data.maxVersion) <= 0 }, 1.1615 + ]; 1.1616 + 1.1617 + for (let check of simpleChecks) { 1.1618 + let result = check.condition(); 1.1619 + if (!result) { 1.1620 + this._log.debug("isApplicable() - id=" 1.1621 + + data.id + " - test '" + check.name + "' failed"); 1.1622 + return Promise.reject([check.name]); 1.1623 + } 1.1624 + } 1.1625 + 1.1626 + if (data.jsfilter) { 1.1627 + return this._runFilterFunction(data.jsfilter); 1.1628 + } 1.1629 + 1.1630 + return Promise.resolve(true); 1.1631 + }, 1.1632 + 1.1633 + /* 1.1634 + * Run the jsfilter function from the manifest in a sandbox and return the 1.1635 + * result (forced to boolean). 1.1636 + */ 1.1637 + _runFilterFunction: function (jsfilter) { 1.1638 + this._log.trace("runFilterFunction() - filter: " + jsfilter); 1.1639 + 1.1640 + return Task.spawn(function ExperimentEntry_runFilterFunction_task() { 1.1641 + const nullprincipal = Cc["@mozilla.org/nullprincipal;1"].createInstance(Ci.nsIPrincipal); 1.1642 + let options = { 1.1643 + sandboxName: "telemetry experiments jsfilter sandbox", 1.1644 + wantComponents: false, 1.1645 + }; 1.1646 + 1.1647 + let sandbox = Cu.Sandbox(nullprincipal); 1.1648 + let context = {}; 1.1649 + context.healthReportPayload = yield this._policy.healthReportPayload(); 1.1650 + context.telemetryPayload = yield this._policy.telemetryPayload(); 1.1651 + 1.1652 + try { 1.1653 + Cu.evalInSandbox(jsfilter, sandbox); 1.1654 + } catch (e) { 1.1655 + this._log.error("runFilterFunction() - failed to eval jsfilter: " + e.message); 1.1656 + throw ["jsfilter-evalfailed"]; 1.1657 + } 1.1658 + 1.1659 + // You can't insert arbitrarily complex objects into a sandbox, so 1.1660 + // we serialize everything through JSON. 1.1661 + sandbox._hr = JSON.stringify(yield this._policy.healthReportPayload()); 1.1662 + Object.defineProperty(sandbox, "_t", 1.1663 + { get: () => JSON.stringify(this._policy.telemetryPayload()) }); 1.1664 + 1.1665 + let result = false; 1.1666 + try { 1.1667 + result = !!Cu.evalInSandbox("filter({healthReportPayload: JSON.parse(_hr), telemetryPayload: JSON.parse(_t)})", sandbox); 1.1668 + } 1.1669 + catch (e) { 1.1670 + this._log.debug("runFilterFunction() - filter function failed: " 1.1671 + + e.message + ", " + e.stack); 1.1672 + throw ["jsfilter-threw", e.message]; 1.1673 + } 1.1674 + finally { 1.1675 + Cu.nukeSandbox(sandbox); 1.1676 + } 1.1677 + 1.1678 + if (!result) { 1.1679 + throw ["jsfilter-false"]; 1.1680 + } 1.1681 + 1.1682 + throw new Task.Result(true); 1.1683 + }.bind(this)); 1.1684 + }, 1.1685 + 1.1686 + /* 1.1687 + * Start running the experiment. 1.1688 + * 1.1689 + * @return Promise<> Resolved when the operation is complete. 1.1690 + */ 1.1691 + start: Task.async(function* () { 1.1692 + this._log.trace("start() for " + this.id); 1.1693 + 1.1694 + this._enabled = true; 1.1695 + return yield this.reconcileAddonState(); 1.1696 + }), 1.1697 + 1.1698 + // Async install of the addon for this experiment, part of the start task above. 1.1699 + _installAddon: Task.async(function* () { 1.1700 + let deferred = Promise.defer(); 1.1701 + 1.1702 + let hash = this._policy.ignoreHashes ? null : this._manifestData.xpiHash; 1.1703 + 1.1704 + let install = yield addonInstallForURL(this._manifestData.xpiURL, hash); 1.1705 + gActiveInstallURLs.add(install.sourceURI.spec); 1.1706 + 1.1707 + let failureHandler = (install, handler) => { 1.1708 + let message = "AddonInstall " + handler + " for " + this.id + ", state=" + 1.1709 + (install.state || "?") + ", error=" + install.error; 1.1710 + this._log.error("_installAddon() - " + message); 1.1711 + this._failedStart = true; 1.1712 + gActiveInstallURLs.delete(install.sourceURI.spec); 1.1713 + 1.1714 + TelemetryLog.log(TELEMETRY_LOG.ACTIVATION_KEY, 1.1715 + [TELEMETRY_LOG.ACTIVATION.INSTALL_FAILURE, this.id]); 1.1716 + 1.1717 + deferred.reject(new Error(message)); 1.1718 + }; 1.1719 + 1.1720 + let listener = { 1.1721 + _expectedID: null, 1.1722 + 1.1723 + onDownloadEnded: install => { 1.1724 + this._log.trace("_installAddon() - onDownloadEnded for " + this.id); 1.1725 + 1.1726 + if (install.existingAddon) { 1.1727 + this._log.warn("_installAddon() - onDownloadEnded, addon already installed"); 1.1728 + } 1.1729 + 1.1730 + if (install.addon.type !== "experiment") { 1.1731 + this._log.error("_installAddon() - onDownloadEnded, wrong addon type"); 1.1732 + install.cancel(); 1.1733 + } 1.1734 + }, 1.1735 + 1.1736 + onInstallStarted: install => { 1.1737 + this._log.trace("_installAddon() - onInstallStarted for " + this.id); 1.1738 + 1.1739 + if (install.existingAddon) { 1.1740 + this._log.warn("_installAddon() - onInstallStarted, addon already installed"); 1.1741 + } 1.1742 + 1.1743 + if (install.addon.type !== "experiment") { 1.1744 + this._log.error("_installAddon() - onInstallStarted, wrong addon type"); 1.1745 + return false; 1.1746 + } 1.1747 + }, 1.1748 + 1.1749 + onInstallEnded: install => { 1.1750 + this._log.trace("_installAddon() - install ended for " + this.id); 1.1751 + gActiveInstallURLs.delete(install.sourceURI.spec); 1.1752 + 1.1753 + this._lastChangedDate = this._policy.now(); 1.1754 + this._startDate = this._policy.now(); 1.1755 + this._enabled = true; 1.1756 + 1.1757 + TelemetryLog.log(TELEMETRY_LOG.ACTIVATION_KEY, 1.1758 + [TELEMETRY_LOG.ACTIVATION.ACTIVATED, this.id]); 1.1759 + 1.1760 + let addon = install.addon; 1.1761 + this._name = addon.name; 1.1762 + this._addonId = addon.id; 1.1763 + this._description = addon.description || ""; 1.1764 + this._homepageURL = addon.homepageURL || ""; 1.1765 + 1.1766 + // Experiment add-ons default to userDisabled=true. Enable if needed. 1.1767 + if (addon.userDisabled) { 1.1768 + this._log.trace("Add-on is disabled. Enabling."); 1.1769 + listener._expectedID = addon.id; 1.1770 + AddonManager.addAddonListener(listener); 1.1771 + addon.userDisabled = false; 1.1772 + } else { 1.1773 + this._log.trace("Add-on is enabled. start() completed."); 1.1774 + deferred.resolve(); 1.1775 + } 1.1776 + }, 1.1777 + 1.1778 + onEnabled: addon => { 1.1779 + this._log.info("onEnabled() for " + addon.id); 1.1780 + 1.1781 + if (addon.id != listener._expectedID) { 1.1782 + return; 1.1783 + } 1.1784 + 1.1785 + AddonManager.removeAddonListener(listener); 1.1786 + deferred.resolve(); 1.1787 + }, 1.1788 + }; 1.1789 + 1.1790 + ["onDownloadCancelled", "onDownloadFailed", "onInstallCancelled", "onInstallFailed"] 1.1791 + .forEach(what => { 1.1792 + listener[what] = install => failureHandler(install, what) 1.1793 + }); 1.1794 + 1.1795 + install.addListener(listener); 1.1796 + install.install(); 1.1797 + 1.1798 + return yield deferred.promise; 1.1799 + }), 1.1800 + 1.1801 + /** 1.1802 + * Stop running the experiment if it is active. 1.1803 + * 1.1804 + * @param terminationKind (optional) 1.1805 + * The termination kind, e.g. ADDON_UNINSTALLED or EXPIRED. 1.1806 + * @param terminationReason (optional) 1.1807 + * The termination reason details for termination kind RECHECK. 1.1808 + * @return Promise<> Resolved when the operation is complete. 1.1809 + */ 1.1810 + stop: Task.async(function* (terminationKind, terminationReason) { 1.1811 + this._log.trace("stop() - id=" + this.id + ", terminationKind=" + terminationKind); 1.1812 + if (!this._enabled) { 1.1813 + throw new Error("Must not call stop() on an inactive experiment."); 1.1814 + } 1.1815 + 1.1816 + this._enabled = false; 1.1817 + let now = this._policy.now(); 1.1818 + this._lastChangedDate = now; 1.1819 + this._endDate = now; 1.1820 + 1.1821 + let changes = yield this.reconcileAddonState(); 1.1822 + this._logTermination(terminationKind, terminationReason); 1.1823 + 1.1824 + if (terminationKind == TELEMETRY_LOG.TERMINATION.ADDON_UNINSTALLED) { 1.1825 + changes |= this.ADDON_CHANGE_UNINSTALL; 1.1826 + } 1.1827 + 1.1828 + return changes; 1.1829 + }), 1.1830 + 1.1831 + /** 1.1832 + * Reconcile the state of the add-on against what it's supposed to be. 1.1833 + * 1.1834 + * If we are active, ensure the add-on is enabled and up to date. 1.1835 + * 1.1836 + * If we are inactive, ensure the add-on is not installed. 1.1837 + */ 1.1838 + reconcileAddonState: Task.async(function* () { 1.1839 + this._log.trace("reconcileAddonState()"); 1.1840 + 1.1841 + if (!this._enabled) { 1.1842 + if (!this._addonId) { 1.1843 + this._log.trace("reconcileAddonState() - Experiment is not enabled and " + 1.1844 + "has no add-on. Doing nothing."); 1.1845 + return this.ADDON_CHANGE_NONE; 1.1846 + } 1.1847 + 1.1848 + let addon = yield this._getAddon(); 1.1849 + if (!addon) { 1.1850 + this._log.trace("reconcileAddonState() - Inactive experiment has no " + 1.1851 + "add-on. Doing nothing."); 1.1852 + return this.ADDON_CHANGE_NONE; 1.1853 + } 1.1854 + 1.1855 + this._log.info("reconcileAddonState() - Uninstalling add-on for inactive " + 1.1856 + "experiment: " + addon.id); 1.1857 + gActiveUninstallAddonIDs.add(addon.id); 1.1858 + yield uninstallAddons([addon]); 1.1859 + gActiveUninstallAddonIDs.delete(addon.id); 1.1860 + return this.ADDON_CHANGE_UNINSTALL; 1.1861 + } 1.1862 + 1.1863 + // If we get here, we're supposed to be active. 1.1864 + 1.1865 + let changes = 0; 1.1866 + 1.1867 + // That requires an add-on. 1.1868 + let currentAddon = yield this._getAddon(); 1.1869 + 1.1870 + // If we have an add-on but it isn't up to date, uninstall it 1.1871 + // (to prepare for reinstall). 1.1872 + if (currentAddon && this._needsUpdate) { 1.1873 + this._log.info("reconcileAddonState() - Uninstalling add-on because update " + 1.1874 + "needed: " + currentAddon.id); 1.1875 + gActiveUninstallAddonIDs.add(currentAddon.id); 1.1876 + yield uninstallAddons([currentAddon]); 1.1877 + gActiveUninstallAddonIDs.delete(currentAddon.id); 1.1878 + changes |= this.ADDON_CHANGE_UNINSTALL; 1.1879 + } 1.1880 + 1.1881 + if (!currentAddon || this._needsUpdate) { 1.1882 + this._log.info("reconcileAddonState() - Installing add-on."); 1.1883 + yield this._installAddon(); 1.1884 + changes |= this.ADDON_CHANGE_INSTALL; 1.1885 + } 1.1886 + 1.1887 + let addon = yield this._getAddon(); 1.1888 + if (!addon) { 1.1889 + throw new Error("Could not obtain add-on for experiment that should be " + 1.1890 + "enabled."); 1.1891 + } 1.1892 + 1.1893 + // If we have the add-on and it is enabled, we are done. 1.1894 + if (!addon.userDisabled) { 1.1895 + return changes; 1.1896 + } 1.1897 + 1.1898 + let deferred = Promise.defer(); 1.1899 + 1.1900 + // Else we need to enable it. 1.1901 + let listener = { 1.1902 + onEnabled: enabledAddon => { 1.1903 + if (enabledAddon.id != addon.id) { 1.1904 + return; 1.1905 + } 1.1906 + 1.1907 + AddonManager.removeAddonListener(listener); 1.1908 + deferred.resolve(); 1.1909 + }, 1.1910 + }; 1.1911 + 1.1912 + this._log.info("Activating add-on: " + addon.id); 1.1913 + AddonManager.addAddonListener(listener); 1.1914 + addon.userDisabled = false; 1.1915 + yield deferred.promise; 1.1916 + changes |= this.ADDON_CHANGE_ENABLE; 1.1917 + 1.1918 + this._log.info("Add-on has been enabled: " + addon.id); 1.1919 + return changes; 1.1920 + }), 1.1921 + 1.1922 + /** 1.1923 + * Obtain the underlying Addon from the Addon Manager. 1.1924 + * 1.1925 + * @return Promise<Addon|null> 1.1926 + */ 1.1927 + _getAddon: function () { 1.1928 + if (!this._addonId) { 1.1929 + return Promise.resolve(null); 1.1930 + } 1.1931 + 1.1932 + let deferred = Promise.defer(); 1.1933 + 1.1934 + AddonManager.getAddonByID(this._addonId, (addon) => { 1.1935 + if (addon && addon.appDisabled) { 1.1936 + // Don't return PreviousExperiments. 1.1937 + addon = null; 1.1938 + } 1.1939 + 1.1940 + deferred.resolve(addon); 1.1941 + }); 1.1942 + 1.1943 + return deferred.promise; 1.1944 + }, 1.1945 + 1.1946 + _logTermination: function (terminationKind, terminationReason) { 1.1947 + if (terminationKind === undefined) { 1.1948 + return; 1.1949 + } 1.1950 + 1.1951 + if (!(terminationKind in TELEMETRY_LOG.TERMINATION)) { 1.1952 + this._log.warn("stop() - unknown terminationKind " + terminationKind); 1.1953 + return; 1.1954 + } 1.1955 + 1.1956 + let data = [terminationKind, this.id]; 1.1957 + if (terminationReason) { 1.1958 + data = data.concat(terminationReason); 1.1959 + } 1.1960 + 1.1961 + TelemetryLog.log(TELEMETRY_LOG.TERMINATION_KEY, data); 1.1962 + }, 1.1963 + 1.1964 + /** 1.1965 + * Determine whether an active experiment should be stopped. 1.1966 + */ 1.1967 + shouldStop: function () { 1.1968 + if (!this._enabled) { 1.1969 + throw new Error("shouldStop must not be called on disabled experiments."); 1.1970 + } 1.1971 + 1.1972 + let data = this._manifestData; 1.1973 + let now = this._policy.now() / 1000; // The manifest times are in seconds. 1.1974 + let maxActiveSec = data.maxActiveSeconds || 0; 1.1975 + 1.1976 + let deferred = Promise.defer(); 1.1977 + this.isApplicable().then( 1.1978 + () => deferred.resolve({shouldStop: false}), 1.1979 + reason => deferred.resolve({shouldStop: true, reason: reason}) 1.1980 + ); 1.1981 + 1.1982 + return deferred.promise; 1.1983 + }, 1.1984 + 1.1985 + /* 1.1986 + * Should this be discarded from the cache due to age? 1.1987 + */ 1.1988 + shouldDiscard: function () { 1.1989 + let limit = this._policy.now(); 1.1990 + limit.setDate(limit.getDate() - KEEP_HISTORY_N_DAYS); 1.1991 + return (this._lastChangedDate < limit); 1.1992 + }, 1.1993 + 1.1994 + /* 1.1995 + * Get next date (in epoch-ms) to schedule a re-evaluation for this. 1.1996 + * Returns 0 if it doesn't need one. 1.1997 + */ 1.1998 + getScheduleTime: function () { 1.1999 + if (this._enabled) { 1.2000 + let now = this._policy.now(); 1.2001 + let startTime = this._startDate.getTime(); 1.2002 + let maxActiveTime = startTime + 1000 * this._manifestData.maxActiveSeconds; 1.2003 + return Math.min(1000 * this._manifestData.endTime, maxActiveTime); 1.2004 + } 1.2005 + 1.2006 + if (this._endDate) { 1.2007 + return this._endDate.getTime(); 1.2008 + } 1.2009 + 1.2010 + return 1000 * this._manifestData.startTime; 1.2011 + }, 1.2012 + 1.2013 + /* 1.2014 + * Perform sanity checks on the experiment data. 1.2015 + */ 1.2016 + _isManifestDataValid: function (data) { 1.2017 + this._log.trace("isManifestDataValid() - data: " + JSON.stringify(data)); 1.2018 + 1.2019 + for (let key of this.MANIFEST_REQUIRED_FIELDS) { 1.2020 + if (!(key in data)) { 1.2021 + this._log.error("isManifestDataValid() - missing required key: " + key); 1.2022 + return false; 1.2023 + } 1.2024 + } 1.2025 + 1.2026 + for (let key in data) { 1.2027 + if (!this.MANIFEST_OPTIONAL_FIELDS.has(key) && 1.2028 + !this.MANIFEST_REQUIRED_FIELDS.has(key)) { 1.2029 + this._log.error("isManifestDataValid() - unknown key: " + key); 1.2030 + return false; 1.2031 + } 1.2032 + } 1.2033 + 1.2034 + return true; 1.2035 + }, 1.2036 +}; 1.2037 + 1.2038 + 1.2039 + 1.2040 +/** 1.2041 + * Strip a Date down to its UTC midnight. 1.2042 + * 1.2043 + * This will return a cloned Date object. The original is unchanged. 1.2044 + */ 1.2045 +let stripDateToMidnight = function (d) { 1.2046 + let m = new Date(d); 1.2047 + m.setUTCHours(0, 0, 0, 0); 1.2048 + 1.2049 + return m; 1.2050 +}; 1.2051 + 1.2052 +function ExperimentsLastActiveMeasurement1() { 1.2053 + Metrics.Measurement.call(this); 1.2054 +} 1.2055 +function ExperimentsLastActiveMeasurement2() { 1.2056 + Metrics.Measurement.call(this); 1.2057 +} 1.2058 + 1.2059 +const FIELD_DAILY_LAST_TEXT = {type: Metrics.Storage.FIELD_DAILY_LAST_TEXT}; 1.2060 + 1.2061 +ExperimentsLastActiveMeasurement1.prototype = Object.freeze({ 1.2062 + __proto__: Metrics.Measurement.prototype, 1.2063 + 1.2064 + name: "info", 1.2065 + version: 1, 1.2066 + 1.2067 + fields: { 1.2068 + lastActive: FIELD_DAILY_LAST_TEXT, 1.2069 + } 1.2070 +}); 1.2071 +ExperimentsLastActiveMeasurement2.prototype = Object.freeze({ 1.2072 + __proto__: Metrics.Measurement.prototype, 1.2073 + 1.2074 + name: "info", 1.2075 + version: 2, 1.2076 + 1.2077 + fields: { 1.2078 + lastActive: FIELD_DAILY_LAST_TEXT, 1.2079 + lastActiveBranch: FIELD_DAILY_LAST_TEXT, 1.2080 + } 1.2081 +}); 1.2082 + 1.2083 +this.ExperimentsProvider = function () { 1.2084 + Metrics.Provider.call(this); 1.2085 + 1.2086 + this._experiments = null; 1.2087 +}; 1.2088 + 1.2089 +ExperimentsProvider.prototype = Object.freeze({ 1.2090 + __proto__: Metrics.Provider.prototype, 1.2091 + 1.2092 + name: "org.mozilla.experiments", 1.2093 + 1.2094 + measurementTypes: [ 1.2095 + ExperimentsLastActiveMeasurement1, 1.2096 + ExperimentsLastActiveMeasurement2, 1.2097 + ], 1.2098 + 1.2099 + _OBSERVERS: [ 1.2100 + EXPERIMENTS_CHANGED_TOPIC, 1.2101 + ], 1.2102 + 1.2103 + postInit: function () { 1.2104 + for (let o of this._OBSERVERS) { 1.2105 + Services.obs.addObserver(this, o, false); 1.2106 + } 1.2107 + 1.2108 + return Promise.resolve(); 1.2109 + }, 1.2110 + 1.2111 + onShutdown: function () { 1.2112 + for (let o of this._OBSERVERS) { 1.2113 + Services.obs.removeObserver(this, o); 1.2114 + } 1.2115 + 1.2116 + return Promise.resolve(); 1.2117 + }, 1.2118 + 1.2119 + observe: function (subject, topic, data) { 1.2120 + switch (topic) { 1.2121 + case EXPERIMENTS_CHANGED_TOPIC: 1.2122 + this.recordLastActiveExperiment(); 1.2123 + break; 1.2124 + } 1.2125 + }, 1.2126 + 1.2127 + collectDailyData: function () { 1.2128 + return this.recordLastActiveExperiment(); 1.2129 + }, 1.2130 + 1.2131 + recordLastActiveExperiment: function () { 1.2132 + if (!gExperimentsEnabled) { 1.2133 + return Promise.resolve(); 1.2134 + } 1.2135 + 1.2136 + if (!this._experiments) { 1.2137 + this._experiments = Experiments.instance(); 1.2138 + } 1.2139 + 1.2140 + let m = this.getMeasurement(ExperimentsLastActiveMeasurement2.prototype.name, 1.2141 + ExperimentsLastActiveMeasurement2.prototype.version); 1.2142 + 1.2143 + return this.enqueueStorageOperation(() => { 1.2144 + return Task.spawn(function* recordTask() { 1.2145 + let todayActive = yield this._experiments.lastActiveToday(); 1.2146 + if (!todayActive) { 1.2147 + this._log.info("No active experiment on this day: " + 1.2148 + this._experiments._policy.now()); 1.2149 + return; 1.2150 + } 1.2151 + 1.2152 + this._log.info("Recording last active experiment: " + todayActive.id); 1.2153 + yield m.setDailyLastText("lastActive", todayActive.id, 1.2154 + this._experiments._policy.now()); 1.2155 + let branch = todayActive.branch; 1.2156 + if (branch) { 1.2157 + yield m.setDailyLastText("lastActiveBranch", branch, 1.2158 + this._experiments._policy.now()); 1.2159 + } 1.2160 + }.bind(this)); 1.2161 + }); 1.2162 + }, 1.2163 +}); 1.2164 + 1.2165 +/** 1.2166 + * An Add-ons Manager provider that knows about old experiments. 1.2167 + * 1.2168 + * This provider exposes read-only add-ons corresponding to previously-active 1.2169 + * experiments. The existence of this provider (and the add-ons it knows about) 1.2170 + * facilitates the display of old experiments in the Add-ons Manager UI with 1.2171 + * very little custom code in that component. 1.2172 + */ 1.2173 +this.Experiments.PreviousExperimentProvider = function (experiments) { 1.2174 + this._experiments = experiments; 1.2175 + this._experimentList = []; 1.2176 + this._log = Log.repository.getLoggerWithMessagePrefix( 1.2177 + "Browser.Experiments.Experiments", 1.2178 + "PreviousExperimentProvider #" + gPreviousProviderCounter++ + "::"); 1.2179 +} 1.2180 + 1.2181 +this.Experiments.PreviousExperimentProvider.prototype = Object.freeze({ 1.2182 + startup: function () { 1.2183 + this._log.trace("startup()"); 1.2184 + Services.obs.addObserver(this, EXPERIMENTS_CHANGED_TOPIC, false); 1.2185 + }, 1.2186 + 1.2187 + shutdown: function () { 1.2188 + this._log.trace("shutdown()"); 1.2189 + Services.obs.removeObserver(this, EXPERIMENTS_CHANGED_TOPIC); 1.2190 + }, 1.2191 + 1.2192 + observe: function (subject, topic, data) { 1.2193 + switch (topic) { 1.2194 + case EXPERIMENTS_CHANGED_TOPIC: 1.2195 + this._updateExperimentList(); 1.2196 + break; 1.2197 + } 1.2198 + }, 1.2199 + 1.2200 + getAddonByID: function (id, cb) { 1.2201 + for (let experiment of this._experimentList) { 1.2202 + if (experiment.id == id) { 1.2203 + cb(new PreviousExperimentAddon(experiment)); 1.2204 + return; 1.2205 + } 1.2206 + } 1.2207 + 1.2208 + cb(null); 1.2209 + }, 1.2210 + 1.2211 + getAddonsByTypes: function (types, cb) { 1.2212 + if (types && types.length > 0 && types.indexOf("experiment") == -1) { 1.2213 + cb([]); 1.2214 + return; 1.2215 + } 1.2216 + 1.2217 + cb([new PreviousExperimentAddon(e) for (e of this._experimentList)]); 1.2218 + }, 1.2219 + 1.2220 + _updateExperimentList: function () { 1.2221 + return this._experiments.getExperiments().then((experiments) => { 1.2222 + let list = [e for (e of experiments) if (!e.active)]; 1.2223 + 1.2224 + let newMap = new Map([[e.id, e] for (e of list)]); 1.2225 + let oldMap = new Map([[e.id, e] for (e of this._experimentList)]); 1.2226 + 1.2227 + let added = [e.id for (e of list) if (!oldMap.has(e.id))]; 1.2228 + let removed = [e.id for (e of this._experimentList) if (!newMap.has(e.id))]; 1.2229 + 1.2230 + for (let id of added) { 1.2231 + this._log.trace("updateExperimentList() - adding " + id); 1.2232 + let wrapper = new PreviousExperimentAddon(newMap.get(id)); 1.2233 + AddonManagerPrivate.callInstallListeners("onExternalInstall", null, wrapper, null, false); 1.2234 + AddonManagerPrivate.callAddonListeners("onInstalling", wrapper, false); 1.2235 + } 1.2236 + 1.2237 + for (let id of removed) { 1.2238 + this._log.trace("updateExperimentList() - removing " + id); 1.2239 + let wrapper = new PreviousExperimentAddon(oldMap.get(id)); 1.2240 + AddonManagerPrivate.callAddonListeners("onUninstalling", plugin, false); 1.2241 + } 1.2242 + 1.2243 + this._experimentList = list; 1.2244 + 1.2245 + for (let id of added) { 1.2246 + let wrapper = new PreviousExperimentAddon(newMap.get(id)); 1.2247 + AddonManagerPrivate.callAddonListeners("onInstalled", wrapper); 1.2248 + } 1.2249 + 1.2250 + for (let id of removed) { 1.2251 + let wrapper = new PreviousExperimentAddon(oldMap.get(id)); 1.2252 + AddonManagerPrivate.callAddonListeners("onUninstalled", wrapper); 1.2253 + } 1.2254 + 1.2255 + return this._experimentList; 1.2256 + }); 1.2257 + }, 1.2258 +}); 1.2259 + 1.2260 +/** 1.2261 + * An add-on that represents a previously-installed experiment. 1.2262 + */ 1.2263 +function PreviousExperimentAddon(experiment) { 1.2264 + this._id = experiment.id; 1.2265 + this._name = experiment.name; 1.2266 + this._endDate = experiment.endDate; 1.2267 + this._description = experiment.description; 1.2268 +} 1.2269 + 1.2270 +PreviousExperimentAddon.prototype = Object.freeze({ 1.2271 + // BEGIN REQUIRED ADDON PROPERTIES 1.2272 + 1.2273 + get appDisabled() { 1.2274 + return true; 1.2275 + }, 1.2276 + 1.2277 + get blocklistState() { 1.2278 + Ci.nsIBlocklistService.STATE_NOT_BLOCKED 1.2279 + }, 1.2280 + 1.2281 + get creator() { 1.2282 + return new AddonManagerPrivate.AddonAuthor(""); 1.2283 + }, 1.2284 + 1.2285 + get foreignInstall() { 1.2286 + return false; 1.2287 + }, 1.2288 + 1.2289 + get id() { 1.2290 + return this._id; 1.2291 + }, 1.2292 + 1.2293 + get isActive() { 1.2294 + return false; 1.2295 + }, 1.2296 + 1.2297 + get isCompatible() { 1.2298 + return true; 1.2299 + }, 1.2300 + 1.2301 + get isPlatformCompatible() { 1.2302 + return true; 1.2303 + }, 1.2304 + 1.2305 + get name() { 1.2306 + return this._name; 1.2307 + }, 1.2308 + 1.2309 + get pendingOperations() { 1.2310 + return AddonManager.PENDING_NONE; 1.2311 + }, 1.2312 + 1.2313 + get permissions() { 1.2314 + return 0; 1.2315 + }, 1.2316 + 1.2317 + get providesUpdatesSecurely() { 1.2318 + return true; 1.2319 + }, 1.2320 + 1.2321 + get scope() { 1.2322 + return AddonManager.SCOPE_PROFILE; 1.2323 + }, 1.2324 + 1.2325 + get type() { 1.2326 + return "experiment"; 1.2327 + }, 1.2328 + 1.2329 + get userDisabled() { 1.2330 + return true; 1.2331 + }, 1.2332 + 1.2333 + get version() { 1.2334 + return null; 1.2335 + }, 1.2336 + 1.2337 + // END REQUIRED PROPERTIES 1.2338 + 1.2339 + // BEGIN OPTIONAL PROPERTIES 1.2340 + 1.2341 + get description() { 1.2342 + return this._description; 1.2343 + }, 1.2344 + 1.2345 + get updateDate() { 1.2346 + return new Date(this._endDate); 1.2347 + }, 1.2348 + 1.2349 + // END OPTIONAL PROPERTIES 1.2350 + 1.2351 + // BEGIN REQUIRED METHODS 1.2352 + 1.2353 + isCompatibleWith: function (appVersion, platformVersion) { 1.2354 + return true; 1.2355 + }, 1.2356 + 1.2357 + findUpdates: function (listener, reason, appVersion, platformVersion) { 1.2358 + AddonManagerPrivate.callNoUpdateListeners(this, listener, reason, 1.2359 + appVersion, platformVersion); 1.2360 + }, 1.2361 + 1.2362 + // END REQUIRED METHODS 1.2363 + 1.2364 + /** 1.2365 + * The end-date of the experiment, required for the Addon Manager UI. 1.2366 + */ 1.2367 + 1.2368 + get endDate() { 1.2369 + return this._endDate; 1.2370 + }, 1.2371 + 1.2372 +});