mirror of
https://gitlab.winehq.org/wine/wine-gecko.git
synced 2024-09-13 09:24:08 -07:00
1422 lines
42 KiB
JavaScript
1422 lines
42 KiB
JavaScript
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
|
|
|
"use strict";
|
|
|
|
this.EXPORTED_SYMBOLS = [
|
|
"Experiments",
|
|
];
|
|
|
|
const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
|
|
|
|
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
|
Cu.import("resource://gre/modules/Services.jsm");
|
|
Cu.import("resource://gre/modules/Task.jsm");
|
|
Cu.import("resource://gre/modules/Promise.jsm");
|
|
Cu.import("resource://gre/modules/osfile.jsm");
|
|
Cu.import("resource://gre/modules/Log.jsm");
|
|
Cu.import("resource://gre/modules/Preferences.jsm");
|
|
Cu.import("resource://services-common/utils.js");
|
|
Cu.import("resource://gre/modules/AsyncShutdown.jsm");
|
|
|
|
XPCOMUtils.defineLazyModuleGetter(this, "UpdateChannel",
|
|
"resource://gre/modules/UpdateChannel.jsm");
|
|
XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
|
|
"resource://gre/modules/AddonManager.jsm");
|
|
XPCOMUtils.defineLazyModuleGetter(this, "TelemetryPing",
|
|
"resource://gre/modules/TelemetryPing.jsm");
|
|
// CertUtils.jsm doesn't expose a single "CertUtils" object like a normal .jsm
|
|
// would.
|
|
XPCOMUtils.defineLazyGetter(this, "CertUtils",
|
|
function() {
|
|
var mod = {};
|
|
Cu.import("resource://gre/modules/CertUtils.jsm", mod);
|
|
return mod;
|
|
});
|
|
|
|
|
|
const FILE_CACHE = "experiments.json";
|
|
const OBSERVER_TOPIC = "experiments-changed";
|
|
const MANIFEST_VERSION = 1;
|
|
const CACHE_VERSION = 1;
|
|
|
|
const KEEP_HISTORY_N_DAYS = 180;
|
|
const MIN_EXPERIMENT_ACTIVE_SECONDS = 60;
|
|
|
|
const PREF_BRANCH = "experiments.";
|
|
const PREF_ENABLED = "enabled"; // experiments.enabled
|
|
const PREF_LOGGING = "logging";
|
|
const PREF_LOGGING_LEVEL = PREF_LOGGING + ".level"; // experiments.logging.level
|
|
const PREF_LOGGING_DUMP = PREF_LOGGING + ".dump"; // experiments.logging.dump
|
|
const PREF_MANIFEST_URI = "manifest.uri"; // experiments.logging.manifest.uri
|
|
const PREF_MANIFEST_CHECKCERT = "manifest.cert.checkAttributes"; // experiments.manifest.cert.checkAttributes
|
|
const PREF_MANIFEST_REQUIREBUILTIN = "manifest.cert.requireBuiltin"; // experiments.manifest.cert.requireBuiltin
|
|
|
|
const PREF_HEALTHREPORT_ENABLED = "datareporting.healthreport.service.enabled";
|
|
|
|
const PREF_BRANCH_TELEMETRY = "toolkit.telemetry.";
|
|
const PREF_TELEMETRY_ENABLED = "enabled";
|
|
const PREF_TELEMETRY_PRERELEASE = "enabledPreRelease";
|
|
|
|
const gPrefs = new Preferences(PREF_BRANCH);
|
|
const gPrefsTelemetry = new Preferences(PREF_BRANCH_TELEMETRY);
|
|
let gExperimentsEnabled = false;
|
|
let gExperiments = null;
|
|
let gLogAppenderDump = null;
|
|
|
|
XPCOMUtils.defineLazyGetter(this, "gLogger", function() {
|
|
let logger = Log.repository.getLogger("Browser.Experiments");
|
|
logger.addAppender(new Log.ConsoleAppender(new Log.BasicFormatter()));
|
|
return logger;
|
|
});
|
|
|
|
|
|
function configureLogging() {
|
|
gLogger.level = gPrefs.get(PREF_LOGGING_LEVEL, 50);
|
|
|
|
if (gPrefs.get(PREF_LOGGING_DUMP, false)) {
|
|
gLogAppenderDump = new Log.DumpAppender(new Log.BasicFormatter());
|
|
gLogger.addAppender(gLogAppenderDump);
|
|
} else {
|
|
gLogger.removeAppender(gLogAppenderDump);
|
|
gLogAppenderDump = null;
|
|
}
|
|
}
|
|
|
|
// Takes an array of promises and returns a promise that is resolved once all of
|
|
// them are rejected or resolved.
|
|
function allResolvedOrRejected(promises) {
|
|
if (!promises.length) {
|
|
return Promise.resolve([]);
|
|
}
|
|
|
|
let countdown = promises.length;
|
|
let deferred = Promise.defer();
|
|
|
|
for (let p of promises) {
|
|
let helper = () => {
|
|
if (--countdown == 0) {
|
|
deferred.resolve();
|
|
}
|
|
};
|
|
Promise.resolve(p).then(helper, helper);
|
|
}
|
|
|
|
return deferred.promise;
|
|
}
|
|
|
|
// Loads a JSON file using OS.file. file is a string representing the path
|
|
// of the file to be read, options contains additional options to pass to
|
|
// OS.File.read.
|
|
// Returns a Promise resolved with the json payload or rejected with
|
|
// OS.File.Error or JSON.parse() errors.
|
|
function loadJSONAsync(file, options) {
|
|
return Task.spawn(function() {
|
|
let rawData = yield OS.File.read(file, options);
|
|
// Read json file into a string
|
|
let data;
|
|
try {
|
|
// Obtain a converter to read from a UTF-8 encoded input stream.
|
|
let converter = new TextDecoder();
|
|
data = JSON.parse(converter.decode(rawData));
|
|
} catch (ex) {
|
|
gLogger.error("Experiments: Could not parse JSON: " + file + " " + ex);
|
|
throw ex;
|
|
}
|
|
throw new Task.Result(data);
|
|
});
|
|
}
|
|
|
|
function telemetryEnabled() {
|
|
return gPrefsTelemetry.get(PREF_TELEMETRY_ENABLED, false) ||
|
|
gPrefsTelemetry.get(PREF_TELEMETRY_PRERELEASE, false);
|
|
}
|
|
|
|
/**
|
|
* The experiments module.
|
|
*/
|
|
|
|
let Experiments = {
|
|
/**
|
|
* Provides access to the global `Experiments.Experiments` instance.
|
|
*/
|
|
instance: function () {
|
|
if (!gExperiments) {
|
|
gExperiments = new Experiments.Experiments();
|
|
}
|
|
|
|
return gExperiments;
|
|
},
|
|
};
|
|
|
|
/*
|
|
* The policy object allows us to inject fake enviroment data from the
|
|
* outside by monkey-patching.
|
|
*/
|
|
|
|
Experiments.Policy = function () {
|
|
};
|
|
|
|
Experiments.Policy.prototype = {
|
|
now: function () {
|
|
return new Date();
|
|
},
|
|
|
|
random: function () {
|
|
return Math.random();
|
|
},
|
|
|
|
futureDate: function (offset) {
|
|
return new Date(this.now().getTime() + offset);
|
|
},
|
|
|
|
oneshotTimer: function (callback, timeout, thisObj, name) {
|
|
return CommonUtils.namedTimer(callback, timeout, thisObj, name);
|
|
},
|
|
|
|
updatechannel: function () {
|
|
return UpdateChannel.get();
|
|
},
|
|
|
|
/*
|
|
* @return Promise<> Resolved with the payload data.
|
|
*/
|
|
healthReportPayload: function () {
|
|
return Task.spawn(function*() {
|
|
let reporter = Cc["@mozilla.org/datareporting/service;1"]
|
|
.getService(Ci.nsISupports)
|
|
.wrappedJSObject
|
|
.healthReporter;
|
|
yield reporter.onInit();
|
|
let payload = yield reporter.collectAndObtainJSONPayload();
|
|
throw new Task.Result(payload);
|
|
});
|
|
},
|
|
|
|
telemetryPayload: function () {
|
|
return TelemetryPing.getPayload();
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Manages the experiments and provides an interface to control them.
|
|
*/
|
|
|
|
Experiments.Experiments = function (policy=new Experiments.Policy()) {
|
|
this._policy = policy;
|
|
|
|
// This is a Map of (string -> ExperimentEntry), keyed with the experiment id.
|
|
// It holds both the current experiments and history.
|
|
// Map() preserves insertion order, which means we preserve the manifest order.
|
|
this._experiments = null;
|
|
// Holds tasks that hit the experiments list and files.
|
|
// We need to serialize them, so only one is running at a time.
|
|
this._pendingTasks = {
|
|
updateManifest: null,
|
|
loadFromCache: null,
|
|
saveToCache: null,
|
|
evaluateExperiments: null,
|
|
disableExperiment: null,
|
|
};
|
|
// Timer for re-evaluating experiment status.
|
|
this._timer = null;
|
|
|
|
this._shutdown = false;
|
|
|
|
this.init();
|
|
};
|
|
|
|
Experiments.Experiments.prototype = {
|
|
QueryInterface: XPCOMUtils.generateQI([Ci.nsITimerCallback, Ci.nsIObserver]),
|
|
|
|
init: function () {
|
|
configureLogging();
|
|
|
|
gExperimentsEnabled = gPrefs.get(PREF_ENABLED, false);
|
|
gLogger.trace("enabled="+gExperimentsEnabled+", "+this.enabled);
|
|
|
|
gPrefs.observe(PREF_LOGGING, configureLogging);
|
|
gPrefs.observe(PREF_MANIFEST_URI, this.updateManifest, this);
|
|
gPrefs.observe(PREF_ENABLED, this._toggleExperimentsEnabled, this);
|
|
|
|
gPrefsTelemetry.observe(PREF_TELEMETRY_ENABLED, this._telemetryStatusChanged, this);
|
|
gPrefsTelemetry.observe(PREF_TELEMETRY_PRERELEASE, this._telemetryStatusChanged, this);
|
|
|
|
AsyncShutdown.profileBeforeChange.addBlocker("Experiments.jsm shutdown",
|
|
this.uninit.bind(this));
|
|
|
|
AddonManager.addAddonListener(this);
|
|
|
|
this._experiments = new Map();
|
|
this._loadFromCache();
|
|
},
|
|
|
|
/**
|
|
* @return Promise<>
|
|
* The promise is fulfilled when all pending tasks are finished.
|
|
*/
|
|
uninit: function () {
|
|
if (!this._shutdown) {
|
|
AddonManager.removeAddonListener(this);
|
|
|
|
gPrefs.ignore(PREF_LOGGING, configureLogging);
|
|
gPrefs.ignore(PREF_MANIFEST_URI, this.updateManifest, this);
|
|
gPrefs.ignore(PREF_ENABLED, this._toggleExperimentsEnabled, this);
|
|
|
|
gPrefsTelemetry.ignore(PREF_TELEMETRY_ENABLED, this._telemetryStatusChanged, this);
|
|
gPrefsTelemetry.ignore(PREF_TELEMETRY_PRERELEASE, this._telemetryStatusChanged, this);
|
|
|
|
if (this._timer) {
|
|
this._timer.clear();
|
|
}
|
|
}
|
|
|
|
this._shutdown = true;
|
|
if (this._pendingTasks.saveToCache) {
|
|
return this._pendingTasks.saveToCache;
|
|
}
|
|
return Promise.resolve();
|
|
},
|
|
|
|
/**
|
|
* Throws an exception if we've already shut down.
|
|
*/
|
|
_checkForShutdown: function() {
|
|
if (this._shutdown) {
|
|
throw Error("uninit() already called");
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Whether the experiments feature is enabled.
|
|
*/
|
|
get enabled() {
|
|
return gExperimentsEnabled;
|
|
},
|
|
|
|
/**
|
|
* Toggle whether the experiments feature is enabled or not.
|
|
*/
|
|
set enabled(enabled) {
|
|
gLogger.trace("Experiments::set enabled(" + enabled + ")");
|
|
gPrefs.set(PREF_ENABLED, enabled);
|
|
},
|
|
|
|
_toggleExperimentsEnabled: function (enabled) {
|
|
gLogger.trace("Experiments::_toggleExperimentsEnabled(" + enabled + ")");
|
|
let wasEnabled = gExperimentsEnabled;
|
|
gExperimentsEnabled = enabled && telemetryEnabled();
|
|
|
|
if (wasEnabled == gExperimentsEnabled) {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
if (gExperimentsEnabled) {
|
|
return this.updateManifest();
|
|
}
|
|
|
|
let promise = this._disableExperiments();
|
|
if (wasEnabled) {
|
|
Services.obs.notifyObservers(null, OBSERVER_TOPIC, null);
|
|
}
|
|
return promise;
|
|
},
|
|
|
|
_telemetryStatusChanged: function () {
|
|
_toggleExperimentsEnabled(gExperimentsEnabled);
|
|
},
|
|
|
|
/**
|
|
* Returns a promise that is resolved with an array of `ExperimentInfo` objects,
|
|
* which provide info on the currently and recently active experiments.
|
|
* The array is in chronological order.
|
|
*
|
|
* The experiment info is of the form:
|
|
* {
|
|
* id: <string>,
|
|
* name: <string>,
|
|
* description: <string>,
|
|
* active: <boolean>,
|
|
* endDate: <integer>, // epoch ms
|
|
* detailURL: <string>,
|
|
* ... // possibly extended later
|
|
* }
|
|
*
|
|
* @return Promise<Array<ExperimentInfo>> Array of experiment info objects.
|
|
*/
|
|
getExperiments: function () {
|
|
return Promise.resolve(this._experiments || this._loadFromCache()).then(
|
|
() => {
|
|
let list = [];
|
|
|
|
for (let [id, experiment] of this._experiments) {
|
|
if (!experiment.startDate) {
|
|
// We only collect experiments that are or were active.
|
|
continue;
|
|
}
|
|
|
|
list.push({
|
|
id: id,
|
|
name: experiment._name,
|
|
description: experiment._description,
|
|
active: experiment.enabled,
|
|
endDate: experiment.endDate.getTime(),
|
|
detailURL: experiment._homepageURL,
|
|
});
|
|
}
|
|
|
|
// Sort chronologically, descending.
|
|
list.sort((a, b) => b.endDate - a.endDate);
|
|
|
|
return list;
|
|
},
|
|
() => []
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Fetch an updated list of experiments and trigger experiment updates.
|
|
* Do only use when experiments are enabled.
|
|
*
|
|
* @return Promise<>
|
|
* The promise is resolved when the manifest and experiment list is updated.
|
|
*/
|
|
updateManifest: function () {
|
|
gLogger.trace("Experiments::updateManifest()");
|
|
|
|
if (!gExperimentsEnabled) {
|
|
return Promise.reject(new Error("experiments are disabled"));
|
|
}
|
|
|
|
if (this._shutdown) {
|
|
return Promise.reject(Error("uninit() alrady called"));
|
|
}
|
|
|
|
if (this._pendingTasks.updateManifest) {
|
|
return this._pendingTasks.updateManifest;
|
|
}
|
|
|
|
let uri = Services.urlFormatter.formatURLPref(PREF_BRANCH + PREF_MANIFEST_URI);
|
|
|
|
this._pendingTasks.updateManifest = Task.spawn(function () {
|
|
this._checkForShutdown();
|
|
|
|
// Don't interfere while we're saving or loading the cache.
|
|
try {
|
|
yield this._pendingTasksDone([this._pendingTasks.updateManifest]);
|
|
} catch (e) {
|
|
// Ignore a failed cache load/save.
|
|
}
|
|
|
|
try {
|
|
let responseText = yield this._httpGetRequest(uri);
|
|
gLogger.trace("Experiments::updateManifest::updateTask() - responseText=\"" + responseText + "\"");
|
|
|
|
this._checkForShutdown();
|
|
|
|
let data = JSON.parse(responseText);
|
|
this._updateExperiments(data);
|
|
yield this._evaluateExperiments();
|
|
this._scheduleExperimentEvaluation();
|
|
} catch (e if e instanceof SyntaxError) {
|
|
gLogger.error("Experiments::updateManifest::updateTask() - failed to parse manifest - " + e);
|
|
throw e;
|
|
} finally {
|
|
this._pendingTasks.updateManifest = null;
|
|
}
|
|
|
|
yield this._saveToCache(this._experiments);
|
|
}.bind(this));
|
|
|
|
return this._pendingTasks.updateManifest;
|
|
},
|
|
|
|
notify: function (timer) {
|
|
gLogger.trace("Experiments::notify()");
|
|
|
|
this._checkForShutdown();
|
|
|
|
if (this._pendingTasks.evaluateExperiments) {
|
|
return;
|
|
}
|
|
|
|
this._pendingTasks.evaluateExperiments = new Task.spawn(function scheduledEvaluateTask() {
|
|
gLogger.trace("Experiments::notify::scheduledEvaluateTask()");
|
|
yield this._pendingTasksDone([this._pendingTasks.evaluateExperiments]);
|
|
yield this._evaluateExperiments();
|
|
this._pendingTasks.evaluateExperiments = null;
|
|
this._scheduleExperimentEvaluation();
|
|
}.bind(this));
|
|
},
|
|
|
|
onDisabled: function (addon) {
|
|
this._checkForShutdown();
|
|
let experiment = this._experiments.get(addon.id);
|
|
if (!experiment) {
|
|
return;
|
|
}
|
|
|
|
this.disableExperiment(addon.id);
|
|
},
|
|
|
|
onUninstalled: function (addon) {
|
|
this._checkForShutdown();
|
|
let experiment = this._experiments.get(addon.id);
|
|
if (!experiment) {
|
|
return;
|
|
}
|
|
|
|
this.disableExperiment(addon.id);
|
|
},
|
|
|
|
/*
|
|
* Helper function to make HTTP GET requests. Returns a promise that is resolved with
|
|
* the responseText when the request is complete.
|
|
*/
|
|
_httpGetRequest: function (url) {
|
|
gLogger.trace("Experiments::httpGetRequest(" + url + ")");
|
|
let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].createInstance(Ci.nsIXMLHttpRequest);
|
|
try {
|
|
xhr.open("GET", url);
|
|
} catch (e) {
|
|
gLogger.error("Experiments::httpGetRequest() - Error opening request to " + url + ": " + e);
|
|
return Promise.reject(new Error("Experiments - Error opening XHR for " + url));
|
|
}
|
|
|
|
let deferred = Promise.defer();
|
|
|
|
xhr.onerror = function (e) {
|
|
gLogger.error("Experiments::httpGetRequest::onError() - Error making request to " + url + ": " + e.error);
|
|
deferred.reject(new Error("Experiments - XHR error for " + url + " - " + e.error));
|
|
};
|
|
|
|
xhr.onload = function (event) {
|
|
if (xhr.status !== 200 && xhr.state !== 0) {
|
|
gLogger.error("Experiments::httpGetRequest::onLoad() - Request to " + url + " returned status " + xhr.status);
|
|
deferred.reject(new Error("Experiments - XHR status for " + url + " is " + xhr.status));
|
|
return;
|
|
}
|
|
|
|
let certs = null;
|
|
if (gPrefs.get(PREF_MANIFEST_CHECKCERT, true)) {
|
|
certs = CertUtils.readCertPrefs(PREF_BRANCH + "manifest.certs.");
|
|
}
|
|
try {
|
|
let allowNonBuiltin = !gPrefs.get(PREF_MANIFEST_REQUIREBUILTIN, true);
|
|
CertUtils.checkCert(xhr.channel, allowNonBuiltin, certs);
|
|
}
|
|
catch (e) {
|
|
gLogger.error("Experiments: manifest fetch failed certificate checks", [e]);
|
|
deferred.reject(new Error("Experiments - manifest fetch failed certificate checks: " + e));
|
|
return;
|
|
}
|
|
|
|
deferred.resolve(xhr.responseText);
|
|
};
|
|
|
|
if (xhr.channel instanceof Ci.nsISupportsPriority) {
|
|
xhr.channel.priority = Ci.nsISupportsPriority.PRIORITY_LOWEST;
|
|
}
|
|
|
|
xhr.send(null);
|
|
return deferred.promise;
|
|
},
|
|
|
|
/*
|
|
* Path of the cache file we use in the profile.
|
|
*/
|
|
get _cacheFilePath() {
|
|
return OS.Path.join(OS.Constants.Path.profileDir, FILE_CACHE);
|
|
},
|
|
|
|
/*
|
|
* Returns a promise that is resolved when all _pendingTasks are resolved
|
|
* or rejected.
|
|
* exceptThese is optional and allows to specify exceptions of tasks not to
|
|
* wait on.
|
|
*/
|
|
_pendingTasksDone: function (exceptThese) {
|
|
exceptThese = exceptThese || [];
|
|
let ts = this._pendingTasks;
|
|
let list = [ts[k] for (k of Object.keys(ts))
|
|
if (ts.hasOwnProperty(k) && exceptThese.indexOf(ts[k]) == -1)]
|
|
return allResolvedOrRejected(list);
|
|
},
|
|
|
|
/*
|
|
* Save the experiment data on disk. Returns a promise that
|
|
* is resolved when the operation is done.
|
|
*/
|
|
_saveToCache: function (data) {
|
|
if (this._pendingTasks.saveToCache) {
|
|
return this._pendingTasks.saveToCache;
|
|
}
|
|
|
|
let path = this._cacheFilePath;
|
|
let textData = JSON.stringify({
|
|
version: CACHE_VERSION,
|
|
data: [e[1].toJSON() for (e of data.entries())],
|
|
});
|
|
|
|
this._pendingTasks.saveToCache = Task.spawn(function Experiments_saveToCache_fileTask() {
|
|
try {
|
|
yield this._pendingTasksDone([this._pendingTasks.saveToCache]);
|
|
let encoder = new TextEncoder();
|
|
let data = encoder.encode(textData);
|
|
let options = { tmpPath: path + ".tmp", compression: "lz4" };
|
|
yield OS.File.writeAtomic(path, data, options);
|
|
} finally {
|
|
this._pendingTasks.saveToCache = null;
|
|
}
|
|
}.bind(this)).then(
|
|
() => gLogger.debug("Experiments::saveToCache::fileTask() saved to: " + path),
|
|
e => gLogger.error("Experiments::saveToCache::fileTask() failed: " + e));
|
|
|
|
return this._pendingTasks.saveToCache;
|
|
},
|
|
|
|
/*
|
|
* Load the cached experiments manifest file from disk.
|
|
* Returns a promise that is resolved when the experiments are loaded and updated.
|
|
*/
|
|
_loadFromCache: function () {
|
|
if (this._pendingTasks.loadFromCache) {
|
|
return this._pendingTasks.loadFromCache;
|
|
}
|
|
|
|
if (this._pendingTasks.updateManifest) {
|
|
// We're already updating the manifest, no need to load the cached version.
|
|
return this._pendingTasks.updateManifest;
|
|
}
|
|
|
|
let path = this._cacheFilePath;
|
|
this._pendingTasks.loadFromCache = Task.spawn(function () {
|
|
try {
|
|
yield this._pendingTasksDone([this._pendingTasks.loadFromCache]);
|
|
let result = yield loadJSONAsync(path, { compression: "lz4" });
|
|
|
|
this._populateFromCache(result);
|
|
yield this._evaluateExperiments();
|
|
this._scheduleExperimentEvaluation();
|
|
} catch (e if e instanceof OS.File.Error && e.becauseNoSuchFile) {
|
|
// No cached manifest yet.
|
|
} finally {
|
|
this._pendingTasks.loadFromCache = null;
|
|
}
|
|
}.bind(this)).then(null, error => {
|
|
gLogger.error("Experiments::loadFromCache::fileTask() failed: " + error);
|
|
});
|
|
|
|
return this._pendingTasks.loadFromCache;
|
|
},
|
|
|
|
_populateFromCache: function (data) {
|
|
gLogger.trace("Experiments::populateFromCache() - data: " + JSON.stringify(data));
|
|
|
|
if (CACHE_VERSION !== data.version) {
|
|
gLogger.warn("Experiments::populateFromCache() - invalid cache version");
|
|
return false;
|
|
}
|
|
|
|
let experiments = new Map();
|
|
for (let item of data.data) {
|
|
let entry = new Experiments.ExperimentEntry(this._policy);
|
|
if (!entry.initFromCacheData(item)) {
|
|
continue;
|
|
}
|
|
experiments.set(item.id, entry);
|
|
}
|
|
|
|
this._experiments = experiments;
|
|
return true;
|
|
},
|
|
|
|
/*
|
|
* Update the experiment entries from the experiments
|
|
* array in the manifest
|
|
*/
|
|
_updateExperiments: function (manifestObject) {
|
|
gLogger.trace("Experiments::updateExperiments() - experiments: " + JSON.stringify(manifestObject));
|
|
|
|
if (manifestObject.version !== MANIFEST_VERSION) {
|
|
gLogger.warning("Experiments::updateExperiments() - unsupported version " + manifestObject.version);
|
|
return false;
|
|
}
|
|
|
|
let experiments = new Map(); // The new experiments map
|
|
|
|
// Collect new and updated experiments.
|
|
for (let data of manifestObject.experiments) {
|
|
let entry = this._experiments.get(data.id);
|
|
|
|
if (entry) {
|
|
if (!entry.updateFromManifestData(data)) {
|
|
gLogger.error("Experiments::updateExperiments() - Invalid manifest data for " + data.id);
|
|
continue;
|
|
}
|
|
} else {
|
|
entry = new Experiments.ExperimentEntry(this._policy);
|
|
if (!entry.initFromManifestData(data)) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (entry.shouldDiscard()) {
|
|
continue;
|
|
}
|
|
|
|
experiments.set(data.id, entry);
|
|
}
|
|
|
|
// Make sure we keep experiments that are or were running.
|
|
// We remove them after KEEP_HISTORY_N_DAYS.
|
|
for (let [id, entry] of this._experiments) {
|
|
if (experiments.has(id) || !entry.startDate || !entry.shouldDiscard()) {
|
|
continue;
|
|
}
|
|
|
|
experiments.set(id, experiment);
|
|
}
|
|
|
|
this._experiments = experiments;
|
|
return true;
|
|
},
|
|
|
|
_getActiveExperiment: function () {
|
|
let enabled = [experiment for ([,experiment] of this._experiments) if (experiment._enabled)];
|
|
|
|
if (enabled.length == 1) {
|
|
return enabled[0];
|
|
}
|
|
|
|
if (enabled.length > 1) {
|
|
gLogger.error("Experiments::getActiveExperimentId() - should not have more than 1 active experiment");
|
|
throw new Error("have more than 1 active experiment");
|
|
}
|
|
|
|
return null;
|
|
},
|
|
|
|
/*
|
|
* Stop running experiments and disable further activations.
|
|
*/
|
|
_disableExperiments: function () {
|
|
gLogger.trace("Experiments::disableExperiments()");
|
|
|
|
if (this._shutdown) {
|
|
return Promise.reject(Error("uninit() alrady called"));
|
|
}
|
|
|
|
let active = this._getActiveExperiment();
|
|
let promise = Promise.resolve();
|
|
if (active) {
|
|
promise = active.stop();
|
|
}
|
|
|
|
if (this._timer) {
|
|
this._timer.clear();
|
|
}
|
|
|
|
return promise;
|
|
},
|
|
|
|
/**
|
|
* Disable an experiment by id.
|
|
* @param experimentId The id of the experiment.
|
|
* @param userDisabled (optional) Whether this is disabled as a result of a user action.
|
|
* @return Promise<> Promise that will get resolved once the task is done or failed.
|
|
*/
|
|
disableExperiment: function (experimentId, userDisabled=true) {
|
|
gLogger.trace("Experiments::disableExperiment() - " + experimentId);
|
|
|
|
this._checkForShutdown();
|
|
|
|
let experiment = this._experiments.get(experimentId);
|
|
if (!experiment) {
|
|
let message = "no experiment with this id";
|
|
gLogger.warning("Experiments::disableExperiment() - " + message);
|
|
return Promise.reject(new Error(message));
|
|
}
|
|
|
|
if (!experiment.enabled) {
|
|
return Promise.reject();
|
|
}
|
|
|
|
return Task.spawn(function* Experiments_disableExperimentTaskOuter() {
|
|
// We need to wait on possible previous disable tasks.
|
|
yield this._pendingTasks.disableExperiment;
|
|
|
|
let disableTask = Task.spawn(function* Experiments_disableExperimentTaskInner() {
|
|
yield this._pendingTasksDone([this._pendingTasks.disableExperiment]);
|
|
yield experiment.stop(userDisabled);
|
|
Services.obs.notifyObservers(null, OBSERVER_TOPIC, null);
|
|
this._pendingTasks.disableExperiment = null;
|
|
}.bind(this));
|
|
|
|
this._pendingTasks.disableExperiment = disableTask;
|
|
yield disableTask;
|
|
}.bind(this));
|
|
},
|
|
|
|
/*
|
|
* Check applicability of experiments, disable the active one if needed and
|
|
* activate the first applicable candidate.
|
|
* @return Promise<boolean> Resolved when done, the value indicates whether
|
|
* the activity status of any experiment changed.
|
|
*/
|
|
_evaluateExperiments: function () {
|
|
gLogger.trace("Experiments::evaluateExperiments()");
|
|
|
|
this._checkForShutdown();
|
|
|
|
return Task.spawn(function Experiments_evaluateExperiments_evaluateTask() {
|
|
this._checkForShutdown();
|
|
let activeExperiment = this._getActiveExperiment();
|
|
let activeChanged = false;
|
|
|
|
if (activeExperiment) {
|
|
let wasStopped = yield activeExperiment.maybeStop();
|
|
if (wasStopped) {
|
|
gLogger.debug("Experiments::evaluateExperiments() - stopped experiment "
|
|
+ activeExperiment.id);
|
|
activeExperiment = null;
|
|
activeChanged = true;
|
|
} else if (activeExperiment.needsUpdate) {
|
|
gLogger.debug("Experiments::evaluateExperiments() - updating experiment "
|
|
+ activeExperiment.id);
|
|
try {
|
|
yield activeExperiment.stop();
|
|
yield activeExperiment.start();
|
|
} catch (e) {
|
|
// On failure try the next experiment.
|
|
activeExperiment = null;
|
|
}
|
|
|
|
activeChanged = true;
|
|
}
|
|
}
|
|
|
|
if (!activeExperiment) {
|
|
for (let [id, experiment] of this._experiments) {
|
|
let applicable;
|
|
yield experiment.isApplicable().then(
|
|
result => applicable = result,
|
|
reason => {
|
|
applicable = false;
|
|
// TODO telemetry: experiment rejection reason
|
|
}
|
|
);
|
|
|
|
if (applicable) {
|
|
gLogger.debug("Experiments::evaluateExperiments() - activating experiment " + id);
|
|
try {
|
|
yield experiment.start();
|
|
activeChanged = true;
|
|
break;
|
|
} catch (e) {
|
|
// On failure try the next experiment.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (activeChanged) {
|
|
Services.obs.notifyObservers(null, OBSERVER_TOPIC, null);
|
|
}
|
|
|
|
throw new Task.Result(activeChanged);
|
|
}.bind(this));
|
|
},
|
|
|
|
/*
|
|
* Schedule the soonest re-check of experiment applicability that is needed.
|
|
*/
|
|
_scheduleExperimentEvaluation: function () {
|
|
this._checkForShutdown();
|
|
if (!gExperimentsEnabled || this._experiments.length == 0) {
|
|
return;
|
|
}
|
|
|
|
let time = new Date(90000, 0, 1, 12).getTime();
|
|
let now = this._policy.now().getTime();
|
|
|
|
for (let [id, experiment] of this._experiments) {
|
|
let scheduleTime = experiment.getScheduleTime();
|
|
if (scheduleTime > now) {
|
|
time = Math.min(time, scheduleTime);
|
|
}
|
|
}
|
|
|
|
if (this._timer) {
|
|
this._timer.clear();
|
|
}
|
|
|
|
gLogger.trace("Experiments::scheduleExperimentEvaluation() - scheduling for "+time+", now: "+now);
|
|
this._policy.oneshotTimer(this.notify, time - now, this, "_timer");
|
|
},
|
|
};
|
|
|
|
|
|
/*
|
|
* Represents a single experiment.
|
|
*/
|
|
|
|
Experiments.ExperimentEntry = function (policy) {
|
|
this._policy = policy || new Experiments.Policy();
|
|
|
|
// Is this experiment running?
|
|
this._enabled = false;
|
|
// When this experiment was started, if ever.
|
|
this._startDate = null;
|
|
// When this experiment was ended, if ever.
|
|
this._endDate = null;
|
|
// The condition data from the manifest.
|
|
this._manifestData = null;
|
|
// For an active experiment, signifies whether we need to update the xpi.
|
|
this._needsUpdate = false;
|
|
// A random sample value for comparison against the manifest conditions.
|
|
this._randomValue = null;
|
|
// When this entry was last changed for respecting history retention duration.
|
|
this._lastChangedDate = null;
|
|
// Has this experiment failed to activate before?
|
|
this._failedStart = false;
|
|
|
|
// We grab these from the addon after download.
|
|
this._name = null;
|
|
this._description = null;
|
|
this._homepageURL = null;
|
|
};
|
|
|
|
Experiments.ExperimentEntry.prototype = {
|
|
MANIFEST_REQUIRED_FIELDS: new Set([
|
|
"id",
|
|
"xpiURL",
|
|
"xpiHash",
|
|
"startTime",
|
|
"endTime",
|
|
"maxActiveSeconds",
|
|
"appName",
|
|
"channel",
|
|
]),
|
|
|
|
MANIFEST_OPTIONAL_FIELDS: new Set([
|
|
"maxStartTime",
|
|
"minVersion",
|
|
"maxVersion",
|
|
"version",
|
|
"minBuildID",
|
|
"maxBuildID",
|
|
"buildIDs",
|
|
"os",
|
|
"locale",
|
|
"sample",
|
|
"disabled",
|
|
"frozen",
|
|
"jsfilter",
|
|
]),
|
|
|
|
SERIALIZE_KEYS: new Set([
|
|
"_enabled",
|
|
"_manifestData",
|
|
"_needsUpdate",
|
|
"_randomValue",
|
|
"_failedStart",
|
|
"_name",
|
|
"_description",
|
|
"_homepageURL",
|
|
"_startDate",
|
|
"_endDate",
|
|
]),
|
|
|
|
DATE_KEYS: new Set([
|
|
"_startDate",
|
|
"_endDate",
|
|
]),
|
|
|
|
/*
|
|
* Initialize entry from the manifest.
|
|
* @param data The experiment data from the manifest.
|
|
* @return boolean Whether initialization succeeded.
|
|
*/
|
|
initFromManifestData: function (data) {
|
|
if (!this._isManifestDataValid(data)) {
|
|
return false;
|
|
}
|
|
|
|
this._manifestData = data;
|
|
|
|
this._randomValue = this._policy.random();
|
|
this._lastChangedDate = this._policy.now();
|
|
|
|
return true;
|
|
},
|
|
|
|
get enabled() {
|
|
return this._enabled;
|
|
},
|
|
|
|
get id() {
|
|
return this._manifestData.id;
|
|
},
|
|
|
|
get startDate() {
|
|
return this._startDate;
|
|
},
|
|
|
|
get endDate() {
|
|
if (!this._startDate) {
|
|
return null;
|
|
}
|
|
|
|
let endTime = 0;
|
|
|
|
if (!this._enabled) {
|
|
return this._endDate;
|
|
}
|
|
|
|
let maxActiveMs = 1000 * this._manifestData.maxActiveSeconds;
|
|
endTime = Math.min(1000 * this._manifestData.endTime,
|
|
this._startDate.getTime() + maxActiveMs);
|
|
|
|
return new Date(endTime);
|
|
},
|
|
|
|
get needsUpdate() {
|
|
return this._needsUpdate;
|
|
},
|
|
|
|
/*
|
|
* Initialize entry from the cache.
|
|
* @param data The entry data from the cache.
|
|
* @return boolean Whether initialization succeeded.
|
|
*/
|
|
initFromCacheData: function (data) {
|
|
for (let key of this.SERIALIZE_KEYS) {
|
|
if (!(key in data)) {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
if (!this._isManifestDataValid(data._manifestData)) {
|
|
return false;
|
|
}
|
|
|
|
this._lastChangedDate = this._policy.now();
|
|
this.SERIALIZE_KEYS.forEach(key => {
|
|
this[key] = data[key];
|
|
});
|
|
|
|
this.DATE_KEYS.forEach(key => {
|
|
if (key in this) {
|
|
let date = new Date();
|
|
date.setTime(this[key]);
|
|
this[key] = date;
|
|
}
|
|
});
|
|
|
|
return true;
|
|
},
|
|
|
|
/*
|
|
* Returns a JSON representation of this object.
|
|
*/
|
|
toJSON: function () {
|
|
let obj = {};
|
|
|
|
this.SERIALIZE_KEYS.forEach(key => {
|
|
if (!this.DATE_KEYS.has(key)) {
|
|
obj[key] = this[key];
|
|
}
|
|
});
|
|
|
|
this.DATE_KEYS.forEach(key => {
|
|
obj[key] = this[key] ? this[key].getTime() : null;
|
|
});
|
|
|
|
return obj;
|
|
},
|
|
|
|
/*
|
|
* Update from the experiment data from the manifest.
|
|
* @param data The experiment data from the manifest.
|
|
* @return boolean Whether updating succeeded.
|
|
*/
|
|
updateFromManifestData: function (data) {
|
|
let old = this._manifestData;
|
|
|
|
if (!this._isManifestDataValid(data)) {
|
|
return false;
|
|
}
|
|
|
|
if (this._enabled) {
|
|
if (old.xpiHash !== data.xpiHash) {
|
|
// A changed hash means we need to update active experiments.
|
|
this._needsUpdate = true;
|
|
}
|
|
} else if (this._failedStart &&
|
|
(old.xpiHash !== data.xpiHash) ||
|
|
(old.xpiURL !== data.xpiURL)) {
|
|
// Retry installation of previously invalid experiments
|
|
// if hash or url changed.
|
|
this._failedStart = false;
|
|
}
|
|
|
|
this._manifestData = data;
|
|
this._lastChangedDate = this._policy.now();
|
|
|
|
return true;
|
|
},
|
|
|
|
/*
|
|
* Is this experiment applicable?
|
|
* @return Promise<> Resolved if the experiment is applicable.
|
|
* If it is not applicable it is rejected with
|
|
* a Promise<string> which contains the reason.
|
|
*/
|
|
isApplicable: function () {
|
|
let versionCmp = Cc["@mozilla.org/xpcom/version-comparator;1"]
|
|
.getService(Ci.nsIVersionComparator);
|
|
let app = Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULAppInfo);
|
|
let runtime = Cc["@mozilla.org/xre/app-info;1"]
|
|
.getService(Ci.nsIXULRuntime);
|
|
let chrome = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(Ci.nsIXULChromeRegistry);
|
|
|
|
let locale = chrome.getSelectedLocale("global");
|
|
let channel = this._policy.updatechannel();
|
|
let data = this._manifestData;
|
|
|
|
let now = this._policy.now() / 1000; // The manifest times are in seconds.
|
|
let minActive = MIN_EXPERIMENT_ACTIVE_SECONDS;
|
|
let maxActive = data.maxActiveSeconds || 0;
|
|
let startSec = (this.startDate || 0) / 1000;
|
|
|
|
gLogger.trace("ExperimentEntry::isApplicable() - now=" + now
|
|
+ ", data=" + JSON.stringify(this._manifestData));
|
|
|
|
// Not applicable if it already ran.
|
|
|
|
if (!this.enabled && this._endDate) {
|
|
return Promise.reject("was already active");
|
|
}
|
|
|
|
// Define and run the condition checks.
|
|
|
|
let simpleChecks = [
|
|
{ name: "failedStart",
|
|
condition: () => !this._failedStart },
|
|
{ name: "disabled",
|
|
condition: () => !data.disabled },
|
|
{ name: "frozen",
|
|
condition: () => !data.frozen || this._enabled },
|
|
{ name: "startTime",
|
|
condition: () => now >= data.startTime },
|
|
{ name: "endTime",
|
|
condition: () => now < data.endTime },
|
|
{ name: "maxStartTime",
|
|
condition: () => !data.maxStartTime || now <= (data.maxStartTime - minActive) },
|
|
{ name: "maxActiveSeconds",
|
|
condition: () => !this._startDate || now <= (startSec + maxActive) },
|
|
{ name: "appName",
|
|
condition: () => !data.name || data.appName.indexOf(app.name) != -1 },
|
|
{ name: "minBuildID",
|
|
condition: () => !data.minBuildID || app.platformBuildID >= data.minBuildID },
|
|
{ name: "maxBuildID",
|
|
condition: () => !data.maxBuildID || app.platformBuildID <= data.maxBuildID },
|
|
{ name: "buildIDs",
|
|
condition: () => !data.buildIDs || data.buildIDs.indexOf(app.platformBuildID) != -1 },
|
|
{ name: "os",
|
|
condition: () => !data.os || data.os.indexOf(runtime.OS) != -1 },
|
|
{ name: "channel",
|
|
condition: () => !data.channel || data.channel.indexOf(channel) != -1 },
|
|
{ name: "locale",
|
|
condition: () => !data.locale || data.locale.indexOf(locale) != -1 },
|
|
{ name: "sample",
|
|
condition: () => !data.sample || this._randomValue <= data.sample },
|
|
{ name: "version",
|
|
condition: () => !data.version || data.appVersion.indexOf(app.version) != -1 },
|
|
{ name: "minVersion",
|
|
condition: () => !data.minVersion || versionCmp.compare(app.version, data.minVersion) >= 0 },
|
|
{ name: "maxVersion",
|
|
condition: () => !data.maxVersion || versionCmp.compare(app.version, data.maxVersion) <= 0 },
|
|
];
|
|
|
|
for (let check of simpleChecks) {
|
|
let result = check.condition();
|
|
if (!result) {
|
|
gLogger.debug("ExperimentEntry::isApplicable() - id="
|
|
+ data.id + " - test '" + check.name + "' failed");
|
|
return Promise.reject(check.name);
|
|
}
|
|
}
|
|
|
|
if (data.jsfilter) {
|
|
return this._runFilterFunction(data.jsfilter);
|
|
}
|
|
|
|
return Promise.resolve(true);
|
|
},
|
|
|
|
/*
|
|
* Run the jsfilter function from the manifest in a sandbox and return the
|
|
* result (forced to boolean).
|
|
*/
|
|
_runFilterFunction: function (jsfilter) {
|
|
gLogger.trace("ExperimentEntry::runFilterFunction() - filter: " + jsfilter);
|
|
|
|
return Task.spawn(function ExperimentEntry_runFilterFunction_task() {
|
|
const nullprincipal = Cc["@mozilla.org/nullprincipal;1"].createInstance(Ci.nsIPrincipal);
|
|
let options = {
|
|
sandboxName: "telemetry experiments jsfilter sandbox",
|
|
wantComponents: false,
|
|
};
|
|
|
|
let sandbox = Cu.Sandbox(nullprincipal);
|
|
let context = {};
|
|
context.healthReportPayload = yield this._policy.healthReportPayload();
|
|
context.telemetryPayload = yield this._policy.telemetryPayload();
|
|
|
|
try {
|
|
Cu.evalInSandbox(jsfilter, sandbox);
|
|
} catch (e) {
|
|
gLogger.error("ExperimentEntry::runFilterFunction() - failed to eval jsfilter: " + e.message);
|
|
throw "jsfilter:evalFailure";
|
|
}
|
|
|
|
// You can't insert arbitrarily complex objects into a sandbox, so
|
|
// we serialize everything through JSON.
|
|
sandbox._hr = JSON.stringify(yield this._policy.healthReportPayload());
|
|
Object.defineProperty(sandbox, "_t",
|
|
{ get: () => JSON.stringify(this._policy.telemetryPayload()) });
|
|
|
|
let result = false;
|
|
try {
|
|
result = !!Cu.evalInSandbox("filter({healthReportPayload: JSON.parse(_hr), telemetryPayload: JSON.parse(_t)})", sandbox);
|
|
}
|
|
catch (e) {
|
|
gLogger.debug("ExperimentEntry::runFilterFunction() - filter function failed: "
|
|
+ e.message + ", " + e.stack);
|
|
throw "jsfilter:rejected " + e.message;
|
|
}
|
|
finally {
|
|
Cu.nukeSandbox(sandbox);
|
|
}
|
|
|
|
throw new Task.Result(result);
|
|
}.bind(this));
|
|
},
|
|
|
|
/*
|
|
* Start running the experiment.
|
|
* @return Promise<> Resolved when the operation is complete.
|
|
*/
|
|
start: function () {
|
|
gLogger.trace("ExperimentEntry::start() for " + this.id);
|
|
let deferred = Promise.defer();
|
|
|
|
let installCallback = install => {
|
|
let failureHandler = (install, handler) => {
|
|
let message = "AddonInstall " + handler + " for " + this.id + ", state=" +
|
|
(install.state || "?") + ", error=" + install.error;
|
|
gLogger.error("ExperimentEntry::start() - " + message);
|
|
this._failedStart = true;
|
|
|
|
// TODO telemetry: install failure
|
|
|
|
deferred.reject(new Error(message));
|
|
};
|
|
|
|
let listener = {
|
|
onDownloadEnded: install => {
|
|
gLogger.trace("ExperimentEntry::start() - onDownloadEnded for " + this.id);
|
|
let addon = install.addon;
|
|
|
|
if (addon.id !== this.id) {
|
|
let message = "id mismatch: '" + this.id + "' vs. '" + addon.id + "'";
|
|
gLogger.error("ExperimentEntry::start() - " + message);
|
|
install.cancel();
|
|
}
|
|
},
|
|
|
|
onInstallStarted: install => {
|
|
gLogger.trace("ExperimentEntry::start() - onInstallStarted for " + this.id);
|
|
// TODO: this check still needs changes in the addon manager
|
|
//if (install.addon.type !== "experiment") {
|
|
// gLogger.error("ExperimentEntry::start() - wrong addon type");
|
|
// failureHandler({state: -1, error: -1}, "onInstallStarted");
|
|
//}
|
|
|
|
let addon = install.addon;
|
|
this._name = addon.name;
|
|
this._description = addon.description || "";
|
|
this._homepageURL = addon.homepageURL || "";
|
|
},
|
|
|
|
onInstallEnded: install => {
|
|
gLogger.trace("ExperimentEntry::start() - install ended for " + this.id);
|
|
this._lastChangedDate = this._policy.now();
|
|
this._startDate = this._policy.now();
|
|
this._enabled = true;
|
|
|
|
// TODO telemetry: install success
|
|
|
|
deferred.resolve();
|
|
},
|
|
};
|
|
|
|
["onDownloadCancelled", "onDownloadFailed", "onInstallCancelled", "onInstallFailed"]
|
|
.forEach(what => listener[what] = install => failureHandler(install, what));
|
|
|
|
install.addListener(listener);
|
|
install.install();
|
|
};
|
|
|
|
AddonManager.getInstallForURL(this._manifestData.xpiURL,
|
|
installCallback,
|
|
"application/x-xpinstall",
|
|
this._manifestData.xpiHash);
|
|
return deferred.promise;
|
|
},
|
|
|
|
/*
|
|
* Stop running the experiment if it is active.
|
|
* @param userDisabled (optional) Whether this is disabled by user action, defaults to false.
|
|
* @return Promise<> Resolved when the operation is complete.
|
|
*/
|
|
stop: function (userDisabled=false) {
|
|
gLogger.trace("ExperimentEntry::stop() - id=" + this.id + ", userDisabled=" + userDisabled);
|
|
if (!this._enabled) {
|
|
gLogger.warning("ExperimentEntry::stop() - experiment not enabled: " + id);
|
|
return Promise.reject();
|
|
}
|
|
|
|
this._enabled = false;
|
|
let deferred = Promise.defer();
|
|
let updateDates = () => {
|
|
let now = this._policy.now();
|
|
this._lastChangedDate = now;
|
|
this._endDate = now;
|
|
};
|
|
|
|
AddonManager.getAddonByID(this.id, addon => {
|
|
if (!addon) {
|
|
let message = "could not get Addon for " + this.id;
|
|
gLogger.warn("ExperimentEntry::stop() - " + message);
|
|
updateDates();
|
|
deferred.resolve();
|
|
return;
|
|
}
|
|
|
|
let listener = {};
|
|
let handler = addon => {
|
|
if (addon.id !== this.id) {
|
|
return;
|
|
}
|
|
|
|
updateDates();
|
|
|
|
AddonManager.removeAddonListener(listener);
|
|
deferred.resolve();
|
|
};
|
|
|
|
listener.onUninstalled = handler;
|
|
listener.onDisabled = handler;
|
|
|
|
AddonManager.addAddonListener(listener);
|
|
|
|
addon.uninstall();
|
|
|
|
// TODO telemetry: experiment disabling, differentiate by userDisabled
|
|
});
|
|
|
|
return deferred.promise;
|
|
},
|
|
|
|
/*
|
|
* Stop if experiment stop criteria are met.
|
|
* @return Promise<boolean> Resolved when done stopping or checking,
|
|
* the value indicates whether it was stopped.
|
|
*/
|
|
maybeStop: function () {
|
|
gLogger.trace("ExperimentEntry::maybeStop()");
|
|
|
|
return Task.spawn(function ExperimentEntry_maybeStop_task() {
|
|
let shouldStop = yield this._shouldStop();
|
|
if (shouldStop) {
|
|
yield this.stop();
|
|
}
|
|
throw new Task.Result(shouldStop);
|
|
}.bind(this));
|
|
},
|
|
|
|
_shouldStop: function () {
|
|
let data = this._manifestData;
|
|
let now = this._policy.now() / 1000; // The manifest times are in seconds.
|
|
let maxActiveSec = data.maxActiveSeconds || 0;
|
|
|
|
if (!this._enabled) {
|
|
return Promise.resolve(false);
|
|
}
|
|
|
|
let deferred = Promise.defer();
|
|
this.isApplicable().then(
|
|
() => deferred.resolve(false),
|
|
() => deferred.resolve(true)
|
|
);
|
|
|
|
return deferred.promise;
|
|
},
|
|
|
|
/*
|
|
* Should this be discarded from the cache due to age?
|
|
*/
|
|
shouldDiscard: function () {
|
|
let limit = this._policy.now();
|
|
limit.setDate(limit.getDate() - KEEP_HISTORY_N_DAYS);
|
|
return (this._lastChangedDate < limit);
|
|
},
|
|
|
|
/*
|
|
* Get next date (in epoch-ms) to schedule a re-evaluation for this.
|
|
* Returns 0 if it doesn't need one.
|
|
*/
|
|
getScheduleTime: function () {
|
|
if (this._enabled) {
|
|
let now = this._policy.now();
|
|
let startTime = this._startDate.getTime();
|
|
let maxActiveTime = startTime + 1000 * this._manifestData.maxActiveSeconds;
|
|
return Math.min(1000 * this._manifestData.endTime, maxActiveTime);
|
|
}
|
|
|
|
if (this._endDate) {
|
|
return this._endDate.getTime();
|
|
}
|
|
|
|
return 1000 * this._manifestData.startTime;
|
|
},
|
|
|
|
/*
|
|
* Perform sanity checks on the experiment data.
|
|
*/
|
|
_isManifestDataValid: function (data) {
|
|
gLogger.trace("ExperimentEntry::isManifestDataValid() - data: " + JSON.stringify(data));
|
|
|
|
for (let key of this.MANIFEST_REQUIRED_FIELDS) {
|
|
if (!(key in data)) {
|
|
gLogger.error("ExperimentEntry::isManifestDataValid() - missing required key: " + key);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
for (let key in data) {
|
|
if (!this.MANIFEST_OPTIONAL_FIELDS.has(key) &&
|
|
!this.MANIFEST_REQUIRED_FIELDS.has(key)) {
|
|
gLogger.error("ExperimentEntry::isManifestDataValid() - unknown key: " + key);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
},
|
|
};
|