1
0
Fork 0
firefox/toolkit/components/nimbus/lib/PrefFlipsFeature.sys.mjs
Daniel Baumann 5e9a113729
Adding upstream version 140.0.
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
2025-06-25 09:37:52 +02:00

714 lines
20 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/. */
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
ExperimentManager: "resource://nimbus/lib/ExperimentManager.sys.mjs",
NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
PrefUtils: "resource://normandy/lib/PrefUtils.sys.mjs",
UnenrollmentCause: "resource://nimbus/lib/ExperimentManager.sys.mjs",
});
const FEATURE_ID = "prefFlips";
/**
* The value of a pref.
*
* A value of null indicates that the preference will be cleared.
*
* @typedef {string | number | boolean | null} PrefValue
*/
/**
* Prefs can be set on two branches: the user branch or the default branch.
*
* The user branch will be persisted to a file on disk and be restored early
* during startup, whereas the default branch is set to default values every
* startup.
*
* @typedef {"user" | "default"} PrefBranch
*/
/**
* Information about an individual pref tracked by this feaure.
*
* @typedef {object} PrefEntry
*
* @property {PrefBranch} branch
* The branch the pref was set on.
*
* @property {PrefValue} originalValue
* The original value of the pref on that branch. This may be `null`
* to indicate there was no value.
*
* @property {PrefValue} value
* The value of the pref. This may be `null` to indicate the pref was
* cleared.
*
* @property {nsIPrefObserver} observer
* The observer that is called when the pref changes to cause
* unenrollment.
*
* @property {Set<string>} slugs
* The slugs of all active enrollments that set this pref.
*/
/**
* Configuration for an individual preference in the feature configuration.
*
* @typedef {object} PrefConfig
*
* @property {PrefBranch} branch
* The requested branch to set the pref on.
*
* @property {PrefValue} value
* The requested value to set the pref to.
*/
/**
* The prefFlips feature.
*
* This should *only* be instantiated by the active `ExperimentManager` in the
* parent process.
*/
export class PrefFlipsFeature {
/**
* Whether or not the feature has been initialized.
*
* @type {boolean}
*/
#initialized;
/**
* Whether or not the feature is currently in a feature update callback. This
* flag is checked to ensure we don't recursively trigger update callbacks by
* causing unenrollments.
*
* @type {boolean}
*/
#updating;
/**
* All the prefs that the feature is tracking.
*
* @type {Map<string, PrefEntry>}
*/
#prefs;
/**
* A mapping of prefs to the slugs of experiments that set that pref.
*
* It is guaranteed that all these slugs set the pref to the same value on the
* same branch.
*
* @type {Map<string, Set<string>>}
*/
#prefsBySlug;
static get FEATURE_ID() {
return FEATURE_ID;
}
/**
* Construct a new prefFlips feature.
*
* @param {object} options
* @param {ExperimentManager} options.manager
* The ExperimentManager that owns this feature.
*/
constructor({ manager }) {
this.manager = manager;
this.#initialized = false;
this.#updating = false;
this.#prefs = new Map();
this.#prefsBySlug = new Map();
}
/**
* 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;
}
this.#initialized = true;
const enrollments = lazy.NimbusFeatures[FEATURE_ID].getAllEnrollments();
this.#updating = true;
for (const {
meta: { slug },
value: { prefs = {} },
} of enrollments) {
const enrollment = this.manager.store.get(slug);
this.#restoreEnrollment(enrollment, prefs);
}
this.#updating = false;
lazy.NimbusFeatures[FEATURE_ID].onUpdate(this.#onFeatureUpdate.bind(this));
}
/**
* Return the orginal value of the pref on the specific branch if it is set by
* this feature.
*
* @params {string} pref
* The pref to get the original value of.
*
* @params {PrefBranch} branch
* The requested branch for the pref.
*
* @returns {PrefValue | undefined}
* The original value of the pref on the specified branch. If the
* pref is not set, `undefined` will be returned. If the pref is
* being cleared by an experiment, `null` will be returned.
*/
_getOriginalValue(pref, branch) {
const entry = this.#prefs.get(pref);
if (!entry || entry.branch !== branch) {
return undefined;
}
return entry.originalValue;
}
/**
* Return the number of registered prefs.
*
* This is only exposed so that tests can assert on its value.
*
* @returns {number}
* The number of registered prefs.
*/
get _registeredPrefCount() {
return this.#prefs.size;
}
/**
* Handle a potential conflict between a pref flip we own and a regular
* setPref.
*
* This should only be called by the global `ExperimentManager` when it is
* enrolling.
*
* @param {string} conflictingSlug
* The slug the ExperimentManager is enrolling.
*
* @param {string[]} prefs
* The prefs that the experiment will set.
*/
async _handleSetPrefConflict(conflictingSlug, prefs) {
// Suppress feature updates while we unenroll from these enrollments.
this.#updating = true;
for (const pref of prefs) {
const entry = this.#prefs.get(pref);
if (entry) {
for (const slug of entry.slugs) {
await this.manager.unenroll(
slug,
lazy.UnenrollmentCause.PrefFlipsConflict(conflictingSlug)
);
this.#removeEnrollment(slug);
}
}
}
this.#updating = false;
}
/**
* Triggered when a feature update happens on the prefFlips feature.
*
* Only one update can happen at a time.
*/
#onFeatureUpdate() {
if (this.#updating) {
return;
}
this.#updating = true;
this.#onFeatureUpdateImpl();
this.#updating = false;
}
/**
* Handle a feature update.
*
* N.B.: This can only be called when `#updating` is true to prevent recursive
* updates from triggering.
*/
#onFeatureUpdateImpl() {
const enrollments = lazy.NimbusFeatures[FEATURE_ID].getAllEnrollments();
const activeSlugs = new Set(enrollments.map(e => e.meta.slug));
const knownSlugs = new Set(this.#prefsBySlug.keys());
const inactiveSlugs = knownSlugs.difference(activeSlugs);
const newSlugs = activeSlugs.difference(knownSlugs);
for (const slug of inactiveSlugs) {
this.#removeEnrollment(slug);
// Remove the cached original values on the inactive enrollment.
const enrollment = this.manager.store.get(slug);
delete enrollment.prefFlips;
}
for (const slug of newSlugs) {
const enrollment = this.manager.store.get(slug);
this.#addEnrollment(enrollment);
}
if (inactiveSlugs.size || newSlugs.size) {
// If we've modified any enrollments in the store we must ensure that
// there is a save queued.
this.manager.store._store.saveSoon();
}
}
async _annotateEnrollment(enrollment) {
const { featureIds } = enrollment;
if (!featureIds.includes(FEATURE_ID)) {
return;
}
const prefs =
lazy.ExperimentManager.getFeatureConfigFromBranch(
enrollment.branch,
FEATURE_ID
).value.prefs ?? {};
const originalValues = await this.manager._handlePrefFlipsConflict(
enrollment.slug,
Object.entries(prefs).map(([pref, { branch }]) => [pref, branch])
);
for (const [pref, { branch }] of Object.entries(prefs)) {
if (this.#prefs.has(pref)) {
originalValues[pref] = this.#prefs.get(pref).originalValue;
} else {
originalValues[pref] = Object.hasOwn(originalValues, pref)
? originalValues[pref]
: lazy.PrefUtils.getPref(pref, { branch });
}
}
// Cache the original values one the enrollment so they can be restored upon
// unenrollment.
if (!Object.hasOwn(enrollment, "prefFlips")) {
enrollment.prefFlips = {};
}
enrollment.prefFlips.originalValues = originalValues;
}
/**
* Start tracking an enrollment.
*
* This will register prefs for the enrollment. If we have already registered
* any of the prefs for this enrollment, the values and branches must match or
* this enrollment will be unenrolled.
*
* NB: The enrollment must have already been annotated by a call to
* {@link _annotateEnrollment}, which occurrs in `ExperimentManager.enroll()`.
*
* @param {object} enrollment
* The enrollment we are tracking.
*/
#addEnrollment(enrollment) {
const { slug } = enrollment;
const prefs =
lazy.ExperimentManager.getFeatureConfigFromBranch(
enrollment.branch,
FEATURE_ID
).value.prefs ?? {};
const originalValues = enrollment.prefFlips.originalValues;
const prefsBySlug = new Set();
this.#prefsBySlug.set(slug, prefsBySlug);
for (const [pref, { branch, value }] of Object.entries(prefs)) {
try {
if (this.#prefs.has(pref)) {
this.#registerExistingPref(slug, pref, branch, value);
} else {
this.#registerNewPref(
slug,
pref,
branch,
value,
originalValues[pref]
);
}
prefsBySlug.add(pref);
} catch (e) {
this.#unenrollForFailure(enrollment, pref);
return;
}
}
}
/**
* Start tracking an enrollment at startup.
*
* If we fail to set a pref, this will trigger an unenrollment for the
* feature.
*
* N.B.: This can only be called when `#updating` is true to prevent recursive
* updates from triggering.
*
* @param {object} enrollment
* The enrollment we are restoring.
*
* @param {Record<string, PrefConfig>}
* The prefs that this enrollment will set.
*/
#restoreEnrollment(enrollment, prefs) {
const { slug } = enrollment;
const prefsBySlug = new Set();
this.#prefsBySlug.set(slug, prefsBySlug);
for (const [pref, { branch, value }] of Object.entries(prefs)) {
try {
if (this.#prefs.has(pref)) {
this.#registerExistingPref(slug, pref, branch, value);
} else {
const originalValue = enrollment.prefFlips.originalValues[pref];
this.#registerNewPref(slug, pref, branch, value, originalValue);
}
prefsBySlug.add(pref);
} catch (e) {
console.error(
`Failed to restore enrollment ${enrollment.slug} because of ${pref}:`,
e
);
this.#unenrollForFailure(enrollment, pref);
return;
}
}
}
/**
* Stop tracking the enrollment for the given slug.
*
* This will stop unregister all the prefs for this slug. If no enrollments
* are tracking a pref, it will be reset to its original value.
*
* @param {string} slug
* The slug for the enrollment we are no longer tracking.
*/
#removeEnrollment(slug) {
const prefs = this.#prefsBySlug.get(slug);
if (!prefs) {
return;
}
this.#prefsBySlug.delete(slug);
for (const pref of prefs) {
const entry = this.#prefs.get(pref);
entry.slugs.delete(slug);
if (entry.slugs.size == 0) {
Services.prefs.removeObserver(pref, entry.observer);
this.#prefs.delete(pref);
try {
lazy.PrefUtils.setPref(pref, entry.originalValue, {
branch: entry.branch,
});
} catch (e) {
console.error(`Failed to restore pref ${pref}:`, e);
}
}
}
}
/**
* Register a new pref for the enrollment with the given slug.
*
* @param {string} slug
* The slug of the enrollment.
*
* @param {string} pref
* The pref that we are setting.
*
* @param {PrefBranch} branch
* The branch the pref will be set on.
*
* @param {PrefValue} value
* The value we will set the pref to.
*
* @param {PrefValue} originalValue
* The original value of the pref.
*
* This is always required because we cannot determine the original
* value correctly if we have previously unenrolled a setPref
* experiment for this pref on the default branch.
*
* @throws {PrefFlipsFailedError}
* If we fail to set the pref. This should cause the caller to
* unenroll from the enrollment.
*/
#registerNewPref(slug, pref, branch, value, originalValue) {
/** @type nsIPrefObserver */
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);
}
};
/** @type PrefEntry */
const entry = {
branch,
originalValue,
value: value ?? null,
observer,
slugs: new Set([slug]),
};
try {
lazy.PrefUtils.setPref(pref, value ?? null, { branch });
} catch (e) {
throw new PrefFlipsFailedError(pref, value);
}
Services.prefs.addObserver(pref, entry.observer);
this.#prefs.set(pref, entry);
}
/**
* Register an existing pref for a new enrollment.
*
* @param {string} slug
* The slug for the new enrollment.
*
* @param {string} pref
* The pref that we are setting.
*
* @param {PrefBranch} branch
* The branch the pref will be set on.
*
* @param {PrefValue} value
* The value we will set the pref to.
*
* @throws {PrefFlipsFailedError}
* If either the pref branch or pref value do not match the existing
* registration. This should cause the caller to unenroll from the
* enrollment.
*/
#registerExistingPref(slug, pref, branch, value) {
const entry = this.#prefs.get(pref);
if (entry.branch !== branch || entry.value !== value) {
throw new PrefFlipsFailedError(pref, value);
}
entry.slugs.add(slug);
}
/**
* Triggered when a pref unexpectedly changes.
*
* @param {string} pref
* The pref that changed.
*/
#onPrefChanged(pref) {
if (this.#updating) {
return;
}
if (this.manager._prefs.get(pref)?.enrollmentChanging) {
return;
}
this.#updating = true;
this.#onPrefChangedImpl(pref);
this.#updating = false;
}
/**
* Handle an unexpected preference change.
*
* N.B.: This can only be called when `#updating` is true to prevent updates
* from triggering.
*
* @param {string} pref
* The pref that changed.
*/
#onPrefChangedImpl(pref) {
const entry = this.#prefs.get(pref);
if (!entry) {
return;
}
// The pref was changed by something outside our control, so before we start
// rolling back enrollments we need to remove it from all our internal
// state, otherwise we will end up overwriting the change that just happened.
this.#prefs.delete(pref);
Services.prefs.removeObserver(pref, entry.observer);
// Compute how the pref changed so we can report it in telemetry.
const cause = lazy.UnenrollmentCause.ChangedPref({
name: pref,
branch: PrefFlipsFeature.determinePrefChangeBranch(
pref,
entry.branch,
entry.value
),
});
// Now we can trigger unenrollment of these slugs. Every enrollment settings
// this pref has to stop tracking it.
for (const slug of entry.slugs) {
this.#prefsBySlug.get(slug).delete(pref);
// TODO(bug 1956082): This is an async method that we are not awaiting.
//
// This function is only ever called inside a nsIPrefObserver callback,
// which are invoked without `await`. Awaiting here breaks tests in
// test_prefFlips.js, which assert about the values of prefs *after* we
// trigger unenrollment.
//
// There is no good way to synchronize this behaviour yet to satisfy tests and
// the only thing that is being deferred are the database writes, which we
// and our caller don't care about.
this.manager.unenroll(slug, cause);
}
// Because we've unenrolled first, we can trigger a regular feature update
// to update our state.
this.#onFeatureUpdateImpl();
}
/**
* Unenroll the given enrollment due to a failure to set the given pref.
*
* N.B.: This must only be called when `#updating` is true to prevent
* recursive updates from triggering.
*
* @param {object} enrollment
* The enrollment that we are unenrolling.
*
* @param {string} pref
* The name of the pref that we failed to set.
*
* @returns {string}
* The type of pref, or "invalid" if there is no value for the pref.
*/
#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,
lazy.UnenrollmentCause.PrefFlipsFailed(pref, prefType)
);
// This function is only called during an update, so we have to remove the
// enrollment ourselves instead of relying on the feature update callback.
this.#removeEnrollment(enrollment.slug);
}
/**
* Determine on what branch did a pref change happen.
*
* @param {string} pref
* The name of the pref.
*
* @param {string} expectedBranch
* The branch we were setting the pref on.
*
* @param {PrefValue} expectedValue
* The value we were setting for the pref on `expectedBranch`.
*
* @returns {PrefBranch}
* The branch the pref change occurred on.
*/
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";
}
}
/**
* Thrown when the prefFlips feature fails to set a pref.
*
* @property {string} pref
* The pref that triggered this exception.
*/
class PrefFlipsFailedError extends Error {
/**
* Construct a new PrefFlipsFailedError.
*
* @param {string} pref
* The pref we failed to set.
*
* @param {PrefValue} value
* The value we failed to set the pref to.
*/
constructor(pref, value) {
super(`The Nimbus prefFlips feature failed to set ${pref}=${value}`);
this.pref = pref;
}
}