762 lines
24 KiB
JavaScript
762 lines
24 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/. */
|
|
|
|
/*
|
|
* This action handles the life cycle of add-on based studies. Currently that
|
|
* means installing the add-on the first time the recipe applies to this
|
|
* client, updating the add-on to new versions if the recipe changes, and
|
|
* uninstalling them when the recipe no longer applies.
|
|
*/
|
|
|
|
import { BaseStudyAction } from "resource://normandy/actions/BaseStudyAction.sys.mjs";
|
|
|
|
const lazy = {};
|
|
|
|
ChromeUtils.defineESModuleGetters(lazy, {
|
|
ActionSchemas: "resource://normandy/actions/schemas/index.sys.mjs",
|
|
AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
|
|
AddonStudies: "resource://normandy/lib/AddonStudies.sys.mjs",
|
|
BaseAction: "resource://normandy/actions/BaseAction.sys.mjs",
|
|
ClientEnvironment: "resource://normandy/lib/ClientEnvironment.sys.mjs",
|
|
NormandyApi: "resource://normandy/lib/NormandyApi.sys.mjs",
|
|
Sampling: "resource://gre/modules/components-utils/Sampling.sys.mjs",
|
|
TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs",
|
|
TelemetryEvents: "resource://normandy/lib/TelemetryEvents.sys.mjs",
|
|
});
|
|
|
|
class AddonStudyEnrollError extends Error {
|
|
/**
|
|
* @param {string} studyName
|
|
* @param {object} extra Extra details to include when reporting the error to telemetry.
|
|
* @param {string} extra.reason The specific reason for the failure.
|
|
*/
|
|
constructor(studyName, extra) {
|
|
let message;
|
|
let { reason } = extra;
|
|
switch (reason) {
|
|
case "conflicting-addon-id": {
|
|
message = "an add-on with this ID is already installed";
|
|
break;
|
|
}
|
|
case "download-failure": {
|
|
message = "the add-on failed to download";
|
|
break;
|
|
}
|
|
case "metadata-mismatch": {
|
|
message = "the server metadata does not match the downloaded add-on";
|
|
break;
|
|
}
|
|
case "install-failure": {
|
|
message = "the add-on failed to install";
|
|
break;
|
|
}
|
|
default: {
|
|
throw new Error(`Unexpected AddonStudyEnrollError reason: ${reason}`);
|
|
}
|
|
}
|
|
super(`Cannot install study add-on for ${studyName}: ${message}.`);
|
|
this.studyName = studyName;
|
|
this.extra = extra;
|
|
}
|
|
}
|
|
|
|
class AddonStudyUpdateError extends Error {
|
|
/**
|
|
* @param {string} studyName
|
|
* @param {object} extra Extra details to include when reporting the error to telemetry.
|
|
* @param {string} extra.reason The specific reason for the failure.
|
|
*/
|
|
constructor(studyName, extra) {
|
|
let message;
|
|
let { reason } = extra;
|
|
switch (reason) {
|
|
case "addon-id-mismatch": {
|
|
message = "new add-on ID does not match old add-on ID";
|
|
break;
|
|
}
|
|
case "addon-does-not-exist": {
|
|
message = "an add-on with this ID does not exist";
|
|
break;
|
|
}
|
|
case "no-downgrade": {
|
|
message = "the add-on was an older version than is installed";
|
|
break;
|
|
}
|
|
case "metadata-mismatch": {
|
|
message = "the server metadata does not match the downloaded add-on";
|
|
break;
|
|
}
|
|
case "download-failure": {
|
|
message = "the add-on failed to download";
|
|
break;
|
|
}
|
|
case "install-failure": {
|
|
message = "the add-on failed to install";
|
|
break;
|
|
}
|
|
default: {
|
|
throw new Error(`Unexpected AddonStudyUpdateError reason: ${reason}`);
|
|
}
|
|
}
|
|
super(`Cannot update study add-on for ${studyName}: ${message}.`);
|
|
this.studyName = studyName;
|
|
this.extra = extra;
|
|
}
|
|
}
|
|
|
|
export class BranchedAddonStudyAction extends BaseStudyAction {
|
|
get schema() {
|
|
return lazy.ActionSchemas["branched-addon-study"];
|
|
}
|
|
|
|
constructor() {
|
|
super();
|
|
this.seenRecipeIds = new Set();
|
|
}
|
|
|
|
async _run() {
|
|
throw new Error("_run should not be called anymore");
|
|
}
|
|
|
|
/**
|
|
* This hook is executed once for every recipe currently enabled on the
|
|
* server. It is responsible for:
|
|
*
|
|
* - Enrolling studies the first time they have a FILTER_MATCH suitability.
|
|
* - Updating studies that have changed and still have a FILTER_MATCH suitability.
|
|
* - Marking studies as having been seen in this session.
|
|
* - Unenrolling studies when they have permanent errors.
|
|
* - Unenrolling studies when temporary errors persist for too long.
|
|
*
|
|
* If the action fails to perform any of these tasks, it should throw to
|
|
* properly report its status.
|
|
*/
|
|
async _processRecipe(recipe, suitability) {
|
|
this.seenRecipeIds.add(recipe.id);
|
|
const study = await lazy.AddonStudies.get(recipe.id);
|
|
|
|
switch (suitability) {
|
|
case lazy.BaseAction.suitability.FILTER_MATCH: {
|
|
if (!study) {
|
|
await this.enroll(recipe);
|
|
} else if (study.active) {
|
|
await this.update(recipe, study);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case lazy.BaseAction.suitability.SIGNATURE_ERROR: {
|
|
await this._considerTemporaryError({
|
|
study,
|
|
reason: "signature-error",
|
|
});
|
|
break;
|
|
}
|
|
|
|
case lazy.BaseAction.suitability.FILTER_ERROR: {
|
|
await this._considerTemporaryError({
|
|
study,
|
|
reason: "filter-error",
|
|
});
|
|
break;
|
|
}
|
|
|
|
case lazy.BaseAction.suitability.CAPABILITIES_MISMATCH: {
|
|
if (study?.active) {
|
|
await this.unenroll(recipe.id, "capability-mismatch");
|
|
}
|
|
break;
|
|
}
|
|
|
|
case lazy.BaseAction.suitability.FILTER_MISMATCH: {
|
|
if (study?.active) {
|
|
await this.unenroll(recipe.id, "filter-mismatch");
|
|
}
|
|
break;
|
|
}
|
|
|
|
case lazy.BaseAction.suitability.ARGUMENTS_INVALID: {
|
|
if (study?.active) {
|
|
await this.unenroll(recipe.id, "arguments-invalid");
|
|
}
|
|
break;
|
|
}
|
|
|
|
default: {
|
|
throw new Error(`Unknown recipe suitability "${suitability}".`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* This hook is executed once after all recipes that apply to this client
|
|
* have been processed. It is responsible for unenrolling the client from any
|
|
* studies that no longer apply, based on this.seenRecipeIds.
|
|
*/
|
|
async _finalize({ noRecipes } = {}) {
|
|
const activeStudies = await lazy.AddonStudies.getAllActive({
|
|
branched: lazy.AddonStudies.FILTER_BRANCHED_ONLY,
|
|
});
|
|
|
|
if (noRecipes) {
|
|
if (this.seenRecipeIds.size) {
|
|
throw new BranchedAddonStudyAction.BadNoRecipesArg();
|
|
}
|
|
for (const study of activeStudies) {
|
|
await this._considerTemporaryError({ study, reason: "no-recipes" });
|
|
}
|
|
} else {
|
|
for (const study of activeStudies) {
|
|
if (!this.seenRecipeIds.has(study.recipeId)) {
|
|
this.log.debug(
|
|
`Stopping branched add-on study for recipe ${study.recipeId}`
|
|
);
|
|
try {
|
|
await this.unenroll(study.recipeId, "recipe-not-seen");
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Download and install the addon for a given recipe
|
|
*
|
|
* @param recipe Object describing the study to enroll in.
|
|
* @param extensionDetails Object describing the addon to be installed.
|
|
* @param onInstallStarted A function that returns a callback for the install listener.
|
|
* @param onComplete A callback function that is run on completion of the download.
|
|
* @param onFailedInstall A callback function that is run if the installation fails.
|
|
* @param errorClass The class of error to be thrown when exceptions occur.
|
|
* @param reportError A function that reports errors to Telemetry.
|
|
* @param [errorExtra] Optional, an object that will be merged into the
|
|
* `extra` field of the error generated, if any.
|
|
*/
|
|
async downloadAndInstall({
|
|
recipe,
|
|
extensionDetails,
|
|
branchSlug,
|
|
onInstallStarted,
|
|
onComplete,
|
|
onFailedInstall,
|
|
errorClass,
|
|
reportError,
|
|
errorExtra = {},
|
|
}) {
|
|
const { slug } = recipe.arguments;
|
|
const { hash, hash_algorithm } = extensionDetails;
|
|
|
|
const downloadDeferred = Promise.withResolvers();
|
|
const installDeferred = Promise.withResolvers();
|
|
|
|
const install = await lazy.AddonManager.getInstallForURL(
|
|
extensionDetails.xpi,
|
|
{
|
|
hash: `${hash_algorithm}:${hash}`,
|
|
telemetryInfo: { source: "internal" },
|
|
}
|
|
);
|
|
|
|
const listener = {
|
|
onDownloadFailed() {
|
|
downloadDeferred.reject(
|
|
new errorClass(slug, {
|
|
reason: "download-failure",
|
|
branch: branchSlug,
|
|
detail: lazy.AddonManager.errorToString(install.error),
|
|
...errorExtra,
|
|
})
|
|
);
|
|
},
|
|
|
|
onDownloadEnded() {
|
|
downloadDeferred.resolve();
|
|
return false; // temporarily pause installation for Normandy bookkeeping
|
|
},
|
|
|
|
onInstallFailed() {
|
|
installDeferred.reject(
|
|
new errorClass(slug, {
|
|
reason: "install-failure",
|
|
branch: branchSlug,
|
|
detail: lazy.AddonManager.errorToString(install.error),
|
|
})
|
|
);
|
|
},
|
|
|
|
onInstallEnded() {
|
|
installDeferred.resolve();
|
|
},
|
|
};
|
|
|
|
listener.onInstallStarted = onInstallStarted(installDeferred);
|
|
|
|
install.addListener(listener);
|
|
|
|
// Download the add-on
|
|
try {
|
|
install.install();
|
|
await downloadDeferred.promise;
|
|
} catch (err) {
|
|
reportError(err);
|
|
install.removeListener(listener);
|
|
throw err;
|
|
}
|
|
|
|
await onComplete(install, listener);
|
|
|
|
// Finish paused installation
|
|
try {
|
|
install.install();
|
|
await installDeferred.promise;
|
|
} catch (err) {
|
|
reportError(err);
|
|
install.removeListener(listener);
|
|
await onFailedInstall();
|
|
throw err;
|
|
}
|
|
|
|
install.removeListener(listener);
|
|
|
|
return [install.addon.id, install.addon.version];
|
|
}
|
|
|
|
async chooseBranch({ slug, branches }) {
|
|
const ratios = branches.map(branch => branch.ratio);
|
|
const userId = lazy.ClientEnvironment.userId;
|
|
|
|
// It's important that the input be:
|
|
// - Unique per-user (no one is bucketed alike)
|
|
// - Unique per-experiment (bucketing differs across multiple experiments)
|
|
// - Differs from the input used for sampling the recipe (otherwise only
|
|
// branches that contain the same buckets as the recipe sampling will
|
|
// receive users)
|
|
const input = `${userId}-${slug}-addon-branch`;
|
|
|
|
const index = await lazy.Sampling.ratioSample(input, ratios);
|
|
return branches[index];
|
|
}
|
|
|
|
/**
|
|
* Enroll in the study represented by the given recipe.
|
|
* @param recipe Object describing the study to enroll in.
|
|
* @param extensionDetails Object describing the addon to be installed.
|
|
*/
|
|
async enroll(recipe) {
|
|
// This function first downloads the add-on to get its metadata. Then it
|
|
// uses that metadata to record a study in `AddonStudies`. Then, it finishes
|
|
// installing the add-on, and finally sends telemetry. If any of these steps
|
|
// fails, the previous ones are undone, as needed.
|
|
//
|
|
// This ordering is important because the only intermediate states we can be
|
|
// in are:
|
|
// 1. The add-on is only downloaded, in which case AddonManager will clean it up.
|
|
// 2. The study has been recorded, in which case we will unenroll on next
|
|
// start up. The start up code will assume that the add-on was uninstalled
|
|
// while the browser was shutdown.
|
|
// 3. After installation is complete, but before telemetry, in which case we
|
|
// lose an enroll event. This is acceptable.
|
|
//
|
|
// This way a shutdown, crash or unexpected error can't leave Normandy in a
|
|
// long term inconsistent state. The main thing avoided is having a study
|
|
// add-on installed but no record of it, which would leave it permanently
|
|
// installed.
|
|
|
|
if (recipe.arguments.isEnrollmentPaused) {
|
|
// Recipe does not need anything done
|
|
return;
|
|
}
|
|
|
|
const { slug, userFacingName, userFacingDescription } = recipe.arguments;
|
|
const branch = await this.chooseBranch({
|
|
slug: recipe.arguments.slug,
|
|
branches: recipe.arguments.branches,
|
|
});
|
|
this.log.debug(`Enrolling in branch ${branch.slug}`);
|
|
|
|
if (branch.extensionApiId === null) {
|
|
const study = {
|
|
recipeId: recipe.id,
|
|
slug,
|
|
userFacingName,
|
|
userFacingDescription,
|
|
branch: branch.slug,
|
|
addonId: null,
|
|
addonVersion: null,
|
|
addonUrl: null,
|
|
extensionApiId: null,
|
|
extensionHash: null,
|
|
extensionHashAlgorithm: null,
|
|
active: true,
|
|
studyStartDate: new Date(),
|
|
studyEndDate: null,
|
|
temporaryErrorDeadline: null,
|
|
};
|
|
|
|
try {
|
|
await lazy.AddonStudies.add(study);
|
|
} catch (err) {
|
|
this.reportEnrollError(err);
|
|
throw err;
|
|
}
|
|
|
|
// All done, report success to Telemetry
|
|
lazy.TelemetryEvents.sendEvent("enroll", "addon_study", slug, {
|
|
addonId: lazy.AddonStudies.NO_ADDON_MARKER,
|
|
addonVersion: lazy.AddonStudies.NO_ADDON_MARKER,
|
|
branch: branch.slug,
|
|
});
|
|
} else {
|
|
const extensionDetails = await lazy.NormandyApi.fetchExtensionDetails(
|
|
branch.extensionApiId
|
|
);
|
|
|
|
const onInstallStarted = installDeferred => cbInstall => {
|
|
const versionMatches =
|
|
cbInstall.addon.version === extensionDetails.version;
|
|
const idMatches = cbInstall.addon.id === extensionDetails.extension_id;
|
|
|
|
if (cbInstall.existingAddon) {
|
|
installDeferred.reject(
|
|
new AddonStudyEnrollError(slug, {
|
|
reason: "conflicting-addon-id",
|
|
branch: branch.slug,
|
|
})
|
|
);
|
|
return false; // cancel the installation, no upgrades allowed
|
|
} else if (!versionMatches || !idMatches) {
|
|
installDeferred.reject(
|
|
new AddonStudyEnrollError(slug, {
|
|
branch: branch.slug,
|
|
reason: "metadata-mismatch",
|
|
})
|
|
);
|
|
return false; // cancel the installation, server metadata does not match downloaded add-on
|
|
}
|
|
return true;
|
|
};
|
|
|
|
let study;
|
|
const onComplete = async (install, listener) => {
|
|
study = {
|
|
recipeId: recipe.id,
|
|
slug,
|
|
userFacingName,
|
|
userFacingDescription,
|
|
branch: branch.slug,
|
|
addonId: install.addon.id,
|
|
addonVersion: install.addon.version,
|
|
addonUrl: extensionDetails.xpi,
|
|
extensionApiId: branch.extensionApiId,
|
|
extensionHash: extensionDetails.hash,
|
|
extensionHashAlgorithm: extensionDetails.hash_algorithm,
|
|
active: true,
|
|
studyStartDate: new Date(),
|
|
studyEndDate: null,
|
|
temporaryErrorDeadline: null,
|
|
};
|
|
|
|
try {
|
|
await lazy.AddonStudies.add(study);
|
|
} catch (err) {
|
|
this.reportEnrollError(err);
|
|
install.removeListener(listener);
|
|
install.cancel();
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
const onFailedInstall = async () => {
|
|
await lazy.AddonStudies.delete(recipe.id);
|
|
};
|
|
|
|
const [installedId, installedVersion] = await this.downloadAndInstall({
|
|
recipe,
|
|
branchSlug: branch.slug,
|
|
extensionDetails,
|
|
onInstallStarted,
|
|
onComplete,
|
|
onFailedInstall,
|
|
errorClass: AddonStudyEnrollError,
|
|
reportError: this.reportEnrollError,
|
|
});
|
|
|
|
// All done, report success to Telemetry
|
|
lazy.TelemetryEvents.sendEvent("enroll", "addon_study", slug, {
|
|
addonId: installedId,
|
|
addonVersion: installedVersion,
|
|
branch: branch.slug,
|
|
});
|
|
}
|
|
|
|
lazy.TelemetryEnvironment.setExperimentActive(slug, branch.slug, {
|
|
type: "normandy-addonstudy",
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Update the study represented by the given recipe.
|
|
* @param recipe Object describing the study to be updated.
|
|
* @param extensionDetails Object describing the addon to be installed.
|
|
*/
|
|
async update(recipe, study) {
|
|
const { slug } = recipe.arguments;
|
|
|
|
// Stay in the same branch, don't re-sample every time.
|
|
const branch = recipe.arguments.branches.find(
|
|
branch => branch.slug === study.branch
|
|
);
|
|
|
|
if (!branch) {
|
|
// Our branch has been removed. Unenroll.
|
|
await this.unenroll(recipe.id, "branch-removed");
|
|
return;
|
|
}
|
|
|
|
// Since we saw a non-error suitability, clear the temporary error deadline.
|
|
study.temporaryErrorDeadline = null;
|
|
await lazy.AddonStudies.update(study);
|
|
|
|
const extensionDetails = await lazy.NormandyApi.fetchExtensionDetails(
|
|
branch.extensionApiId
|
|
);
|
|
|
|
let error;
|
|
|
|
if (study.addonId && study.addonId !== extensionDetails.extension_id) {
|
|
error = new AddonStudyUpdateError(slug, {
|
|
branch: branch.slug,
|
|
reason: "addon-id-mismatch",
|
|
});
|
|
}
|
|
|
|
const versionCompare = Services.vc.compare(
|
|
study.addonVersion,
|
|
extensionDetails.version
|
|
);
|
|
if (versionCompare > 0) {
|
|
error = new AddonStudyUpdateError(slug, {
|
|
branch: branch.slug,
|
|
reason: "no-downgrade",
|
|
});
|
|
} else if (versionCompare === 0) {
|
|
return; // Unchanged, do nothing
|
|
}
|
|
|
|
if (error) {
|
|
this.reportUpdateError(error);
|
|
throw error;
|
|
}
|
|
|
|
const onInstallStarted = installDeferred => cbInstall => {
|
|
const versionMatches =
|
|
cbInstall.addon.version === extensionDetails.version;
|
|
const idMatches = cbInstall.addon.id === extensionDetails.extension_id;
|
|
|
|
if (!cbInstall.existingAddon) {
|
|
installDeferred.reject(
|
|
new AddonStudyUpdateError(slug, {
|
|
branch: branch.slug,
|
|
reason: "addon-does-not-exist",
|
|
})
|
|
);
|
|
return false; // cancel the installation, must upgrade an existing add-on
|
|
} else if (!versionMatches || !idMatches) {
|
|
installDeferred.reject(
|
|
new AddonStudyUpdateError(slug, {
|
|
branch: branch.slug,
|
|
reason: "metadata-mismatch",
|
|
})
|
|
);
|
|
return false; // cancel the installation, server metadata do not match downloaded add-on
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
const onComplete = async (install, listener) => {
|
|
try {
|
|
await lazy.AddonStudies.update({
|
|
...study,
|
|
addonVersion: install.addon.version,
|
|
addonUrl: extensionDetails.xpi,
|
|
extensionHash: extensionDetails.hash,
|
|
extensionHashAlgorithm: extensionDetails.hash_algorithm,
|
|
extensionApiId: branch.extensionApiId,
|
|
});
|
|
} catch (err) {
|
|
this.reportUpdateError(err);
|
|
install.removeListener(listener);
|
|
install.cancel();
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
const onFailedInstall = () => {
|
|
lazy.AddonStudies.update(study);
|
|
};
|
|
|
|
const [installedId, installedVersion] = await this.downloadAndInstall({
|
|
recipe,
|
|
extensionDetails,
|
|
branchSlug: branch.slug,
|
|
onInstallStarted,
|
|
onComplete,
|
|
onFailedInstall,
|
|
errorClass: AddonStudyUpdateError,
|
|
reportError: this.reportUpdateError,
|
|
errorExtra: {},
|
|
});
|
|
|
|
// All done, report success to Telemetry
|
|
lazy.TelemetryEvents.sendEvent("update", "addon_study", slug, {
|
|
addonId: installedId,
|
|
addonVersion: installedVersion,
|
|
branch: branch.slug,
|
|
});
|
|
}
|
|
|
|
reportEnrollError(error) {
|
|
if (error instanceof AddonStudyEnrollError) {
|
|
// One of our known errors. Report it nicely to telemetry
|
|
lazy.TelemetryEvents.sendEvent(
|
|
"enrollFailed",
|
|
"addon_study",
|
|
error.studyName,
|
|
error.extra
|
|
);
|
|
} else {
|
|
/*
|
|
* Some unknown error. Add some helpful details, and report it to
|
|
* telemetry. The actual stack trace and error message could possibly
|
|
* contain PII, so we don't include them here. Instead include some
|
|
* information that should still be helpful, and is less likely to be
|
|
* unsafe.
|
|
*/
|
|
const safeErrorMessage = `${error.fileName}:${error.lineNumber}:${error.columnNumber} ${error.name}`;
|
|
lazy.TelemetryEvents.sendEvent(
|
|
"enrollFailed",
|
|
"addon_study",
|
|
error.studyName,
|
|
{
|
|
reason: safeErrorMessage.slice(0, 80), // max length is 80 chars
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
reportUpdateError(error) {
|
|
if (error instanceof AddonStudyUpdateError) {
|
|
// One of our known errors. Report it nicely to telemetry
|
|
lazy.TelemetryEvents.sendEvent(
|
|
"updateFailed",
|
|
"addon_study",
|
|
error.studyName,
|
|
error.extra
|
|
);
|
|
} else {
|
|
/*
|
|
* Some unknown error. Add some helpful details, and report it to
|
|
* telemetry. The actual stack trace and error message could possibly
|
|
* contain PII, so we don't include them here. Instead include some
|
|
* information that should still be helpful, and is less likely to be
|
|
* unsafe.
|
|
*/
|
|
const safeErrorMessage = `${error.fileName}:${error.lineNumber}:${error.columnNumber} ${error.name}`;
|
|
lazy.TelemetryEvents.sendEvent(
|
|
"updateFailed",
|
|
"addon_study",
|
|
error.studyName,
|
|
{
|
|
reason: safeErrorMessage.slice(0, 80), // max length is 80 chars
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Unenrolls the client from the study with a given recipe ID.
|
|
* @param recipeId The recipe ID of an enrolled study
|
|
* @param reason The reason for this unenrollment, to be used in Telemetry
|
|
* @throws If the specified study does not exist, or if it is already inactive.
|
|
*/
|
|
async unenroll(recipeId, reason = "unknown") {
|
|
const study = await lazy.AddonStudies.get(recipeId);
|
|
if (!study) {
|
|
throw new Error(`No study found for recipe ${recipeId}.`);
|
|
}
|
|
if (!study.active) {
|
|
throw new Error(
|
|
`Cannot stop study for recipe ${recipeId}; it is already inactive.`
|
|
);
|
|
}
|
|
|
|
await lazy.AddonStudies.markAsEnded(study, reason);
|
|
|
|
// Study branches may indicate that no add-on should be installed, as a
|
|
// form of control branch. In that case, `study.addonId` will be null (as
|
|
// will the other add-on related fields). Only try to uninstall the add-on
|
|
// if we expect one should be installed.
|
|
if (study.addonId) {
|
|
const addon = await lazy.AddonManager.getAddonByID(study.addonId);
|
|
if (addon) {
|
|
await addon.uninstall();
|
|
} else {
|
|
this.log.warn(
|
|
`Could not uninstall addon ${study.addonId} for recipe ${study.recipeId}: it is not installed.`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Given that a temporary error has occured for a study, check if it
|
|
* should be temporarily ignored, or if the deadline has passed. If the
|
|
* deadline is passed, the study will be ended. If this is the first
|
|
* temporary error, a deadline will be generated. Otherwise, nothing will
|
|
* happen.
|
|
*
|
|
* If a temporary deadline exists but cannot be parsed, a new one will be
|
|
* made.
|
|
*
|
|
* The deadline is 7 days from the first time that recipe failed, as
|
|
* reckoned by the client's clock.
|
|
*
|
|
* @param {Object} args
|
|
* @param {Study} args.study The enrolled study to potentially unenroll.
|
|
* @param {String} args.reason If the study should end, the reason it is ending.
|
|
*/
|
|
async _considerTemporaryError({ study, reason }) {
|
|
if (!study?.active) {
|
|
return;
|
|
}
|
|
|
|
let now = Date.now(); // milliseconds-since-epoch
|
|
let day = 24 * 60 * 60 * 1000;
|
|
let newDeadline = new Date(now + 7 * day);
|
|
|
|
if (study.temporaryErrorDeadline) {
|
|
// if deadline is an invalid date, set it to one week from now.
|
|
if (isNaN(study.temporaryErrorDeadline)) {
|
|
study.temporaryErrorDeadline = newDeadline;
|
|
await lazy.AddonStudies.update(study);
|
|
return;
|
|
}
|
|
|
|
if (now > study.temporaryErrorDeadline) {
|
|
await this.unenroll(study.recipeId, reason);
|
|
}
|
|
} else {
|
|
// there is no deadline, so set one
|
|
study.temporaryErrorDeadline = newDeadline;
|
|
await lazy.AddonStudies.update(study);
|
|
}
|
|
}
|
|
}
|
|
|
|
BranchedAddonStudyAction.BadNoRecipesArg = class extends Error {
|
|
message = "noRecipes is true, but some recipes observed";
|
|
};
|