chromium/base/android/java/src/org/chromium/base/cached_flags/CachedFlagsSafeMode.java

// Copyright 2021 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.base.cached_flags;

import android.content.Context;
import android.content.SharedPreferences;

import androidx.annotation.AnyThread;
import androidx.annotation.IntDef;
import androidx.annotation.VisibleForTesting;

import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.ResettersForTesting;
import org.chromium.base.TraceEvent;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.task.AsyncTask;
import org.chromium.base.version_info.VersionInfo;
import org.chromium.build.BuildConfig;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.Map.Entry;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * Controls Safe Mode for {@link CachedFlag}.
 *
 * Safe Mode is a mechanism that allows Chrome to prevent crashes gated behind flags used before
 * native from becoming a crash loop that cannot be recovered from by disabling the experiment.
 */
public class CachedFlagsSafeMode {
    private static final String TAG = "Flags";
    private static final int CRASH_STREAK_TO_ENTER_SAFE_MODE = 2;

    private static final String SAFE_VALUES_FILE =
            "org.chromium.chrome.browser.flags.SafeModeValues";

    @VisibleForTesting
    static final String PREF_SAFE_VALUES_VERSION = "Chrome.Flags.SafeValuesVersion";

    private Boolean mSafeModeExperimentForcedForTesting;

    // These values are persisted to logs. Entries should not be renumbered and numeric values
    // should never be reused.
    @VisibleForTesting
    @Retention(RetentionPolicy.SOURCE)
    @IntDef({
        Behavior.UNKNOWN,
        Behavior.NOT_ENGAGED_BELOW_THRESHOLD,
        Behavior.ENGAGED_WITH_SAFE_VALUES,
        Behavior.ENGAGED_IGNORING_OUTDATED_SAFE_VALUES,
        Behavior.ENGAGED_WITHOUT_SAFE_VALUES
    })
    @interface Behavior {
        int UNKNOWN = 0;
        int NOT_ENGAGED_BELOW_THRESHOLD = 1;
        int ENGAGED_WITH_SAFE_VALUES = 2;
        int ENGAGED_IGNORING_OUTDATED_SAFE_VALUES = 3;
        int ENGAGED_WITHOUT_SAFE_VALUES = 4;

        int NUM_ENTRIES = 5;
    }

    private AtomicInteger mBehavior = new AtomicInteger(Behavior.UNKNOWN);

    private AtomicBoolean mStartCheckpointWritten = new AtomicBoolean(false);
    private AtomicBoolean mEndCheckpointWritten = new AtomicBoolean(false);

    private static final CachedFlagsSafeMode sInstance = new CachedFlagsSafeMode();

    public static CachedFlagsSafeMode getInstance() {
        return sInstance;
    }

    // Singleton
    private CachedFlagsSafeMode() {}

    /**
     * Call right before any flag is checked. The first time this is called, check if safe mode
     * should be engaged, and engages it if necessary.
     */
    @AnyThread
    void onFlagChecked() {
        synchronized (mBehavior) {
            if (mBehavior.get() != Behavior.UNKNOWN) return;
            if (shouldEnterSafeMode()) {
                String cachedVersion =
                        getSafeValuePreferences().getString(PREF_SAFE_VALUES_VERSION, "");
                int behavior;
                if (cachedVersion.isEmpty()) {
                    behavior = Behavior.ENGAGED_WITHOUT_SAFE_VALUES;
                } else if (!cachedVersion.equals(VersionInfo.getProductVersion())) {
                    behavior = Behavior.ENGAGED_IGNORING_OUTDATED_SAFE_VALUES;
                } else {
                    behavior = Behavior.ENGAGED_WITH_SAFE_VALUES;
                }
                mBehavior.set(behavior);
                RecordHistogram.recordEnumeratedHistogram(
                        "Variations.SafeModeCachedFlags.Engaged", behavior, Behavior.NUM_ENTRIES);
            } else {
                mBehavior.set(Behavior.NOT_ENGAGED_BELOW_THRESHOLD);
                RecordHistogram.recordEnumeratedHistogram(
                        "Variations.SafeModeCachedFlags.Engaged",
                        Behavior.NOT_ENGAGED_BELOW_THRESHOLD,
                        Behavior.NUM_ENTRIES);
            }
        }
    }

