summaryrefslogtreecommitdiffstats
path: root/toolkit/components/normandy/actions/BranchedAddonStudyAction.sys.mjs
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /toolkit/components/normandy/actions/BranchedAddonStudyAction.sys.mjs
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/normandy/actions/BranchedAddonStudyAction.sys.mjs')
-rw-r--r--toolkit/components/normandy/actions/BranchedAddonStudyAction.sys.mjs762
1 files changed, 762 insertions, 0 deletions
diff --git a/toolkit/components/normandy/actions/BranchedAddonStudyAction.sys.mjs b/toolkit/components/normandy/actions/BranchedAddonStudyAction.sys.mjs
new file mode 100644
index 0000000000..d775429ced
--- /dev/null
+++ b/toolkit/components/normandy/actions/BranchedAddonStudyAction.sys.mjs
@@ -0,0 +1,762 @@
+/* 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(recipe) {
+ 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";
+};