Source code

Revision control

Copy as Markdown

Other Tools

/* 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/. */
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
PrefUtils: "resource://normandy/lib/PrefUtils.sys.mjs",
});
const FEATURE_ID = "prefFlips";
export const REASON_PREFFLIPS_FAILED = "prefFlips-failed";
export class PrefFlipsFeature {
#initialized;
#updating;
static get FEATURE_ID() {
return FEATURE_ID;
}
constructor({ manager }) {
this.manager = manager;
this._prefs = new Map();
this.#initialized = false;
this.#updating = false;
}
onFeatureUpdate() {
if (this.#updating) {
return;
}
this.#updating = true;
const activeEnrollment =
this.manager.store.getExperimentForFeature(PrefFlipsFeature.FEATURE_ID) ??
this.manager.store.getRolloutForFeature(PrefFlipsFeature.FEATURE_ID);
const prefs = lazy.NimbusFeatures[FEATURE_ID].getVariable("prefs") ?? {};
try {
for (const [pref, details] of this._prefs.entries()) {
if (Object.hasOwn(prefs, pref)) {
// The pref may have changed.
const newDetails = prefs[pref];
if (
newDetails.branch !== details.branch ||
newDetails.value !== details.value
) {
this._updatePref({
pref,
branch: newDetails.branch,
value: newDetails.value,
slug: activeEnrollment.slug,
});
}
} else {
// The pref is no longer controlled by us.
this._unregisterPref(pref);
}
}
} catch (e) {
if (e instanceof PrefFlipsFailedError) {
this.#updating = false;
this._unenrollForFailure(activeEnrollment, e.pref);
return;
}
}
try {
for (const [pref, { branch, value }] of Object.entries(prefs)) {
const known = this._prefs.get(pref);
if (known) {
// We have already processed this pref.
continue;
}
const setPref = this.manager._prefs.get(pref);
if (setPref) {
const toUnenroll = Array.from(setPref.slugs.values()).map(slug =>
this.manager.store.get(slug)
);
if (toUnenroll.length === 2 && !toUnenroll[0].isRollout) {
toUnenroll.reverse();
}
for (const enrollment of toUnenroll) {
this.manager._unenroll(enrollment, {
reason: "prefFlips-conflict",
conflictingSlug: activeEnrollment.slug,
});
}
}
this._registerPref({
pref,
branch,
value,
originalValue: lazy.PrefUtils.getPref(pref, { branch }),
slug: activeEnrollment.slug,
});
}
} catch (e) {
this.#updating = false;
this._unenrollForFailure(activeEnrollment, e.pref);
return;
}
if (activeEnrollment) {
// If this is new enrollment, we need to cache the original values of prefs
// so they can be restored.
if (!Object.hasOwn(activeEnrollment, "prefFlips")) {
activeEnrollment.prefFlips = {};
}
activeEnrollment.prefFlips.originalValues = Object.fromEntries(
Array.from(this._prefs.entries(), ([pref, { originalValue }]) => [
pref,
originalValue,
])
);
}
this.#updating = false;
}
/**
* Intialize the prefFlips feature.
*
* This will re-hydrate `this._prefs` from the active enrollment (if any) and
* register any necessary pref observers.
*
* onFeatureUpdate will be called for any future feature changes.
*/
init() {
if (this.#initialized) {
return;
}
const activeEnrollment =
this.manager.store.getExperimentForFeature(FEATURE_ID) ??
this.manager.store.getRolloutForFeature(FEATURE_ID);
if (activeEnrollment?.prefFlips?.originalValues) {
const featureValue = activeEnrollment.branch.features.find(
fc => fc.featureId === FEATURE_ID
).value;
try {
for (const [pref, { branch, value }] of Object.entries(
featureValue.prefs
)) {
this._registerPref({
pref,
branch,
value,
originalValue: activeEnrollment.prefFlips.originalValues[pref],
slug: activeEnrollment.slug,
});
}
} catch (e) {
if (e instanceof PrefFlipsFailedError) {
this._unenrollForFailure(activeEnrollment, e.pref);
return;
}
}
}
lazy.NimbusFeatures.prefFlips.onUpdate((...args) =>
this.onFeatureUpdate(...args)
);
this.#initialized = true;
}
_registerPref({ pref, branch, value, originalValue, slug }) {
const observer = (_aSubject, _aTopic, aData) => {
// This observer will be called for changes to `name` as well as any
// other pref that begins with `name.`, so we have to filter to
// exactly the pref we care about.
if (aData === pref) {
this._onPrefChanged(pref);
}
};
// If we *just* unenrolled a setPref experiment for this pref on the default
// branch, the pref will only be correctly restored if the pref had a value
// on the default branch. Otherwise, it will be left as-is until restart.
// This may result in us computing an incorrect originalValue, but (a) we
// couldn't correct the problem even if we recorded the correct (i.e., null)
// value and (b) the issue will resolve itself at next startup. This is
// consistent with how setPref experiments work.
const entry = {
branch,
originalValue,
value: value ?? null,
observer,
slug,
};
try {
lazy.PrefUtils.setPref(pref, value ?? null, { branch });
} catch (e) {
throw new PrefFlipsFailedError(pref);
}
Services.prefs.addObserver(pref, observer);
this._prefs.set(pref, entry);
}
_updatePref({ pref, branch, value, slug }) {
const entry = this._prefs.get(pref);
if (!entry) {
return;
}
Services.prefs.removeObserver(pref, entry.observer);
let originalValue = entry.originalValue;
if (entry.branch !== branch) {
// Restore the value on the previous branch.
//
// Because we were able to set the pref, it must have the same type as the
// originalValue, so this will also succeed.
lazy.PrefUtils.setPref(pref, entry.originalValue, {
branch: entry.branch,
});
originalValue = lazy.PrefUtils.getPref(pref, { branch });
}
Object.assign(entry, {
branch,
value,
originalValue,
slug,
});
try {
lazy.PrefUtils.setPref(pref, value, { branch });
} catch (e) {
throw new PrefFlipsFailedError(pref);
}
Services.prefs.addObserver(pref, entry.observer);
}
_unregisterPref(pref) {
const entry = this._prefs.get(pref);
if (!entry) {
return;
}
this._prefs.delete(pref);
Services.prefs.removeObserver(pref, entry.observer);
const { originalValue, branch } = entry;
lazy.PrefUtils.setPref(pref, originalValue, { branch });
}
_onPrefChanged(pref) {
if (this.#updating) {
return;
}
if (this.manager._prefs.get(pref)?.enrollmentChanging) {
return;
}
this.#updating = true;
const entry = this._prefs.get(pref);
if (!entry) {
return;
}
this._prefs.delete(pref);
Services.prefs.removeObserver(pref, entry.observer);
const changedPref = {
name: pref,
branch: PrefFlipsFeature.determinePrefChangeBranch(
pref,
entry.branch,
entry.value
),
};
// If there is both an experiment and a rollout that would both control the
// same pref, we unenroll both because if we only unenrolled the experiment,
// the rollout would clobber the pref change that just happened.
const toUnenroll = this.manager.store.getAll().filter(enrollment => {
if (!enrollment.active || !enrollment.featureIds.includes(FEATURE_ID)) {
return false;
}
const featureValue = enrollment.branch.features.find(
featureConfig => featureConfig.featureId === FEATURE_ID
).value;
return Object.hasOwn(featureValue.prefs, pref);
});
// We have to restore every *other* pref controlled by these enrollments.
const toRestore = new Set(
toUnenroll.flatMap(enrollment =>
Object.keys(
enrollment.branch.features.find(
featureConfig => featureConfig.featureId === FEATURE_ID
).value.prefs
)
)
);
toRestore.delete(pref);
for (const prefToRestore of toRestore) {
this._unregisterPref(prefToRestore);
}
// Unenrollment doesn't matter here like it does in ExperimentManager's
// managed prefs because we've already restored prefs before unenrollment.
for (const enrollment of toUnenroll) {
this.manager._unenroll(enrollment, {
reason: "changed-pref",
changedPref,
});
}
this.#updating = false;
// If we've caused unenrollments, we need to recompute state.
this.onFeatureUpdate();
}
static determinePrefChangeBranch(pref, expectedBranch, expectedValue) {
// We want to know what branch was changed so we can know if we should
// restore prefs (.e.,g if we have a pref set on the user branch and the
// user branch changed, we do not want to then overwrite the user's choice).
// This is not complicated if a pref simply changed. However, we must also
// detect `nsIPrefBranch::clearUserPref()`, which wipes out the user branch
// and leaves the default branch untouched. That is where this gets
// complicated.
if (Services.prefs.prefHasUserValue(pref)) {
// If there is a user branch value, then the user branch changed, because
// a change to the default branch wouldn't have triggered the observer.
return "user";
} else if (!Services.prefs.prefHasDefaultValue(pref)) {
// If there is no user branch value *or* default branch avlue, then the
// user branch must have been cleared because you cannot clear the default
// branch.
return "user";
} else if (expectedBranch === "default") {
const value = lazy.PrefUtils.getPref(pref, { branch: "default" });
if (value === expectedValue) {
// The pref we control was set on the default branch and still matches
// the expected value. Therefore, the user branch must have been
// cleared.
return "user";
}
// The default value branch does not match the value we expect, so it
// must have just changed.
return "default";
}
return "user";
}
_unenrollForFailure(enrollment, pref) {
const rawType = Services.prefs.getPrefType(pref);
let prefType = "invalid";
switch (rawType) {
case Ci.nsIPrefBranch.PREF_BOOL:
prefType = "bool";
break;
case Ci.nsIPrefBranch.PREF_STRING:
prefType = "string";
break;
case Ci.nsIPrefBranch.PREF_INT:
prefType = "int";
break;
}
this.manager._unenroll(enrollment, {
reason: REASON_PREFFLIPS_FAILED,
prefName: pref,
prefType,
});
}
}
/**
* Thrown when the prefFlips feature fails to set a pref.
*/
class PrefFlipsFailedError extends Error {
constructor(pref, value) {
super(`The Nimbus prefFlips feature failed to set ${pref}=${value}`);
this.pref = pref;
}
}