    /**
     * Call at an early point in the path that leads to caching flags. If onEndCheckpoint()
     * does not get called before the next run, this run will be considered a crash for purposes of
     * counting the crash streak and entering Safe Mode.
     */
    public void onStartOrResumeCheckpoint() {
        if (mEndCheckpointWritten.get()) {
            // Do not increment the streak if it was already reset.
            return;
        }
        if (mStartCheckpointWritten.getAndSet(true)) {
            // Limit to one increment per run.
            return;
        }

        CachedFlagsSharedPreferences.getInstance()
                .incrementInt(CachedFlagsSharedPreferences.FLAGS_CRASH_STREAK_BEFORE_CACHE);
        RecordHistogram.recordEnumeratedHistogram(
                "Variations.SafeModeCachedFlags.WillCache", mBehavior.get(), Behavior.NUM_ENTRIES);
    }

    /**
     * Call when aborting a path that leads to caching flags. Rolls back the crash streak
     * incremented in {@link #onStartOrResumeCheckpoint} but does not reset it.
     */
    public void onPauseCheckpoint() {
        if (mEndCheckpointWritten.get()) {
            // Do not change the streak if it was already reset.
            return;
        }
        if (!mStartCheckpointWritten.getAndSet(false)) {
            // Do not change the streak if it hasn't been incremented yet.
            return;
        }

        decreaseCrashStreak(1);
        RecordHistogram.recordEnumeratedHistogram(
                "Variations.SafeModeCachedFlags.Pause", mBehavior.get(), Behavior.NUM_ENTRIES);
    }

    private void decreaseCrashStreak(int decrement) {
        int currentStreak =
                CachedFlagsSharedPreferences.getInstance()
                        .readInt(CachedFlagsSharedPreferences.FLAGS_CRASH_STREAK_BEFORE_CACHE);
        assert currentStreak >= 0;

        int newStreak = currentStreak - decrement;
        if (newStreak < 0) newStreak = 0;
        CachedFlagsSharedPreferences.getInstance()
                .writeInt(CachedFlagsSharedPreferences.FLAGS_CRASH_STREAK_BEFORE_CACHE, newStreak);
    }

    /**
     * Call when all flags have been cached. Signals that the current configuration is safe. It will
     * be saved to be used in Safe Mode.
     */
    public void onEndCheckpoint() {
        if (mEndCheckpointWritten.getAndSet(true)) {
            // Limit to one reset per run.
            return;
        }

        if (isInSafeMode()) {
            CachedFlagsSharedPreferences.getInstance()
                    .writeInt(
                            CachedFlagsSharedPreferences.FLAGS_CRASH_STREAK_BEFORE_CACHE,
                            CRASH_STREAK_TO_ENTER_SAFE_MODE - 1);
        } else {
            decreaseCrashStreak(2);
        }

        new AsyncTask<Void>() {
            @Override
            protected Void doInBackground() {
                try {
                    writeSafeValues();
                } catch (Exception e) {
                    Log.e(TAG, "Exception writing safe values.", e);
                    cancel(true);
                }
                return null;
            }

            @Override
            protected void onPostExecute(Void unused) {
                RecordHistogram.recordEnumeratedHistogram(
                        "Variations.SafeModeCachedFlags.Cached",
                        mBehavior.get(),
                        Behavior.NUM_ENTRIES);
            }
        }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
    }

    private boolean isInSafeMode() {
        int behavior = mBehavior.get();
        return behavior == Behavior.ENGAGED_WITH_SAFE_VALUES
                || behavior == Behavior.ENGAGED_WITHOUT_SAFE_VALUES
                || behavior == Behavior.ENGAGED_IGNORING_OUTDATED_SAFE_VALUES;
    }

