Bumping gaia.json for 2 gaia revision(s) a=gaia-bump
[gecko.git] / browser / experiments / Experiments.jsm
blobdf1df31e47422de2d8377e8c3b1f67a538801ea1
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this
3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 "use strict";
7 this.EXPORTED_SYMBOLS = [
8   "Experiments",
9   "ExperimentsProvider",
12 const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
14 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
15 Cu.import("resource://gre/modules/Services.jsm");
16 Cu.import("resource://gre/modules/Task.jsm");
17 Cu.import("resource://gre/modules/Promise.jsm");
18 Cu.import("resource://gre/modules/osfile.jsm");
19 Cu.import("resource://gre/modules/Log.jsm");
20 Cu.import("resource://gre/modules/Preferences.jsm");
21 Cu.import("resource://gre/modules/AsyncShutdown.jsm");
23 XPCOMUtils.defineLazyModuleGetter(this, "UpdateChannel",
24                                   "resource://gre/modules/UpdateChannel.jsm");
25 XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
26                                   "resource://gre/modules/AddonManager.jsm");
27 XPCOMUtils.defineLazyModuleGetter(this, "AddonManagerPrivate",
28                                   "resource://gre/modules/AddonManager.jsm");
29 XPCOMUtils.defineLazyModuleGetter(this, "TelemetryPing",
30                                   "resource://gre/modules/TelemetryPing.jsm");
31 XPCOMUtils.defineLazyModuleGetter(this, "TelemetryLog",
32                                   "resource://gre/modules/TelemetryLog.jsm");
33 XPCOMUtils.defineLazyModuleGetter(this, "CommonUtils",
34                                   "resource://services-common/utils.js");
35 XPCOMUtils.defineLazyModuleGetter(this, "Metrics",
36                                   "resource://gre/modules/Metrics.jsm");
38 XPCOMUtils.defineLazyServiceGetter(this, "gCrashReporter",
39                                    "@mozilla.org/xre/app-info;1",
40                                    "nsICrashReporter");
42 const FILE_CACHE                = "experiments.json";
43 const EXPERIMENTS_CHANGED_TOPIC = "experiments-changed";
44 const MANIFEST_VERSION          = 1;
45 const CACHE_VERSION             = 1;
47 const KEEP_HISTORY_N_DAYS       = 180;
48 const MIN_EXPERIMENT_ACTIVE_SECONDS = 60;
50 const PREF_BRANCH               = "experiments.";
51 const PREF_ENABLED              = "enabled"; // experiments.enabled
52 const PREF_ACTIVE_EXPERIMENT    = "activeExperiment"; // whether we have an active experiment
53 const PREF_LOGGING              = "logging";
54 const PREF_LOGGING_LEVEL        = PREF_LOGGING + ".level"; // experiments.logging.level
55 const PREF_LOGGING_DUMP         = PREF_LOGGING + ".dump"; // experiments.logging.dump
56 const PREF_MANIFEST_URI         = "manifest.uri"; // experiments.logging.manifest.uri
57 const PREF_FORCE_SAMPLE         = "force-sample-value"; // experiments.force-sample-value
59 const PREF_HEALTHREPORT_ENABLED = "datareporting.healthreport.service.enabled";
61 const PREF_BRANCH_TELEMETRY     = "toolkit.telemetry.";
62 const PREF_TELEMETRY_ENABLED    = "enabled";
64 const URI_EXTENSION_STRINGS     = "chrome://mozapps/locale/extensions/extensions.properties";
65 const STRING_TYPE_NAME          = "type.%ID%.name";
67 const CACHE_WRITE_RETRY_DELAY_SEC = 60 * 3;
68 const MANIFEST_FETCH_TIMEOUT_MSEC = 60 * 3 * 1000; // 3 minutes
70 const TELEMETRY_LOG = {
71   // log(key, [kind, experimentId, details])
72   ACTIVATION_KEY: "EXPERIMENT_ACTIVATION",
73   ACTIVATION: {
74     // Successfully activated.
75     ACTIVATED: "ACTIVATED",
76     // Failed to install the add-on.
77     INSTALL_FAILURE: "INSTALL_FAILURE",
78     // Experiment does not meet activation requirements. Details will
79     // be provided.
80     REJECTED: "REJECTED",
81   },
83   // log(key, [kind, experimentId, optionalDetails...])
84   TERMINATION_KEY: "EXPERIMENT_TERMINATION",
85   TERMINATION: {
86     // The Experiments service was disabled.
87     SERVICE_DISABLED: "SERVICE_DISABLED",
88     // Add-on uninstalled.
89     ADDON_UNINSTALLED: "ADDON_UNINSTALLED",
90     // The experiment disabled itself.
91     FROM_API: "FROM_API",
92     // The experiment expired (e.g. by exceeding the end date).
93     EXPIRED: "EXPIRED",
94     // Disabled after re-evaluating conditions. If this is specified,
95     // details will be provided.
96     RECHECK: "RECHECK",
97   },
100 const gPrefs = new Preferences(PREF_BRANCH);
101 const gPrefsTelemetry = new Preferences(PREF_BRANCH_TELEMETRY);
102 let gExperimentsEnabled = false;
103 let gAddonProvider = null;
104 let gExperiments = null;
105 let gLogAppenderDump = null;
106 let gPolicyCounter = 0;
107 let gExperimentsCounter = 0;
108 let gExperimentEntryCounter = 0;
109 let gPreviousProviderCounter = 0;
111 // Tracks active AddonInstall we know about so we can deny external
112 // installs.
113 let gActiveInstallURLs = new Set();
115 // Tracks add-on IDs that are being uninstalled by us. This allows us
116 // to differentiate between expected uninstalled and user-driven uninstalls.
117 let gActiveUninstallAddonIDs = new Set();
119 let gLogger;
120 let gLogDumping = false;
122 function configureLogging() {
123   if (!gLogger) {
124     gLogger = Log.repository.getLogger("Browser.Experiments");
125     gLogger.addAppender(new Log.ConsoleAppender(new Log.BasicFormatter()));
126   }
127   gLogger.level = gPrefs.get(PREF_LOGGING_LEVEL, Log.Level.Warn);
129   let logDumping = gPrefs.get(PREF_LOGGING_DUMP, false);
130   if (logDumping != gLogDumping) {
131     if (logDumping) {
132       gLogAppenderDump = new Log.DumpAppender(new Log.BasicFormatter());
133       gLogger.addAppender(gLogAppenderDump);
134     } else {
135       gLogger.removeAppender(gLogAppenderDump);
136       gLogAppenderDump = null;
137     }
138     gLogDumping = logDumping;
139   }
142 // Takes an array of promises and returns a promise that is resolved once all of
143 // them are rejected or resolved.
144 function allResolvedOrRejected(promises) {
145   if (!promises.length) {
146     return Promise.resolve([]);
147   }
149   let countdown = promises.length;
150   let deferred = Promise.defer();
152   for (let p of promises) {
153     let helper = () => {
154       if (--countdown == 0) {
155         deferred.resolve();
156       }
157     };
158     Promise.resolve(p).then(helper, helper);
159   }
161   return deferred.promise;
164 // Loads a JSON file using OS.file. file is a string representing the path
165 // of the file to be read, options contains additional options to pass to
166 // OS.File.read.
167 // Returns a Promise resolved with the json payload or rejected with
168 // OS.File.Error or JSON.parse() errors.
169 function loadJSONAsync(file, options) {
170   return Task.spawn(function() {
171     let rawData = yield OS.File.read(file, options);
172     // Read json file into a string
173     let data;
174     try {
175       // Obtain a converter to read from a UTF-8 encoded input stream.
176       let converter = new TextDecoder();
177       data = JSON.parse(converter.decode(rawData));
178     } catch (ex) {
179       gLogger.error("Experiments: Could not parse JSON: " + file + " " + ex);
180       throw ex;
181     }
182     throw new Task.Result(data);
183   });
186 function telemetryEnabled() {
187   return gPrefsTelemetry.get(PREF_TELEMETRY_ENABLED, false);
190 // Returns a promise that is resolved with the AddonInstall for that URL.
191 function addonInstallForURL(url, hash) {
192   let deferred = Promise.defer();
193   AddonManager.getInstallForURL(url, install => deferred.resolve(install),
194                                 "application/x-xpinstall", hash);
195   return deferred.promise;
198 // Returns a promise that is resolved with an Array<Addon> of the installed
199 // experiment addons.
200 function installedExperimentAddons() {
201   let deferred = Promise.defer();
202   AddonManager.getAddonsByTypes(["experiment"], (addons) => {
203     deferred.resolve([a for (a of addons) if (!a.appDisabled)]);
204   });
205   return deferred.promise;
208 // Takes an Array<Addon> and returns a promise that is resolved when the
209 // addons are uninstalled.
210 function uninstallAddons(addons) {
211   let ids = new Set([a.id for (a of addons)]);
212   let deferred = Promise.defer();
214   let listener = {};
215   listener.onUninstalled = addon => {
216     if (!ids.has(addon.id)) {
217       return;
218     }
220     ids.delete(addon.id);
221     if (ids.size == 0) {
222       AddonManager.removeAddonListener(listener);
223       deferred.resolve();
224     }
225   };
227   AddonManager.addAddonListener(listener);
229   for (let addon of addons) {
230     // Disabling the add-on before uninstalling is necessary to cause tests to
231     // pass. This might be indicative of a bug in XPIProvider.
232     // TODO follow up in bug 992396.
233     addon.userDisabled = true;
234     addon.uninstall();
235   }
237   return deferred.promise;
241  * The experiments module.
242  */
244 let Experiments = {
245   /**
246    * Provides access to the global `Experiments.Experiments` instance.
247    */
248   instance: function () {
249     if (!gExperiments) {
250       gExperiments = new Experiments.Experiments();
251     }
253     return gExperiments;
254   },
258  * The policy object allows us to inject fake enviroment data from the
259  * outside by monkey-patching.
260  */
262 Experiments.Policy = function () {
263   this._log = Log.repository.getLoggerWithMessagePrefix(
264     "Browser.Experiments.Policy",
265     "Policy #" + gPolicyCounter++ + "::");
267   // Set to true to ignore hash verification on downloaded XPIs. This should
268   // not be used outside of testing.
269   this.ignoreHashes = false;
272 Experiments.Policy.prototype = {
273   now: function () {
274     return new Date();
275   },
277   random: function () {
278     let pref = gPrefs.get(PREF_FORCE_SAMPLE);
279     if (pref !== undefined) {
280       let val = Number.parseFloat(pref);
281       this._log.debug("random sample forced: " + val);
282       if (isNaN(val) || val < 0) {
283         return 0;
284       }
285       if (val > 1) {
286         return 1;
287       }
288       return val;
289     }
290     return Math.random();
291   },
293   futureDate: function (offset) {
294     return new Date(this.now().getTime() + offset);
295   },
297   oneshotTimer: function (callback, timeout, thisObj, name) {
298     return CommonUtils.namedTimer(callback, timeout, thisObj, name);
299   },
301   updatechannel: function () {
302     return UpdateChannel.get();
303   },
305   locale: function () {
306     let chrome = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(Ci.nsIXULChromeRegistry);
307     return chrome.getSelectedLocale("global");
308   },
310   /*
311    * @return Promise<> Resolved with the payload data.
312    */
313   healthReportPayload: function () {
314     return Task.spawn(function*() {
315       let reporter = Cc["@mozilla.org/datareporting/service;1"]
316             .getService(Ci.nsISupports)
317             .wrappedJSObject
318             .healthReporter;
319       yield reporter.onInit();
320       let payload = yield reporter.collectAndObtainJSONPayload();
321       return payload;
322     });
323   },
325   telemetryPayload: function () {
326     return TelemetryPing.getPayload();
327   },
329   /**
330    * For testing a race condition, one of the tests delays the callback of
331    * writing the cache by replacing this policy function.
332    */
333   delayCacheWrite: function(promise) {
334     return promise;
335   },
338 function AlreadyShutdownError(message="already shut down") {
339   Error.call(this, message);
340   let error = new Error();
341   this.name = "AlreadyShutdownError";
342   this.message = message;
343   this.stack = error.stack;
345 AlreadyShutdownError.prototype = Object.create(Error.prototype);
346 AlreadyShutdownError.prototype.constructor = AlreadyShutdownError;
348 function CacheWriteError(message="Error writing cache file") {
349   Error.call(this, message);
350   let error = new Error();
351   this.name = "CacheWriteError";
352   this.message = message;
353   this.stack = error.stack;
355 CacheWriteError.prototype = Object.create(Error.prototype);
356 CacheWriteError.prototype.constructor = CacheWriteError;
359  * Manages the experiments and provides an interface to control them.
360  */
362 Experiments.Experiments = function (policy=new Experiments.Policy()) {
363   let log = Log.repository.getLoggerWithMessagePrefix(
364       "Browser.Experiments.Experiments",
365       "Experiments #" + gExperimentsCounter++ + "::");
367   // At the time of this writing, Experiments.jsm has severe
368   // crashes. For forensics purposes, keep the last few log
369   // messages in memory and upload them in case of crash.
370   this._forensicsLogs = [];
371   this._forensicsLogs.length = 20;
372   this._log = Object.create(log);
373   this._log.log = (level, string, params) => {
374     this._forensicsLogs.shift();
375     this._forensicsLogs.push(level + ": " + string);
376     log.log(level, string, params);
377   };
379   this._log.trace("constructor");
381   // Capture the latest error, for forensics purposes.
382   this._latestError = null;
385   this._policy = policy;
387   // This is a Map of (string -> ExperimentEntry), keyed with the experiment id.
388   // It holds both the current experiments and history.
389   // Map() preserves insertion order, which means we preserve the manifest order.
390   // This is null until we've successfully completed loading the cache from
391   // disk the first time.
392   this._experiments = null;
393   this._refresh = false;
394   this._terminateReason = null; // or TELEMETRY_LOG.TERMINATION....
395   this._dirty = false;
397   // Loading the cache happens once asynchronously on startup
398   this._loadTask = null;
400   // The _main task handles all other actions:
401   // * refreshing the manifest off the network (if _refresh)
402   // * disabling/enabling experiments
403   // * saving the cache (if _dirty)
404   this._mainTask = null;
406   // Timer for re-evaluating experiment status.
407   this._timer = null;
409   this._shutdown = false;
410   this._networkRequest = null;
412   // We need to tell when we first evaluated the experiments to fire an
413   // experiments-changed notification when we only loaded completed experiments.
414   this._firstEvaluate = true;
416   this.init();
419 Experiments.Experiments.prototype = {
420   QueryInterface: XPCOMUtils.generateQI([Ci.nsITimerCallback, Ci.nsIObserver]),
422   init: function () {
423     this._shutdown = false;
424     configureLogging();
426     gExperimentsEnabled = gPrefs.get(PREF_ENABLED, false);
427     this._log.trace("enabled=" + gExperimentsEnabled + ", " + this.enabled);
429     gPrefs.observe(PREF_LOGGING, configureLogging);
430     gPrefs.observe(PREF_MANIFEST_URI, this.updateManifest, this);
431     gPrefs.observe(PREF_ENABLED, this._toggleExperimentsEnabled, this);
433     gPrefsTelemetry.observe(PREF_TELEMETRY_ENABLED, this._telemetryStatusChanged, this);
435     AddonManager.shutdown.addBlocker("Experiments.jsm shutdown",
436       this.uninit.bind(this),
437       this._getState.bind(this)
438     );
440     this._registerWithAddonManager();
442     this._loadTask = this._loadFromCache();
444     return this._loadTask.then(
445       () => {
446         this._log.trace("_loadTask finished ok");
447         this._loadTask = null;
448         return this._run();
449       },
450       (e) => {
451         this._log.error("_loadFromCache caught error: " + e);
452         this._latestError = e;
453         throw e;
454       }
455     );
456   },
458   /**
459    * Uninitialize this instance.
460    *
461    * This function is susceptible to race conditions. If it is called multiple
462    * times before the previous uninit() has completed or if it is called while
463    * an init() operation is being performed, the object may get in bad state
464    * and/or deadlock could occur.
465    *
466    * @return Promise<>
467    *         The promise is fulfilled when all pending tasks are finished.
468    */
469   uninit: Task.async(function* () {
470     this._log.trace("uninit: started");
471     yield this._loadTask;
472     this._log.trace("uninit: finished with _loadTask");
474     if (!this._shutdown) {
475       this._log.trace("uninit: no previous shutdown");
476       this._unregisterWithAddonManager();
478       gPrefs.ignore(PREF_LOGGING, configureLogging);
479       gPrefs.ignore(PREF_MANIFEST_URI, this.updateManifest, this);
480       gPrefs.ignore(PREF_ENABLED, this._toggleExperimentsEnabled, this);
482       gPrefsTelemetry.ignore(PREF_TELEMETRY_ENABLED, this._telemetryStatusChanged, this);
484       if (this._timer) {
485         this._timer.clear();
486       }
487     }
489     this._shutdown = true;
490     if (this._mainTask) {
491       if (this._networkRequest) {
492         try {
493           this._log.trace("Aborting pending network request: " + this._networkRequest);
494           this._networkRequest.abort();
495         } catch (e) {
496           // pass
497         }
498       }
499       try {
500         this._log.trace("uninit: waiting on _mainTask");
501         yield this._mainTask;
502       } catch (e if e instanceof AlreadyShutdownError) {
503         // We error out of tasks after shutdown via that exception.
504       } catch (e) {
505         this._latestError = e;
506         throw e;
507       }
508     }
510     this._log.info("Completed uninitialization.");
511   }),
513   // Return state information, for debugging purposes.
514   _getState: function() {
515     let state = {
516       isShutdown: this._shutdown,
517       isEnabled: gExperimentsEnabled,
518       isRefresh: this._refresh,
519       isDirty: this._dirty,
520       isFirstEvaluate: this._firstEvaluate,
521       hasLoadTask: !!this._loadTask,
522       hasMainTask: !!this._mainTask,
523       hasTimer: !!this._hasTimer,
524       hasAddonProvider: !!gAddonProvider,
525       latestLogs: this._forensicsLogs,
526       experiments: this._experiments ? this._experiments.keys() : null,
527       terminateReason: this._terminateReason,
528     };
529     if (this._latestError) {
530       if (typeof this._latestError == "object") {
531         state.latestError = {
532           message: this._latestError.message,
533           stack: this._latestError.stack
534         };
535       } else {
536         state.latestError = "" + this._latestError;
537       }
538     }
539     return state;
540   },
542   _registerWithAddonManager: function (previousExperimentsProvider) {
543     this._log.trace("Registering instance with Addon Manager.");
545     AddonManager.addAddonListener(this);
546     AddonManager.addInstallListener(this);
548     if (!gAddonProvider) {
549       // The properties of this AddonType should be kept in sync with the
550       // experiment AddonType registered in XPIProvider.
551       this._log.trace("Registering previous experiment add-on provider.");
552       gAddonProvider = previousExperimentsProvider || new Experiments.PreviousExperimentProvider(this);
553       AddonManagerPrivate.registerProvider(gAddonProvider, [
554           new AddonManagerPrivate.AddonType("experiment",
555                                             URI_EXTENSION_STRINGS,
556                                             STRING_TYPE_NAME,
557                                             AddonManager.VIEW_TYPE_LIST,
558                                             11000,
559                                             AddonManager.TYPE_UI_HIDE_EMPTY),
560       ]);
561     }
563   },
565   _unregisterWithAddonManager: function () {
566     this._log.trace("Unregistering instance with Addon Manager.");
568     this._log.trace("Removing install listener from add-on manager.");
569     AddonManager.removeInstallListener(this);
570     this._log.trace("Removing addon listener from add-on manager.");
571     AddonManager.removeAddonListener(this);
572     this._log.trace("Finished unregistering with addon manager.");
574     if (gAddonProvider) {
575       this._log.trace("Unregistering previous experiment add-on provider.");
576       AddonManagerPrivate.unregisterProvider(gAddonProvider);
577       gAddonProvider = null;
578     }
579   },
581   /*
582    * Change the PreviousExperimentsProvider that this instance uses.
583    * For testing only.
584    */
585   _setPreviousExperimentsProvider: function (provider) {
586     this._unregisterWithAddonManager();
587     this._registerWithAddonManager(provider);
588   },
590   /**
591    * Throws an exception if we've already shut down.
592    */
593   _checkForShutdown: function() {
594     if (this._shutdown) {
595       throw new AlreadyShutdownError("uninit() already called");
596     }
597   },
599   /**
600    * Whether the experiments feature is enabled.
601    */
602   get enabled() {
603     return gExperimentsEnabled;
604   },
606   /**
607    * Toggle whether the experiments feature is enabled or not.
608    */
609   set enabled(enabled) {
610     this._log.trace("set enabled(" + enabled + ")");
611     gPrefs.set(PREF_ENABLED, enabled);
612   },
614   _toggleExperimentsEnabled: Task.async(function* (enabled) {
615     this._log.trace("_toggleExperimentsEnabled(" + enabled + ")");
616     let wasEnabled = gExperimentsEnabled;
617     gExperimentsEnabled = enabled && telemetryEnabled();
619     if (wasEnabled == gExperimentsEnabled) {
620       return;
621     }
623     if (gExperimentsEnabled) {
624       yield this.updateManifest();
625     } else {
626       yield this.disableExperiment(TELEMETRY_LOG.TERMINATION.SERVICE_DISABLED);
627       if (this._timer) {
628         this._timer.clear();
629       }
630     }
631   }),
633   _telemetryStatusChanged: function () {
634     this._toggleExperimentsEnabled(gExperimentsEnabled);
635   },
637   /**
638    * Returns a promise that is resolved with an array of `ExperimentInfo` objects,
639    * which provide info on the currently and recently active experiments.
640    * The array is in chronological order.
641    *
642    * The experiment info is of the form:
643    * {
644    *   id: <string>,
645    *   name: <string>,
646    *   description: <string>,
647    *   active: <boolean>,
648    *   endDate: <integer>, // epoch ms
649    *   detailURL: <string>,
650    *   ... // possibly extended later
651    * }
652    *
653    * @return Promise<Array<ExperimentInfo>> Array of experiment info objects.
654    */
655   getExperiments: function () {
656     return Task.spawn(function*() {
657       yield this._loadTask;
658       let list = [];
660       for (let [id, experiment] of this._experiments) {
661         if (!experiment.startDate) {
662           // We only collect experiments that are or were active.
663           continue;
664         }
666         list.push({
667           id: id,
668           name: experiment._name,
669           description: experiment._description,
670           active: experiment.enabled,
671           endDate: experiment.endDate.getTime(),
672           detailURL: experiment._homepageURL,
673           branch: experiment.branch,
674         });
675       }
677       // Sort chronologically, descending.
678       list.sort((a, b) => b.endDate - a.endDate);
679       return list;
680     }.bind(this));
681   },
683   /**
684    * Returns the ExperimentInfo for the active experiment, or null
685    * if there is none.
686    */
687   getActiveExperiment: function () {
688     let experiment = this._getActiveExperiment();
689     if (!experiment) {
690       return null;
691     }
693     let info = {
694       id: experiment.id,
695       name: experiment._name,
696       description: experiment._description,
697       active: experiment.enabled,
698       endDate: experiment.endDate.getTime(),
699       detailURL: experiment._homepageURL,
700     };
702     return info;
703   },
705   /**
706    * Experiment "branch" support. If an experiment has multiple branches, it
707    * can record the branch with the experiment system and it will
708    * automatically be included in data reporting (FHR/telemetry payloads).
709    */
711   /**
712    * Set the experiment branch for the specified experiment ID.
713    * @returns Promise<>
714    */
715   setExperimentBranch: Task.async(function*(id, branchstr) {
716     yield this._loadTask;
717     let e = this._experiments.get(id);
718     if (!e) {
719       throw new Error("Experiment not found");
720     }
721     e.branch = String(branchstr);
722     this._log.trace("setExperimentBranch(" + id + ", " + e.branch + ") _dirty=" + this._dirty);
723     this._dirty = true;
724     Services.obs.notifyObservers(null, EXPERIMENTS_CHANGED_TOPIC, null);
725     yield this._run();
726   }),
727   /**
728    * Get the branch of the specified experiment. If the experiment is unknown,
729    * throws an error.
730    *
731    * @param id The ID of the experiment. Pass null for the currently running
732    *           experiment.
733    * @returns Promise<string|null>
734    * @throws Error if the specified experiment ID is unknown, or if there is no
735    *         current experiment.
736    */
737   getExperimentBranch: Task.async(function*(id=null) {
738     yield this._loadTask;
739     let e;
740     if (id) {
741       e = this._experiments.get(id);
742       if (!e) {
743         throw new Error("Experiment not found");
744       }
745     } else {
746       e = this._getActiveExperiment();
747       if (e === null) {
748         throw new Error("No active experiment");
749       }
750     }
751     return e.branch;
752   }),
754   /**
755    * Determine whether another date has the same UTC day as now().
756    */
757   _dateIsTodayUTC: function (d) {
758     let now = this._policy.now();
760     return stripDateToMidnight(now).getTime() == stripDateToMidnight(d).getTime();
761   },
763   /**
764    * Obtain the entry of the most recent active experiment that was active
765    * today.
766    *
767    * If no experiment was active today, this resolves to nothing.
768    *
769    * Assumption: Only a single experiment can be active at a time.
770    *
771    * @return Promise<object>
772    */
773   lastActiveToday: function () {
774     return Task.spawn(function* getMostRecentActiveExperimentTask() {
775       let experiments = yield this.getExperiments();
777       // Assumption: Ordered chronologically, descending, with active always
778       // first.
779       for (let experiment of experiments) {
780         if (experiment.active) {
781           return experiment;
782         }
784         if (experiment.endDate && this._dateIsTodayUTC(experiment.endDate)) {
785           return experiment;
786         }
787       }
788       return null;
789     }.bind(this));
790   },
792   _run: function() {
793     this._log.trace("_run");
794     this._checkForShutdown();
795     if (!this._mainTask) {
796       this._mainTask = Task.spawn(function*() {
797         try {
798           yield this._main();
799         } catch (e if e instanceof CacheWriteError) {
800           // In this case we want to reschedule
801         } catch (e) {
802           this._log.error("_main caught error: " + e);
803           return;
804         } finally {
805           this._mainTask = null;
806         }
807         this._log.trace("_main finished, scheduling next run");
808         try {
809           yield this._scheduleNextRun();
810         } catch (ex if ex instanceof AlreadyShutdownError) {
811           // We error out of tasks after shutdown via that exception.
812         }
813       }.bind(this));
814     }
815     return this._mainTask;
816   },
818   _main: function*() {
819     do {
820       this._log.trace("_main iteration");
821       yield this._loadTask;
822       if (!gExperimentsEnabled) {
823         this._refresh = false;
824       }
826       if (this._refresh) {
827         yield this._loadManifest();
828       }
829       yield this._evaluateExperiments();
830       if (this._dirty) {
831         yield this._saveToCache();
832       }
833       // If somebody called .updateManifest() or disableExperiment()
834       // while we were running, go again right now.
835     }
836     while (this._refresh || this._terminateReason || this._dirty);
837   },
839   _loadManifest: function*() {
840     this._log.trace("_loadManifest");
841     let uri = Services.urlFormatter.formatURLPref(PREF_BRANCH + PREF_MANIFEST_URI);
843     this._checkForShutdown();
845     this._refresh = false;
846     try {
847       let responseText = yield this._httpGetRequest(uri);
848       this._log.trace("_loadManifest() - responseText=\"" + responseText + "\"");
850       if (this._shutdown) {
851         return;
852       }
854       let data = JSON.parse(responseText);
855       this._updateExperiments(data);
856     } catch (e) {
857       this._log.error("_loadManifest - failure to fetch/parse manifest (continuing anyway): " + e);
858     }
859   },
861   /**
862    * Fetch an updated list of experiments and trigger experiment updates.
863    * Do only use when experiments are enabled.
864    *
865    * @return Promise<>
866    *         The promise is resolved when the manifest and experiment list is updated.
867    */
868   updateManifest: function () {
869     this._log.trace("updateManifest()");
871     if (!gExperimentsEnabled) {
872       return Promise.reject(new Error("experiments are disabled"));
873     }
875     if (this._shutdown) {
876       return Promise.reject(Error("uninit() alrady called"));
877     }
879     this._refresh = true;
880     return this._run();
881   },
883   notify: function (timer) {
884     this._log.trace("notify()");
885     this._checkForShutdown();
886     return this._run();
887   },
889   // START OF ADD-ON LISTENERS
891   onUninstalled: function (addon) {
892     this._log.trace("onUninstalled() - addon id: " + addon.id);
893     if (gActiveUninstallAddonIDs.has(addon.id)) {
894       this._log.trace("matches pending uninstall");
895       return;
896     }
897     let activeExperiment = this._getActiveExperiment();
898     if (!activeExperiment || activeExperiment._addonId != addon.id) {
899       return;
900     }
902     this.disableExperiment(TELEMETRY_LOG.TERMINATION.ADDON_UNINSTALLED);
903   },
905   onInstallStarted: function (install) {
906     if (install.addon.type != "experiment") {
907       return;
908     }
910     this._log.trace("onInstallStarted() - " + install.addon.id);
911     if (install.addon.appDisabled) {
912       // This is a PreviousExperiment
913       return;
914     }
916     // We want to be in control of all experiment add-ons: reject installs
917     // for add-ons that we don't know about.
919     // We have a race condition of sorts to worry about here. We have 2
920     // onInstallStarted listeners. This one (the global one) and the one
921     // created as part of ExperimentEntry._installAddon. Because of the order
922     // they are registered in, this one likely executes first. Unfortunately,
923     // this means that the add-on ID is not yet set on the ExperimentEntry.
924     // So, we can't just look at this._trackedAddonIds because the new experiment
925     // will have its add-on ID set to null. We work around this by storing a
926     // identifying field - the source URL of the install - in a module-level
927     // variable (so multiple Experiments instances doesn't cancel each other
928     // out).
930     if (this._trackedAddonIds.has(install.addon.id)) {
931       this._log.info("onInstallStarted allowing install because add-on ID " +
932                      "tracked by us.");
933       return;
934     }
936     if (gActiveInstallURLs.has(install.sourceURI.spec)) {
937       this._log.info("onInstallStarted allowing install because install " +
938                      "tracked by us.");
939       return;
940     }
942     this._log.warn("onInstallStarted cancelling install of unknown " +
943                    "experiment add-on: " + install.addon.id);
944     return false;
945   },
947   // END OF ADD-ON LISTENERS.
949   _getExperimentByAddonId: function (addonId) {
950     for (let [, entry] of this._experiments) {
951       if (entry._addonId === addonId) {
952         return entry;
953       }
954     }
956     return null;
957   },
959   /*
960    * Helper function to make HTTP GET requests. Returns a promise that is resolved with
961    * the responseText when the request is complete.
962    */
963   _httpGetRequest: function (url) {
964     this._log.trace("httpGetRequest(" + url + ")");
965     let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].createInstance(Ci.nsIXMLHttpRequest);
966     try {
967       xhr.open("GET", url);
968     } catch (e) {
969       this._log.error("httpGetRequest() - Error opening request to " + url + ": " + e);
970       return Promise.reject(new Error("Experiments - Error opening XHR for " + url));
971     }
973     this._networkRequest = xhr;
974     let deferred = Promise.defer();
976     let log = this._log;
977     let errorhandler = (evt) => {
978       log.error("httpGetRequest::onError() - Error making request to " + url + ": " + evt.type);
979       deferred.reject(new Error("Experiments - XHR error for " + url + " - " + evt.type));
980       this._networkRequest = null;
981     };
982     xhr.onerror = errorhandler;
983     xhr.ontimeout = errorhandler;
984     xhr.onabort = errorhandler;
986     xhr.onload = (event) => {
987       if (xhr.status !== 200 && xhr.state !== 0) {
988         log.error("httpGetRequest::onLoad() - Request to " + url + " returned status " + xhr.status);
989         deferred.reject(new Error("Experiments - XHR status for " + url + " is " + xhr.status));
990         this._networkRequest = null;
991         return;
992       }
994       deferred.resolve(xhr.responseText);
995       this._networkRequest = null;
996     };
998     if (xhr.channel instanceof Ci.nsISupportsPriority) {
999       xhr.channel.priority = Ci.nsISupportsPriority.PRIORITY_LOWEST;
1000     }
1002     xhr.timeout = MANIFEST_FETCH_TIMEOUT_MSEC;
1003     xhr.send(null);
1004     return deferred.promise;
1005   },
1007   /*
1008    * Path of the cache file we use in the profile.
1009    */
1010   get _cacheFilePath() {
1011     return OS.Path.join(OS.Constants.Path.profileDir, FILE_CACHE);
1012   },
1014   /*
1015    * Part of the main task to save the cache to disk, called from _main.
1016    */
1017   _saveToCache: function* () {
1018     this._log.trace("_saveToCache");
1019     let path = this._cacheFilePath;
1020     this._dirty = false;
1021     try {
1022       let textData = JSON.stringify({
1023         version: CACHE_VERSION,
1024         data: [e[1].toJSON() for (e of this._experiments.entries())],
1025       });
1027       let encoder = new TextEncoder();
1028       let data = encoder.encode(textData);
1029       let options = { tmpPath: path + ".tmp", compression: "lz4" };
1030       yield this._policy.delayCacheWrite(OS.File.writeAtomic(path, data, options));
1031     } catch (e) {
1032       // We failed to write the cache, it's still dirty.
1033       this._dirty = true;
1034       this._log.error("_saveToCache failed and caught error: " + e);
1035       throw new CacheWriteError();
1036     }
1038     this._log.debug("_saveToCache saved to " + path);
1039   },
1041   /*
1042    * Task function, load the cached experiments manifest file from disk.
1043    */
1044   _loadFromCache: Task.async(function* () {
1045     this._log.trace("_loadFromCache");
1046     let path = this._cacheFilePath;
1047     try {
1048       let result = yield loadJSONAsync(path, { compression: "lz4" });
1049       this._populateFromCache(result);
1050     } catch (e if e instanceof OS.File.Error && e.becauseNoSuchFile) {
1051       // No cached manifest yet.
1052       this._experiments = new Map();
1053     }
1054   }),
1056   _populateFromCache: function (data) {
1057     this._log.trace("populateFromCache() - data: " + JSON.stringify(data));
1059     // If the user has a newer cache version than we can understand, we fail
1060     // hard; no experiments should be active in this older client.
1061     if (CACHE_VERSION !== data.version) {
1062       throw new Error("Experiments::_populateFromCache() - invalid cache version");
1063     }
1065     let experiments = new Map();
1066     for (let item of data.data) {
1067       let entry = new Experiments.ExperimentEntry(this._policy);
1068       if (!entry.initFromCacheData(item)) {
1069         continue;
1070       }
1071       experiments.set(entry.id, entry);
1072     }
1074     this._experiments = experiments;
1075   },
1077   /*
1078    * Update the experiment entries from the experiments
1079    * array in the manifest
1080    */
1081   _updateExperiments: function (manifestObject) {
1082     this._log.trace("_updateExperiments() - experiments: " + JSON.stringify(manifestObject));
1084     if (manifestObject.version !== MANIFEST_VERSION) {
1085       this._log.warning("updateExperiments() - unsupported version " + manifestObject.version);
1086     }
1088     let experiments = new Map(); // The new experiments map
1090     // Collect new and updated experiments.
1091     for (let data of manifestObject.experiments) {
1092       let entry = this._experiments.get(data.id);
1094       if (entry) {
1095         if (!entry.updateFromManifestData(data)) {
1096           this._log.error("updateExperiments() - Invalid manifest data for " + data.id);
1097           continue;
1098         }
1099       } else {
1100         entry = new Experiments.ExperimentEntry(this._policy);
1101         if (!entry.initFromManifestData(data)) {
1102           continue;
1103         }
1104       }
1106       if (entry.shouldDiscard()) {
1107         continue;
1108       }
1110       experiments.set(entry.id, entry);
1111     }
1113     // Make sure we keep experiments that are or were running.
1114     // We remove them after KEEP_HISTORY_N_DAYS.
1115     for (let [id, entry] of this._experiments) {
1116       if (experiments.has(id)) {
1117         continue;
1118       }
1120       if (!entry.startDate || entry.shouldDiscard()) {
1121         this._log.trace("updateExperiments() - discarding entry for " + id);
1122         continue;
1123       }
1125       experiments.set(id, entry);
1126     }
1128     this._experiments = experiments;
1129     this._dirty = true;
1130   },
1132   getActiveExperimentID: function() {
1133     if (!this._experiments) {
1134       return null;
1135     }
1136     let e = this._getActiveExperiment();
1137     if (!e) {
1138       return null;
1139     }
1140     return e.id;
1141   },
1143   getActiveExperimentBranch: function() {
1144     if (!this._experiments) {
1145       return null;
1146     }
1147     let e = this._getActiveExperiment();
1148     if (!e) {
1149       return null;
1150     }
1151     return e.branch;
1152   },
1154   _getActiveExperiment: function () {
1155     let enabled = [experiment for ([,experiment] of this._experiments) if (experiment._enabled)];
1157     if (enabled.length == 1) {
1158       return enabled[0];
1159     }
1161     if (enabled.length > 1) {
1162       this._log.error("getActiveExperimentId() - should not have more than 1 active experiment");
1163       throw new Error("have more than 1 active experiment");
1164     }
1166     return null;
1167   },
1169   /**
1170    * Disables all active experiments.
1171    *
1172    * @return Promise<> Promise that will get resolved once the task is done or failed.
1173    */
1174   disableExperiment: function (reason) {
1175     if (!reason) {
1176       throw new Error("Must specify a termination reason.");
1177     }
1179     this._log.trace("disableExperiment()");
1180     this._terminateReason = reason;
1181     return this._run();
1182   },
1184   /**
1185    * The Set of add-on IDs that we know about from manifests.
1186    */
1187   get _trackedAddonIds() {
1188     if (!this._experiments) {
1189       return new Set();
1190     }
1192     return new Set([e._addonId for ([,e] of this._experiments) if (e._addonId)]);
1193   },
1195   /*
1196    * Task function to check applicability of experiments, disable the active
1197    * experiment if needed and activate the first applicable candidate.
1198    */
1199   _evaluateExperiments: function*() {
1200     this._log.trace("_evaluateExperiments");
1202     this._checkForShutdown();
1204     // The first thing we do is reconcile our state against what's in the
1205     // Addon Manager. It's possible that the Addon Manager knows of experiment
1206     // add-ons that we don't. This could happen if an experiment gets installed
1207     // when we're not listening or if there is a bug in our synchronization
1208     // code.
1209     //
1210     // We have a few options of what to do with unknown experiment add-ons
1211     // coming from the Addon Manager. Ideally, we'd convert these to
1212     // ExperimentEntry instances and stuff them inside this._experiments.
1213     // However, since ExperimentEntry contain lots of metadata from the
1214     // manifest and trying to make up data could be error prone, it's safer
1215     // to not try. Furthermore, if an experiment really did come from us, we
1216     // should have some record of it. In the end, we decide to discard all
1217     // knowledge for these unknown experiment add-ons.
1218     let installedExperiments = yield installedExperimentAddons();
1219     let expectedAddonIds = this._trackedAddonIds;
1220     let unknownAddons = [a for (a of installedExperiments) if (!expectedAddonIds.has(a.id))];
1221     if (unknownAddons.length) {
1222       this._log.warn("_evaluateExperiments() - unknown add-ons in AddonManager: " +
1223                      [a.id for (a of unknownAddons)].join(", "));
1225       yield uninstallAddons(unknownAddons);
1226     }
1228     let activeExperiment = this._getActiveExperiment();
1229     let activeChanged = false;
1230     let now = this._policy.now();
1232     if (!activeExperiment) {
1233       // Avoid this pref staying out of sync if there were e.g. crashes.
1234       gPrefs.set(PREF_ACTIVE_EXPERIMENT, false);
1235     }
1237     // Ensure the active experiment is in the proper state. This may install,
1238     // uninstall, upgrade, or enable the experiment add-on. What exactly is
1239     // abstracted away from us by design.
1240     if (activeExperiment) {
1241       let changes;
1242       let shouldStopResult = yield activeExperiment.shouldStop();
1243       if (shouldStopResult.shouldStop) {
1244         let expireReasons = ["endTime", "maxActiveSeconds"];
1245         let kind, reason;
1247         if (expireReasons.indexOf(shouldStopResult.reason[0]) != -1) {
1248           kind = TELEMETRY_LOG.TERMINATION.EXPIRED;
1249           reason = null;
1250         } else {
1251           kind = TELEMETRY_LOG.TERMINATION.RECHECK;
1252           reason = shouldStopResult.reason;
1253         }
1254         changes = yield activeExperiment.stop(kind, reason);
1255       }
1256       else if (this._terminateReason) {
1257         changes = yield activeExperiment.stop(this._terminateReason);
1258       }
1259       else {
1260         changes = yield activeExperiment.reconcileAddonState();
1261       }
1263       if (changes) {
1264         this._dirty = true;
1265         activeChanged = true;
1266       }
1268       if (!activeExperiment._enabled) {
1269         activeExperiment = null;
1270         activeChanged = true;
1271       }
1272     }
1274     this._terminateReason = null;
1276     if (!activeExperiment && gExperimentsEnabled) {
1277       for (let [id, experiment] of this._experiments) {
1278         let applicable;
1279         let reason = null;
1280         try {
1281           applicable = yield experiment.isApplicable();
1282         }
1283         catch (e) {
1284           applicable = false;
1285           reason = e;
1286         }
1288         if (!applicable && reason && reason[0] != "was-active") {
1289           // Report this from here to avoid over-reporting.
1290           let desc = TELEMETRY_LOG.ACTIVATION;
1291           let data = [TELEMETRY_LOG.ACTIVATION.REJECTED, id];
1292           data = data.concat(reason);
1293           const key = TELEMETRY_LOG.ACTIVATION_KEY;
1294           TelemetryLog.log(key, data);
1295           this._log.trace("evaluateExperiments() - added " + key + " to TelemetryLog: " + JSON.stringify(data));
1296         }
1298         if (!applicable) {
1299           continue;
1300         }
1302         this._log.debug("evaluateExperiments() - activating experiment " + id);
1303         try {
1304           yield experiment.start();
1305           activeChanged = true;
1306           activeExperiment = experiment;
1307           this._dirty = true;
1308           break;
1309         } catch (e) {
1310           // On failure, clean up the best we can and try the next experiment.
1311           this._log.error("evaluateExperiments() - Unable to start experiment: " + e.message);
1312           experiment._enabled = false;
1313           yield experiment.reconcileAddonState();
1314         }
1315       }
1316     }
1318     gPrefs.set(PREF_ACTIVE_EXPERIMENT, activeExperiment != null);
1320     if (activeChanged || this._firstEvaluate) {
1321       Services.obs.notifyObservers(null, EXPERIMENTS_CHANGED_TOPIC, null);
1322       this._firstEvaluate = false;
1323     }
1325     if ("@mozilla.org/toolkit/crash-reporter;1" in Cc && activeExperiment) {
1326       try {
1327         gCrashReporter.annotateCrashReport("ActiveExperiment", activeExperiment.id);
1328         gCrashReporter.annotateCrashReport("ActiveExperimentBranch", activeExperiment.branch);
1329       } catch (e) {
1330         // It's ok if crash reporting is disabled.
1331       }
1332     }
1333   },
1335   /*
1336    * Schedule the soonest re-check of experiment applicability that is needed.
1337    */
1338   _scheduleNextRun: function () {
1339     this._checkForShutdown();
1341     if (this._timer) {
1342       this._timer.clear();
1343     }
1345     if (!gExperimentsEnabled || this._experiments.length == 0) {
1346       return;
1347     }
1349     let time = null;
1350     let now = this._policy.now().getTime();
1351     if (this._dirty) {
1352       // If we failed to write the cache, we should try again periodically
1353       time = now + 1000 * CACHE_WRITE_RETRY_DELAY_SEC;
1354     }
1356     for (let [id, experiment] of this._experiments) {
1357       let scheduleTime = experiment.getScheduleTime();
1358       if (scheduleTime > now) {
1359         if (time !== null) {
1360           time = Math.min(time, scheduleTime);
1361         } else {
1362           time = scheduleTime;
1363         }
1364       }
1365     }
1367     if (time === null) {
1368       // No schedule time found.
1369       return;
1370     }
1372     this._log.trace("scheduleExperimentEvaluation() - scheduling for "+time+", now: "+now);
1373     this._policy.oneshotTimer(this.notify, time - now, this, "_timer");
1374   },
1379  * Represents a single experiment.
1380  */
1382 Experiments.ExperimentEntry = function (policy) {
1383   this._policy = policy || new Experiments.Policy();
1384   this._log = Log.repository.getLoggerWithMessagePrefix(
1385     "Browser.Experiments.Experiments",
1386     "ExperimentEntry #" + gExperimentEntryCounter++ + "::");
1388   // Is the experiment supposed to be running.
1389   this._enabled = false;
1390   // When this experiment was started, if ever.
1391   this._startDate = null;
1392   // When this experiment was ended, if ever.
1393   this._endDate = null;
1394   // The condition data from the manifest.
1395   this._manifestData = null;
1396   // For an active experiment, signifies whether we need to update the xpi.
1397   this._needsUpdate = false;
1398   // A random sample value for comparison against the manifest conditions.
1399   this._randomValue = null;
1400   // When this entry was last changed for respecting history retention duration.
1401   this._lastChangedDate = null;
1402   // Has this experiment failed to activate before?
1403   this._failedStart = false;
1404   // The experiment branch
1405   this._branch = null;
1407   // We grab these from the addon after download.
1408   this._name = null;
1409   this._description = null;
1410   this._homepageURL = null;
1411   this._addonId = null;
1414 Experiments.ExperimentEntry.prototype = {
1415   MANIFEST_REQUIRED_FIELDS: new Set([
1416     "id",
1417     "xpiURL",
1418     "xpiHash",
1419     "startTime",
1420     "endTime",
1421     "maxActiveSeconds",
1422     "appName",
1423     "channel",
1424   ]),
1426   MANIFEST_OPTIONAL_FIELDS: new Set([
1427     "maxStartTime",
1428     "minVersion",
1429     "maxVersion",
1430     "version",
1431     "minBuildID",
1432     "maxBuildID",
1433     "buildIDs",
1434     "os",
1435     "locale",
1436     "sample",
1437     "disabled",
1438     "frozen",
1439     "jsfilter",
1440   ]),
1442   SERIALIZE_KEYS: new Set([
1443     "_enabled",
1444     "_manifestData",
1445     "_needsUpdate",
1446     "_randomValue",
1447     "_failedStart",
1448     "_name",
1449     "_description",
1450     "_homepageURL",
1451     "_addonId",
1452     "_startDate",
1453     "_endDate",
1454     "_branch",
1455   ]),
1457   DATE_KEYS: new Set([
1458     "_startDate",
1459     "_endDate",
1460   ]),
1462   UPGRADE_KEYS: new Map([
1463     ["_branch", null],
1464   ]),
1466   ADDON_CHANGE_NONE: 0,
1467   ADDON_CHANGE_INSTALL: 1,
1468   ADDON_CHANGE_UNINSTALL: 2,
1469   ADDON_CHANGE_ENABLE: 4,
1471   /*
1472    * Initialize entry from the manifest.
1473    * @param data The experiment data from the manifest.
1474    * @return boolean Whether initialization succeeded.
1475    */
1476   initFromManifestData: function (data) {
1477     if (!this._isManifestDataValid(data)) {
1478       return false;
1479     }
1481     this._manifestData = data;
1483     this._randomValue = this._policy.random();
1484     this._lastChangedDate = this._policy.now();
1486     return true;
1487   },
1489   get enabled() {
1490     return this._enabled;
1491   },
1493   get id() {
1494     return this._manifestData.id;
1495   },
1497   get branch() {
1498     return this._branch;
1499   },
1501   set branch(v) {
1502     this._branch = v;
1503   },
1505   get startDate() {
1506     return this._startDate;
1507   },
1509   get endDate() {
1510     if (!this._startDate) {
1511       return null;
1512     }
1514     let endTime = 0;
1516     if (!this._enabled) {
1517       return this._endDate;
1518     }
1520     let maxActiveMs = 1000 * this._manifestData.maxActiveSeconds;
1521     endTime = Math.min(1000 * this._manifestData.endTime,
1522                        this._startDate.getTime() + maxActiveMs);
1524     return new Date(endTime);
1525   },
1527   get needsUpdate() {
1528     return this._needsUpdate;
1529   },
1531   /*
1532    * Initialize entry from the cache.
1533    * @param data The entry data from the cache.
1534    * @return boolean Whether initialization succeeded.
1535    */
1536   initFromCacheData: function (data) {
1537     for (let [key, dval] of this.UPGRADE_KEYS) {
1538       if (!(key in data)) {
1539         data[key] = dval;
1540       }
1541     }
1543     for (let key of this.SERIALIZE_KEYS) {
1544       if (!(key in data) && !this.DATE_KEYS.has(key)) {
1545         this._log.error("initFromCacheData() - missing required key " + key);
1546         return false;
1547       }
1548     };
1550     if (!this._isManifestDataValid(data._manifestData)) {
1551       return false;
1552     }
1554     // Dates are restored separately from epoch ms, everything else is just
1555     // copied in.
1557     this.SERIALIZE_KEYS.forEach(key => {
1558       if (!this.DATE_KEYS.has(key)) {
1559         this[key] = data[key];
1560       }
1561     });
1563     this.DATE_KEYS.forEach(key => {
1564       if (key in data) {
1565         let date = new Date();
1566         date.setTime(data[key]);
1567         this[key] = date;
1568       }
1569     });
1571     this._lastChangedDate = this._policy.now();
1573     return true;
1574   },
1576   /*
1577    * Returns a JSON representation of this object.
1578    */
1579   toJSON: function () {
1580     let obj = {};
1582     // Dates are serialized separately as epoch ms.
1584     this.SERIALIZE_KEYS.forEach(key => {
1585       if (!this.DATE_KEYS.has(key)) {
1586         obj[key] = this[key];
1587       }
1588     });
1590     this.DATE_KEYS.forEach(key => {
1591       if (this[key]) {
1592         obj[key] = this[key].getTime();
1593       }
1594     });
1596     return obj;
1597   },
1599   /*
1600    * Update from the experiment data from the manifest.
1601    * @param data The experiment data from the manifest.
1602    * @return boolean Whether updating succeeded.
1603    */
1604   updateFromManifestData: function (data) {
1605     let old = this._manifestData;
1607     if (!this._isManifestDataValid(data)) {
1608       return false;
1609     }
1611     if (this._enabled) {
1612       if (old.xpiHash !== data.xpiHash) {
1613         // A changed hash means we need to update active experiments.
1614         this._needsUpdate = true;
1615       }
1616     } else if (this._failedStart &&
1617                (old.xpiHash !== data.xpiHash) ||
1618                (old.xpiURL !== data.xpiURL)) {
1619       // Retry installation of previously invalid experiments
1620       // if hash or url changed.
1621       this._failedStart = false;
1622     }
1624     this._manifestData = data;
1625     this._lastChangedDate = this._policy.now();
1627     return true;
1628   },
1630   /*
1631    * Is this experiment applicable?
1632    * @return Promise<> Resolved if the experiment is applicable.
1633    *                   If it is not applicable it is rejected with
1634    *                   a Promise<string> which contains the reason.
1635    */
1636   isApplicable: function () {
1637     let versionCmp = Cc["@mozilla.org/xpcom/version-comparator;1"]
1638                               .getService(Ci.nsIVersionComparator);
1639     let app = Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULAppInfo);
1640     let runtime = Cc["@mozilla.org/xre/app-info;1"]
1641                     .getService(Ci.nsIXULRuntime);
1643     let locale = this._policy.locale();
1644     let channel = this._policy.updatechannel();
1645     let data = this._manifestData;
1647     let now = this._policy.now() / 1000; // The manifest times are in seconds.
1648     let minActive = MIN_EXPERIMENT_ACTIVE_SECONDS;
1649     let maxActive = data.maxActiveSeconds || 0;
1650     let startSec = (this.startDate || 0) / 1000;
1652     this._log.trace("isApplicable() - now=" + now
1653                     + ", randomValue=" + this._randomValue
1654                     + ", data=" + JSON.stringify(this._manifestData));
1656     // Not applicable if it already ran.
1658     if (!this.enabled && this._endDate) {
1659       return Promise.reject(["was-active"]);
1660     }
1662     // Define and run the condition checks.
1664     let simpleChecks = [
1665       { name: "failedStart",
1666         condition: () => !this._failedStart },
1667       { name: "disabled",
1668         condition: () => !data.disabled },
1669       { name: "frozen",
1670         condition: () => !data.frozen || this._enabled },
1671       { name: "startTime",
1672         condition: () => now >= data.startTime },
1673       { name: "endTime",
1674         condition: () => now < data.endTime },
1675       { name: "maxStartTime",
1676         condition: () => this._startDate || !data.maxStartTime || now <= data.maxStartTime },
1677       { name: "maxActiveSeconds",
1678         condition: () => !this._startDate || now <= (startSec + maxActive) },
1679       { name: "appName",
1680         condition: () => !data.appName || data.appName.indexOf(app.name) != -1 },
1681       { name: "minBuildID",
1682         condition: () => !data.minBuildID || app.platformBuildID >= data.minBuildID },
1683       { name: "maxBuildID",
1684         condition: () => !data.maxBuildID || app.platformBuildID <= data.maxBuildID },
1685       { name: "buildIDs",
1686         condition: () => !data.buildIDs || data.buildIDs.indexOf(app.platformBuildID) != -1 },
1687       { name: "os",
1688         condition: () => !data.os || data.os.indexOf(runtime.OS) != -1 },
1689       { name: "channel",
1690         condition: () => !data.channel || data.channel.indexOf(channel) != -1 },
1691       { name: "locale",
1692         condition: () => !data.locale || data.locale.indexOf(locale) != -1 },
1693       { name: "sample",
1694         condition: () => data.sample === undefined || this._randomValue <= data.sample },
1695       { name: "version",
1696         condition: () => !data.version || data.version.indexOf(app.version) != -1 },
1697       { name: "minVersion",
1698         condition: () => !data.minVersion || versionCmp.compare(app.version, data.minVersion) >= 0 },
1699       { name: "maxVersion",
1700         condition: () => !data.maxVersion || versionCmp.compare(app.version, data.maxVersion) <= 0 },
1701     ];
1703     for (let check of simpleChecks) {
1704       let result = check.condition();
1705       if (!result) {
1706         this._log.debug("isApplicable() - id="
1707                         + data.id + " - test '" + check.name + "' failed");
1708         return Promise.reject([check.name]);
1709       }
1710     }
1712     if (data.jsfilter) {
1713       return this._runFilterFunction(data.jsfilter);
1714     }
1716     return Promise.resolve(true);
1717   },
1719   /*
1720    * Run the jsfilter function from the manifest in a sandbox and return the
1721    * result (forced to boolean).
1722    */
1723   _runFilterFunction: function (jsfilter) {
1724     this._log.trace("runFilterFunction() - filter: " + jsfilter);
1726     return Task.spawn(function ExperimentEntry_runFilterFunction_task() {
1727       const nullprincipal = Cc["@mozilla.org/nullprincipal;1"].createInstance(Ci.nsIPrincipal);
1728       let options = {
1729         sandboxName: "telemetry experiments jsfilter sandbox",
1730         wantComponents: false,
1731       };
1733       let sandbox = Cu.Sandbox(nullprincipal, options);
1734       try {
1735         Cu.evalInSandbox(jsfilter, sandbox);
1736       } catch (e) {
1737         this._log.error("runFilterFunction() - failed to eval jsfilter: " + e.message);
1738         throw ["jsfilter-evalfailed"];
1739       }
1741       // You can't insert arbitrarily complex objects into a sandbox, so
1742       // we serialize everything through JSON.
1743       sandbox._hr = yield this._policy.healthReportPayload();
1744       Object.defineProperty(sandbox, "_t",
1745         { get: () => JSON.stringify(this._policy.telemetryPayload()) });
1747       let result = false;
1748       try {
1749         result = !!Cu.evalInSandbox("filter({healthReportPayload: JSON.parse(_hr), telemetryPayload: JSON.parse(_t)})", sandbox);
1750       }
1751       catch (e) {
1752         this._log.debug("runFilterFunction() - filter function failed: "
1753                       + e.message + ", " + e.stack);
1754         throw ["jsfilter-threw", e.message];
1755       }
1756       finally {
1757         Cu.nukeSandbox(sandbox);
1758       }
1760       if (!result) {
1761         throw ["jsfilter-false"];
1762       }
1764       throw new Task.Result(true);
1765     }.bind(this));
1766   },
1768   /*
1769    * Start running the experiment.
1770    *
1771    * @return Promise<> Resolved when the operation is complete.
1772    */
1773   start: Task.async(function* () {
1774     this._log.trace("start() for " + this.id);
1776     this._enabled = true;
1777     return yield this.reconcileAddonState();
1778   }),
1780   // Async install of the addon for this experiment, part of the start task above.
1781   _installAddon: Task.async(function* () {
1782     let deferred = Promise.defer();
1784     let hash = this._policy.ignoreHashes ? null : this._manifestData.xpiHash;
1786     let install = yield addonInstallForURL(this._manifestData.xpiURL, hash);
1787     gActiveInstallURLs.add(install.sourceURI.spec);
1789     let failureHandler = (install, handler) => {
1790       let message = "AddonInstall " + handler + " for " + this.id + ", state=" +
1791                    (install.state || "?") + ", error=" + install.error;
1792       this._log.error("_installAddon() - " + message);
1793       this._failedStart = true;
1794       gActiveInstallURLs.delete(install.sourceURI.spec);
1796       TelemetryLog.log(TELEMETRY_LOG.ACTIVATION_KEY,
1797                       [TELEMETRY_LOG.ACTIVATION.INSTALL_FAILURE, this.id]);
1799       deferred.reject(new Error(message));
1800     };
1802     let listener = {
1803       _expectedID: null,
1805       onDownloadEnded: install => {
1806         this._log.trace("_installAddon() - onDownloadEnded for " + this.id);
1808         if (install.existingAddon) {
1809           this._log.warn("_installAddon() - onDownloadEnded, addon already installed");
1810         }
1812         if (install.addon.type !== "experiment") {
1813           this._log.error("_installAddon() - onDownloadEnded, wrong addon type");
1814           install.cancel();
1815         }
1816       },
1818       onInstallStarted: install => {
1819         this._log.trace("_installAddon() - onInstallStarted for " + this.id);
1821         if (install.existingAddon) {
1822           this._log.warn("_installAddon() - onInstallStarted, addon already installed");
1823         }
1825         if (install.addon.type !== "experiment") {
1826           this._log.error("_installAddon() - onInstallStarted, wrong addon type");
1827           return false;
1828         }
1829       },
1831       onInstallEnded: install => {
1832         this._log.trace("_installAddon() - install ended for " + this.id);
1833         gActiveInstallURLs.delete(install.sourceURI.spec);
1835         this._lastChangedDate = this._policy.now();
1836         this._startDate = this._policy.now();
1837         this._enabled = true;
1839         TelemetryLog.log(TELEMETRY_LOG.ACTIVATION_KEY,
1840                        [TELEMETRY_LOG.ACTIVATION.ACTIVATED, this.id]);
1842         let addon = install.addon;
1843         this._name = addon.name;
1844         this._addonId = addon.id;
1845         this._description = addon.description || "";
1846         this._homepageURL = addon.homepageURL || "";
1848         // Experiment add-ons default to userDisabled=true. Enable if needed.
1849         if (addon.userDisabled) {
1850           this._log.trace("Add-on is disabled. Enabling.");
1851           listener._expectedID = addon.id;
1852           AddonManager.addAddonListener(listener);
1853           addon.userDisabled = false;
1854         } else {
1855           this._log.trace("Add-on is enabled. start() completed.");
1856           deferred.resolve();
1857         }
1858       },
1860       onEnabled: addon => {
1861         this._log.info("onEnabled() for " + addon.id);
1863         if (addon.id != listener._expectedID) {
1864           return;
1865         }
1867         AddonManager.removeAddonListener(listener);
1868         deferred.resolve();
1869       },
1870     };
1872     ["onDownloadCancelled", "onDownloadFailed", "onInstallCancelled", "onInstallFailed"]
1873       .forEach(what => {
1874         listener[what] = install => failureHandler(install, what)
1875       });
1877     install.addListener(listener);
1878     install.install();
1880     return yield deferred.promise;
1881   }),
1883   /**
1884    * Stop running the experiment if it is active.
1885    *
1886    * @param terminationKind (optional)
1887    *        The termination kind, e.g. ADDON_UNINSTALLED or EXPIRED.
1888    * @param terminationReason (optional)
1889    *        The termination reason details for termination kind RECHECK.
1890    * @return Promise<> Resolved when the operation is complete.
1891    */
1892   stop: Task.async(function* (terminationKind, terminationReason) {
1893     this._log.trace("stop() - id=" + this.id + ", terminationKind=" + terminationKind);
1894     if (!this._enabled) {
1895       throw new Error("Must not call stop() on an inactive experiment.");
1896     }
1898     this._enabled = false;
1899     let now = this._policy.now();
1900     this._lastChangedDate = now;
1901     this._endDate = now;
1903     let changes = yield this.reconcileAddonState();
1904     this._logTermination(terminationKind, terminationReason);
1906     if (terminationKind == TELEMETRY_LOG.TERMINATION.ADDON_UNINSTALLED) {
1907       changes |= this.ADDON_CHANGE_UNINSTALL;
1908     }
1910     return changes;
1911   }),
1913   /**
1914    * Reconcile the state of the add-on against what it's supposed to be.
1915    *
1916    * If we are active, ensure the add-on is enabled and up to date.
1917    *
1918    * If we are inactive, ensure the add-on is not installed.
1919    */
1920   reconcileAddonState: Task.async(function* () {
1921     this._log.trace("reconcileAddonState()");
1923     if (!this._enabled) {
1924       if (!this._addonId) {
1925         this._log.trace("reconcileAddonState() - Experiment is not enabled and " +
1926                         "has no add-on. Doing nothing.");
1927         return this.ADDON_CHANGE_NONE;
1928       }
1930       let addon = yield this._getAddon();
1931       if (!addon) {
1932         this._log.trace("reconcileAddonState() - Inactive experiment has no " +
1933                         "add-on. Doing nothing.");
1934         return this.ADDON_CHANGE_NONE;
1935       }
1937       this._log.info("reconcileAddonState() - Uninstalling add-on for inactive " +
1938                      "experiment: " + addon.id);
1939       gActiveUninstallAddonIDs.add(addon.id);
1940       yield uninstallAddons([addon]);
1941       gActiveUninstallAddonIDs.delete(addon.id);
1942       return this.ADDON_CHANGE_UNINSTALL;
1943     }
1945     // If we get here, we're supposed to be active.
1947     let changes = 0;
1949     // That requires an add-on.
1950     let currentAddon = yield this._getAddon();
1952     // If we have an add-on but it isn't up to date, uninstall it
1953     // (to prepare for reinstall).
1954     if (currentAddon && this._needsUpdate) {
1955       this._log.info("reconcileAddonState() - Uninstalling add-on because update " +
1956                      "needed: " + currentAddon.id);
1957       gActiveUninstallAddonIDs.add(currentAddon.id);
1958       yield uninstallAddons([currentAddon]);
1959       gActiveUninstallAddonIDs.delete(currentAddon.id);
1960       changes |= this.ADDON_CHANGE_UNINSTALL;
1961     }
1963     if (!currentAddon || this._needsUpdate) {
1964       this._log.info("reconcileAddonState() - Installing add-on.");
1965       yield this._installAddon();
1966       changes |= this.ADDON_CHANGE_INSTALL;
1967     }
1969     let addon = yield this._getAddon();
1970     if (!addon) {
1971       throw new Error("Could not obtain add-on for experiment that should be " +
1972                       "enabled.");
1973     }
1975     // If we have the add-on and it is enabled, we are done.
1976     if (!addon.userDisabled) {
1977       return changes;
1978     }
1980     let deferred = Promise.defer();
1982     // Else we need to enable it.
1983     let listener = {
1984       onEnabled: enabledAddon => {
1985         if (enabledAddon.id != addon.id) {
1986           return;
1987         }
1989         AddonManager.removeAddonListener(listener);
1990         deferred.resolve();
1991       },
1992     };
1994     this._log.info("Activating add-on: " + addon.id);
1995     AddonManager.addAddonListener(listener);
1996     addon.userDisabled = false;
1997     yield deferred.promise;
1998     changes |= this.ADDON_CHANGE_ENABLE;
2000     this._log.info("Add-on has been enabled: " + addon.id);
2001     return changes;
2002    }),
2004   /**
2005    * Obtain the underlying Addon from the Addon Manager.
2006    *
2007    * @return Promise<Addon|null>
2008    */
2009   _getAddon: function () {
2010     if (!this._addonId) {
2011       return Promise.resolve(null);
2012     }
2014     let deferred = Promise.defer();
2016     AddonManager.getAddonByID(this._addonId, (addon) => {
2017       if (addon && addon.appDisabled) {
2018         // Don't return PreviousExperiments.
2019         addon = null;
2020       }
2022       deferred.resolve(addon);
2023     });
2025     return deferred.promise;
2026   },
2028   _logTermination: function (terminationKind, terminationReason) {
2029     if (terminationKind === undefined) {
2030       return;
2031     }
2033     if (!(terminationKind in TELEMETRY_LOG.TERMINATION)) {
2034       this._log.warn("stop() - unknown terminationKind " + terminationKind);
2035       return;
2036     }
2038     let data = [terminationKind, this.id];
2039     if (terminationReason) {
2040       data = data.concat(terminationReason);
2041     }
2043     TelemetryLog.log(TELEMETRY_LOG.TERMINATION_KEY, data);
2044   },
2046   /**
2047    * Determine whether an active experiment should be stopped.
2048    */
2049   shouldStop: function () {
2050     if (!this._enabled) {
2051       throw new Error("shouldStop must not be called on disabled experiments.");
2052     }
2054     let data = this._manifestData;
2055     let now = this._policy.now() / 1000; // The manifest times are in seconds.
2056     let maxActiveSec = data.maxActiveSeconds || 0;
2058     let deferred = Promise.defer();
2059     this.isApplicable().then(
2060       () => deferred.resolve({shouldStop: false}),
2061       reason => deferred.resolve({shouldStop: true, reason: reason})
2062     );
2064     return deferred.promise;
2065   },
2067   /*
2068    * Should this be discarded from the cache due to age?
2069    */
2070   shouldDiscard: function () {
2071     let limit = this._policy.now();
2072     limit.setDate(limit.getDate() - KEEP_HISTORY_N_DAYS);
2073     return (this._lastChangedDate < limit);
2074   },
2076   /*
2077    * Get next date (in epoch-ms) to schedule a re-evaluation for this.
2078    * Returns 0 if it doesn't need one.
2079    */
2080   getScheduleTime: function () {
2081     if (this._enabled) {
2082       let now = this._policy.now();
2083       let startTime = this._startDate.getTime();
2084       let maxActiveTime = startTime + 1000 * this._manifestData.maxActiveSeconds;
2085       return Math.min(1000 * this._manifestData.endTime,  maxActiveTime);
2086     }
2088     if (this._endDate) {
2089       return this._endDate.getTime();
2090     }
2092     return 1000 * this._manifestData.startTime;
2093   },
2095   /*
2096    * Perform sanity checks on the experiment data.
2097    */
2098   _isManifestDataValid: function (data) {
2099     this._log.trace("isManifestDataValid() - data: " + JSON.stringify(data));
2101     for (let key of this.MANIFEST_REQUIRED_FIELDS) {
2102       if (!(key in data)) {
2103         this._log.error("isManifestDataValid() - missing required key: " + key);
2104         return false;
2105       }
2106     }
2108     for (let key in data) {
2109       if (!this.MANIFEST_OPTIONAL_FIELDS.has(key) &&
2110           !this.MANIFEST_REQUIRED_FIELDS.has(key)) {
2111         this._log.error("isManifestDataValid() - unknown key: " + key);
2112         return false;
2113       }
2114     }
2116     return true;
2117   },
2123  * Strip a Date down to its UTC midnight.
2125  * This will return a cloned Date object. The original is unchanged.
2126  */
2127 let stripDateToMidnight = function (d) {
2128   let m = new Date(d);
2129   m.setUTCHours(0, 0, 0, 0);
2131   return m;
2134 function ExperimentsLastActiveMeasurement1() {
2135   Metrics.Measurement.call(this);
2137 function ExperimentsLastActiveMeasurement2() {
2138   Metrics.Measurement.call(this);
2141 const FIELD_DAILY_LAST_TEXT = {type: Metrics.Storage.FIELD_DAILY_LAST_TEXT};
2143 ExperimentsLastActiveMeasurement1.prototype = Object.freeze({
2144   __proto__: Metrics.Measurement.prototype,
2146   name: "info",
2147   version: 1,
2149   fields: {
2150     lastActive: FIELD_DAILY_LAST_TEXT,
2151   }
2153 ExperimentsLastActiveMeasurement2.prototype = Object.freeze({
2154   __proto__: Metrics.Measurement.prototype,
2156   name: "info",
2157   version: 2,
2159   fields: {
2160     lastActive: FIELD_DAILY_LAST_TEXT,
2161     lastActiveBranch: FIELD_DAILY_LAST_TEXT,
2162   }
2165 this.ExperimentsProvider = function () {
2166   Metrics.Provider.call(this);
2168   this._experiments = null;
2171 ExperimentsProvider.prototype = Object.freeze({
2172   __proto__: Metrics.Provider.prototype,
2174   name: "org.mozilla.experiments",
2176   measurementTypes: [
2177     ExperimentsLastActiveMeasurement1,
2178     ExperimentsLastActiveMeasurement2,
2179   ],
2181   _OBSERVERS: [
2182     EXPERIMENTS_CHANGED_TOPIC,
2183   ],
2185   postInit: function () {
2186     for (let o of this._OBSERVERS) {
2187       Services.obs.addObserver(this, o, false);
2188     }
2190     return Promise.resolve();
2191   },
2193   onShutdown: function () {
2194     for (let o of this._OBSERVERS) {
2195       Services.obs.removeObserver(this, o);
2196     }
2198     return Promise.resolve();
2199   },
2201   observe: function (subject, topic, data) {
2202     switch (topic) {
2203       case EXPERIMENTS_CHANGED_TOPIC:
2204         this.recordLastActiveExperiment();
2205         break;
2206     }
2207   },
2209   collectDailyData: function () {
2210     return this.recordLastActiveExperiment();
2211   },
2213   recordLastActiveExperiment: function () {
2214     if (!gExperimentsEnabled) {
2215       return Promise.resolve();
2216     }
2218     if (!this._experiments) {
2219       this._experiments = Experiments.instance();
2220     }
2222     let m = this.getMeasurement(ExperimentsLastActiveMeasurement2.prototype.name,
2223                                 ExperimentsLastActiveMeasurement2.prototype.version);
2225     return this.enqueueStorageOperation(() => {
2226       return Task.spawn(function* recordTask() {
2227         let todayActive = yield this._experiments.lastActiveToday();
2228         if (!todayActive) {
2229           this._log.info("No active experiment on this day: " +
2230                          this._experiments._policy.now());
2231           return;
2232         }
2234         this._log.info("Recording last active experiment: " + todayActive.id);
2235         yield m.setDailyLastText("lastActive", todayActive.id,
2236                                  this._experiments._policy.now());
2237         let branch = todayActive.branch;
2238         if (branch) {
2239           yield m.setDailyLastText("lastActiveBranch", branch,
2240                                    this._experiments._policy.now());
2241         }
2242       }.bind(this));
2243     });
2244   },
2248  * An Add-ons Manager provider that knows about old experiments.
2250  * This provider exposes read-only add-ons corresponding to previously-active
2251  * experiments. The existence of this provider (and the add-ons it knows about)
2252  * facilitates the display of old experiments in the Add-ons Manager UI with
2253  * very little custom code in that component.
2254  */
2255 this.Experiments.PreviousExperimentProvider = function (experiments) {
2256   this._experiments = experiments;
2257   this._experimentList = [];
2258   this._log = Log.repository.getLoggerWithMessagePrefix(
2259     "Browser.Experiments.Experiments",
2260     "PreviousExperimentProvider #" + gPreviousProviderCounter++ + "::");
2263 this.Experiments.PreviousExperimentProvider.prototype = Object.freeze({
2264   get name() "PreviousExperimentProvider",
2266   startup: function () {
2267     this._log.trace("startup()");
2268     Services.obs.addObserver(this, EXPERIMENTS_CHANGED_TOPIC, false);
2269   },
2271   shutdown: function () {
2272     this._log.trace("shutdown()");
2273     try {
2274       Services.obs.removeObserver(this, EXPERIMENTS_CHANGED_TOPIC);
2275     } catch(e) {
2276       // Prevent crash in mochitest-browser3 on Mulet
2277     }
2278   },
2280   observe: function (subject, topic, data) {
2281     switch (topic) {
2282       case EXPERIMENTS_CHANGED_TOPIC:
2283         this._updateExperimentList();
2284         break;
2285     }
2286   },
2288   getAddonByID: function (id, cb) {
2289     for (let experiment of this._experimentList) {
2290       if (experiment.id == id) {
2291         cb(new PreviousExperimentAddon(experiment));
2292         return;
2293       }
2294     }
2296     cb(null);
2297   },
2299   getAddonsByTypes: function (types, cb) {
2300     if (types && types.length > 0 && types.indexOf("experiment") == -1) {
2301       cb([]);
2302       return;
2303     }
2305     cb([new PreviousExperimentAddon(e) for (e of this._experimentList)]);
2306   },
2308   _updateExperimentList: function () {
2309     return this._experiments.getExperiments().then((experiments) => {
2310       let list = [e for (e of experiments) if (!e.active)];
2312       let newMap = new Map([[e.id, e] for (e of list)]);
2313       let oldMap = new Map([[e.id, e] for (e of this._experimentList)]);
2315       let added = [e.id for (e of list) if (!oldMap.has(e.id))];
2316       let removed = [e.id for (e of this._experimentList) if (!newMap.has(e.id))];
2318       for (let id of added) {
2319         this._log.trace("updateExperimentList() - adding " + id);
2320         let wrapper = new PreviousExperimentAddon(newMap.get(id));
2321         AddonManagerPrivate.callInstallListeners("onExternalInstall", null, wrapper, null, false);
2322         AddonManagerPrivate.callAddonListeners("onInstalling", wrapper, false);
2323       }
2325       for (let id of removed) {
2326         this._log.trace("updateExperimentList() - removing " + id);
2327         let wrapper = new PreviousExperimentAddon(oldMap.get(id));
2328         AddonManagerPrivate.callAddonListeners("onUninstalling", plugin, false);
2329       }
2331       this._experimentList = list;
2333       for (let id of added) {
2334         let wrapper = new PreviousExperimentAddon(newMap.get(id));
2335         AddonManagerPrivate.callAddonListeners("onInstalled", wrapper);
2336       }
2338       for (let id of removed) {
2339         let wrapper = new PreviousExperimentAddon(oldMap.get(id));
2340         AddonManagerPrivate.callAddonListeners("onUninstalled", wrapper);
2341       }
2343       return this._experimentList;
2344     });
2345   },
2349  * An add-on that represents a previously-installed experiment.
2350  */
2351 function PreviousExperimentAddon(experiment) {
2352   this._id = experiment.id;
2353   this._name = experiment.name;
2354   this._endDate = experiment.endDate;
2355   this._description = experiment.description;
2358 PreviousExperimentAddon.prototype = Object.freeze({
2359   // BEGIN REQUIRED ADDON PROPERTIES
2361   get appDisabled() {
2362     return true;
2363   },
2365   get blocklistState() {
2366     Ci.nsIBlocklistService.STATE_NOT_BLOCKED
2367   },
2369   get creator() {
2370     return new AddonManagerPrivate.AddonAuthor("");
2371   },
2373   get foreignInstall() {
2374     return false;
2375   },
2377   get id() {
2378     return this._id;
2379   },
2381   get isActive() {
2382     return false;
2383   },
2385   get isCompatible() {
2386     return true;
2387   },
2389   get isPlatformCompatible() {
2390     return true;
2391   },
2393   get name() {
2394     return this._name;
2395   },
2397   get pendingOperations() {
2398     return AddonManager.PENDING_NONE;
2399   },
2401   get permissions() {
2402     return 0;
2403   },
2405   get providesUpdatesSecurely() {
2406     return true;
2407   },
2409   get scope() {
2410     return AddonManager.SCOPE_PROFILE;
2411   },
2413   get type() {
2414     return "experiment";
2415   },
2417   get userDisabled() {
2418     return true;
2419   },
2421   get version() {
2422     return null;
2423   },
2425   // END REQUIRED PROPERTIES
2427   // BEGIN OPTIONAL PROPERTIES
2429   get description() {
2430     return this._description;
2431   },
2433   get updateDate() {
2434     return new Date(this._endDate);
2435   },
2437   // END OPTIONAL PROPERTIES
2439   // BEGIN REQUIRED METHODS
2441   isCompatibleWith: function (appVersion, platformVersion) {
2442     return true;
2443   },
2445   findUpdates: function (listener, reason, appVersion, platformVersion) {
2446     AddonManagerPrivate.callNoUpdateListeners(this, listener, reason,
2447                                               appVersion, platformVersion);
2448   },
2450   // END REQUIRED METHODS
2452   /**
2453    * The end-date of the experiment, required for the Addon Manager UI.
2454    */
2456    get endDate() {
2457      return this._endDate;
2458    },