    private boolean shouldEnterSafeMode() {
        if (BuildConfig.IS_FOR_TEST
                && (mSafeModeExperimentForcedForTesting == null
                        || !mSafeModeExperimentForcedForTesting)) {
            return false;
        }

        int safeModeRunsLeft =
                CachedFlagsSharedPreferences.getInstance()
                        .readInt(CachedFlagsSharedPreferences.FLAGS_SAFE_MODE_RUNS_LEFT, 0);
        assert safeModeRunsLeft <= 2;

        if (safeModeRunsLeft > 0) {
            CachedFlagsSharedPreferences.getInstance()
                    .writeInt(
                            CachedFlagsSharedPreferences.FLAGS_SAFE_MODE_RUNS_LEFT,
                            safeModeRunsLeft - 1);

            Log.e(TAG, "Enter Safe Mode for CachedFlags, %d runs left.", safeModeRunsLeft);
            return true;
        }

        int crashStreak =
                CachedFlagsSharedPreferences.getInstance()
                        .readInt(CachedFlagsSharedPreferences.FLAGS_CRASH_STREAK_BEFORE_CACHE, 0);
        RecordHistogram.recordExactLinearHistogram(
                "Variations.SafeModeCachedFlags.Streak.Crashes", crashStreak, 50);

        if (crashStreak >= CRASH_STREAK_TO_ENTER_SAFE_MODE) {
            // Run safe mode twice. This run will enter Safe Mode by returning true here. The next
            // run will enter Safe Mode by looking at the FLAGS_SAFE_MODE_RUNS_LEFT SharedPref.
            CachedFlagsSharedPreferences.getInstance()
                    .writeInt(CachedFlagsSharedPreferences.FLAGS_SAFE_MODE_RUNS_LEFT, 1);

            Log.e(TAG, "Enter Safe Mode for CachedFlags, crash streak is %d.", crashStreak);
            return true;
        } else {
            return false;
        }
    }

    @VisibleForTesting
    static SharedPreferences getSafeValuePreferences() {
        return ContextUtils.getApplicationContext()
                .getSharedPreferences(SAFE_VALUES_FILE, Context.MODE_PRIVATE);
    }

    private void writeSafeValues() {
        TraceEvent.begin("writeSafeValues");
        SharedPreferences.Editor editor = getSafeValuePreferences().edit();

        // Remove values from other versions, since leftover values from previous version are not
        // safe for the current one. Most will get overwritten, but there is no guarantee that all
        // will.
        editor.clear();

        synchronized (ValuesReturned.sBoolValues) {
            for (Entry<String, Boolean> pair : ValuesReturned.sBoolValues.entrySet()) {
                editor.putBoolean(pair.getKey(), pair.getValue());
            }
        }
        synchronized (ValuesReturned.sIntValues) {
            for (Entry<String, Integer> pair : ValuesReturned.sIntValues.entrySet()) {
                editor.putInt(pair.getKey(), pair.getValue());
            }
        }
        synchronized (ValuesReturned.sDoubleValues) {
            for (Entry<String, Double> pair : ValuesReturned.sDoubleValues.entrySet()) {
                long ieee754LongValue = Double.doubleToRawLongBits(pair.getValue());
                editor.putLong(pair.getKey(), ieee754LongValue);
            }
        }
        synchronized (ValuesReturned.sStringValues) {
            for (Entry<String, String> pair : ValuesReturned.sStringValues.entrySet()) {
                editor.putString(pair.getKey(), pair.getValue());
            }
        }
        editor.putString(PREF_SAFE_VALUES_VERSION, VersionInfo.getProductVersion());
        editor.apply();
        TraceEvent.end("writeSafeValues");
    }

    Boolean isEnabled(String featureName, String preferenceName, boolean defaultValue) {
        switch (mBehavior.get()) {
            case Behavior.NOT_ENGAGED_BELOW_THRESHOLD:
                return null;
            case Behavior.ENGAGED_WITH_SAFE_VALUES:
                SharedPreferences prefs = getSafeValuePreferences();
                if (!prefs.contains(preferenceName)) {
                    return null;
                }
                return prefs.getBoolean(preferenceName, false);
            case Behavior.ENGAGED_IGNORING_OUTDATED_SAFE_VALUES:
            case Behavior.ENGAGED_WITHOUT_SAFE_VALUES:
                return defaultValue;
            default:
                assert false;
                return null;
        }
    }

    Boolean getBooleanFieldTrialParam(String preferenceName, boolean defaultValue) {
        switch (mBehavior.get()) {
            case Behavior.NOT_ENGAGED_BELOW_THRESHOLD:
                return null;
            case Behavior.ENGAGED_WITH_SAFE_VALUES:
                SharedPreferences prefs = getSafeValuePreferences();
                if (!prefs.contains(preferenceName)) {
                    return null;
                }
                return prefs.getBoolean(preferenceName, false);
            case Behavior.ENGAGED_IGNORING_OUTDATED_SAFE_VALUES:
            case Behavior.ENGAGED_WITHOUT_SAFE_VALUES:
                return defaultValue;
            default:
                assert false;
                return null;
        }
    }

    Integer getIntFieldTrialParam(String preferenceName, int defaultValue) {
        switch (mBehavior.get()) {
            case Behavior.NOT_ENGAGED_BELOW_THRESHOLD:
                return null;
            case Behavior.ENGAGED_WITH_SAFE_VALUES:
                SharedPreferences prefs = getSafeValuePreferences();
                if (!prefs.contains(preferenceName)) {
                    return null;
                }
                return prefs.getInt(preferenceName, 0);
            case Behavior.ENGAGED_IGNORING_OUTDATED_SAFE_VALUES:
            case Behavior.ENGAGED_WITHOUT_SAFE_VALUES:
                return defaultValue;
            default:
                assert false;
                return null;
        }
    }

    Double getDoubleFieldTrialParam(String preferenceName, double defaultValue) {
        switch (mBehavior.get()) {
            case Behavior.NOT_ENGAGED_BELOW_THRESHOLD:
                return null;
            case Behavior.ENGAGED_WITH_SAFE_VALUES:
                SharedPreferences prefs = getSafeValuePreferences();
                if (!prefs.contains(preferenceName)) {
                    return null;
                }
                long ieee754LongValue = prefs.getLong(preferenceName, 0L);
                return Double.longBitsToDouble(ieee754LongValue);
            case Behavior.ENGAGED_IGNORING_OUTDATED_SAFE_VALUES:
            case Behavior.ENGAGED_WITHOUT_SAFE_VALUES:
                return defaultValue;
            default:
                assert false;
                return null;
        }
    }

    String getStringFieldTrialParam(String preferenceName, String defaultValue) {
        switch (mBehavior.get()) {
            case Behavior.NOT_ENGAGED_BELOW_THRESHOLD:
                return null;
            case Behavior.ENGAGED_WITH_SAFE_VALUES:
                SharedPreferences prefs = getSafeValuePreferences();
                if (!prefs.contains(preferenceName)) {
                    return null;
                }
                return prefs.getString(preferenceName, null);
            case Behavior.ENGAGED_IGNORING_OUTDATED_SAFE_VALUES:
            case Behavior.ENGAGED_WITHOUT_SAFE_VALUES:
                return defaultValue;
            default:
                assert false;
                return null;
        }
    }

    @Behavior
    int getBehaviorForTesting() {
        return mBehavior.get();
    }

    void clearMemoryForTesting() {
        mBehavior.set(Behavior.UNKNOWN);
        mStartCheckpointWritten.set(false);
        mEndCheckpointWritten.set(false);
    }

    void enableForTesting() {
        mSafeModeExperimentForcedForTesting = true;
        ResettersForTesting.register(() -> mSafeModeExperimentForcedForTesting = null);
    }
}