blob: cf0b933e3d48a88d735bd8cbef8083f09916854c [file] [log] [blame]
// Copyright 2025 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "chrome/browser/performance_manager/policies/discard_eligibility_policy.h"
#include "chrome/common/chrome_features.h"
#include "components/performance_manager/graph/page_node_impl.h"
#include "components/performance_manager/public/decorators/page_live_state_decorator.h"
#include "components/performance_manager/public/graph/node_data_describer_registry.h"
#include "components/url_matcher/url_matcher.h"
#include "components/url_matcher/url_util.h"
#include "content/public/browser/web_contents.h"
#if BUILDFLAG(ENABLE_GLIC)
#include "chrome/browser/glic/public/glic_keyed_service.h"
#include "chrome/browser/glic/public/glic_keyed_service_factory.h"
#include "components/tabs/public/tab_interface.h"
#endif
namespace performance_manager::policies {
namespace {
BASE_FEATURE(kIgnoreDiscardAttemptMarker, base::FEATURE_DISABLED_BY_DEFAULT);
// Not intended for launch.
// This feature can be used during testing to ensure realistic priority
// ordering of tabs even when devtools is connected.
BASE_FEATURE(kAllowDevtoolsConnectedDiscard, base::FEATURE_DISABLED_BY_DEFAULT);
// NodeAttachedData used to indicate that there's already been an attempt to
// discard a PageNode.
class DiscardAttemptMarker
: public ExternalNodeAttachedDataImpl<DiscardAttemptMarker> {
public:
explicit DiscardAttemptMarker(const PageNodeImpl* page_node) {}
~DiscardAttemptMarker() override = default;
};
const char kDescriberName[] = "DiscardEligibilityPolicy";
const PageLiveStateDecorator::Data* GetPageNodeLiveStateData(
const PageNode* page_node) {
return PageLiveStateDecorator::Data::FromPageNode(page_node);
}
} // namespace
PageNodeSortProxy::PageNodeSortProxy(
base::WeakPtr<const PageNode> page_node,
CanDiscardResult can_discard_result,
bool is_visible,
bool is_focused,
base::TimeTicks last_visibility_change_time)
: page_node_(std::move(page_node)),
can_discard_result_(can_discard_result),
is_visible_(is_visible),
is_focused_(is_focused),
last_visibility_change_time_(last_visibility_change_time) {}
PageNodeSortProxy::PageNodeSortProxy(PageNodeSortProxy&&) = default;
PageNodeSortProxy& PageNodeSortProxy::operator=(PageNodeSortProxy&&) = default;
PageNodeSortProxy::~PageNodeSortProxy() = default;
DiscardEligibilityPolicy::DiscardEligibilityPolicy() = default;
DiscardEligibilityPolicy::~DiscardEligibilityPolicy() = default;
void DiscardEligibilityPolicy::SetNoDiscardPatternsForProfile(
const std::string& browser_context_id,
const std::vector<std::string>& patterns) {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
std::unique_ptr<url_matcher::URLMatcher>& entry =
profiles_no_discard_patterns_[browser_context_id];
entry = std::make_unique<url_matcher::URLMatcher>();
url_matcher::util::AddAllowFiltersWithLimit(entry.get(), patterns);
if (opt_out_policy_changed_callback_) {
opt_out_policy_changed_callback_.Run(browser_context_id);
}
}
void DiscardEligibilityPolicy::ClearNoDiscardPatternsForProfile(
const std::string& browser_context_id) {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
profiles_no_discard_patterns_.erase(browser_context_id);
if (opt_out_policy_changed_callback_) {
opt_out_policy_changed_callback_.Run(browser_context_id);
}
}
// static
void DiscardEligibilityPolicy::AddDiscardAttemptMarker(PageNode* page_node) {
DiscardAttemptMarker::GetOrCreate(PageNodeImpl::FromNode(page_node));
}
// static
void DiscardEligibilityPolicy::RemovesDiscardAttemptMarkerForTesting(
PageNode* page_node) {
DiscardAttemptMarker::Destroy(PageNodeImpl::FromNode(page_node));
}
void DiscardEligibilityPolicy::SetOptOutPolicyChangedCallback(
base::RepeatingCallback<void(std::string_view)> callback) {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
opt_out_policy_changed_callback_ = std::move(callback);
}
void DiscardEligibilityPolicy::OnPassedToGraph(Graph* graph) {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
graph->AddPageNodeObserver(this);
graph->GetNodeDataDescriberRegistry()->RegisterDescriber(this,
kDescriberName);
}
void DiscardEligibilityPolicy::OnTakenFromGraph(Graph* graph) {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
graph->GetNodeDataDescriberRegistry()->UnregisterDescriber(this);
graph->RemovePageNodeObserver(this);
}
// NOTE: This is used by ProcessRankPolicyAndroid. If you add a new condition to
// this, you need to add an observer callback to ProcessRankPolicyAndroid as
// well.
CanDiscardResult DiscardEligibilityPolicy::CanDiscard(
const PageNode* page_node,
DiscardReason discard_reason,
base::TimeDelta minimum_time_in_background,
std::vector<CannotDiscardReason>* cannot_discard_reasons) const {
if (always_discard_for_testing_) {
return CanDiscardResult::kEligible;
}
auto add_reason = [&](CannotDiscardReason reason) {
if (cannot_discard_reasons) {
cannot_discard_reasons->push_back(reason);
}
};
// Don't discard pages which aren't tabs.
if (page_node->GetType() != PageType::kTab) {
add_reason(CannotDiscardReason::kNotATab);
return CanDiscardResult::kDisallowed;
}
// Don't discard tabs for which discarding has already been attempted.
if (DiscardAttemptMarker::Get(PageNodeImpl::FromNode(page_node)) &&
!base::FeatureList::IsEnabled(kIgnoreDiscardAttemptMarker)) {
add_reason(CannotDiscardReason::kDiscardAttempted);
return CanDiscardResult::kDisallowed;
}
// Don't discard tabs that don't have a main frame (restored tab which is not
// loaded yet, discarded tab, crashed tab).
if (!page_node->GetMainFrameNode()) {
add_reason(CannotDiscardReason::kNoMainFrame);
return CanDiscardResult::kDisallowed;
}
const auto* live_state_data = GetPageNodeLiveStateData(page_node);
// Don't discard tabs that are already discarded, as that will fail.
if (live_state_data && live_state_data->IsDiscarded()) {
add_reason(CannotDiscardReason::kAlreadyDiscarded);
return CanDiscardResult::kDisallowed;
}
bool is_proactive_or_suggested;
switch (discard_reason) {
case DiscardReason::EXTERNAL:
case DiscardReason::FROZEN_WITH_GROWING_MEMORY:
// Always allow discards.
return CanDiscardResult::kEligible;
case DiscardReason::URGENT:
is_proactive_or_suggested = false;
break;
case DiscardReason::PROACTIVE:
case DiscardReason::SUGGESTED:
is_proactive_or_suggested = true;
break;
}
CanDiscardResult result = CanDiscardResult::kEligible;
auto add_reason_and_update_result = [&](CannotDiscardReason reason,
CanDiscardResult new_result) {
if (cannot_discard_reasons) {
cannot_discard_reasons->push_back(reason);
}
result = std::underlying_type_t<CanDiscardResult>(result) <
std::underlying_type_t<CanDiscardResult>(new_result)
? new_result
: result;
};
if (page_node->IsVisible()) {
add_reason_and_update_result(CannotDiscardReason::kVisible,
CanDiscardResult::kProtected);
} else if ((base::TimeTicks::Now() -
page_node->GetLastVisibilityChangeTime()) <
minimum_time_in_background) {
add_reason_and_update_result(CannotDiscardReason::kRecentlyVisible,
CanDiscardResult::kProtected);
}
// Don't discard tabs that are playing or have recently played audio.
if (page_node->IsAudible()) {
add_reason_and_update_result(CannotDiscardReason::kAudible,
CanDiscardResult::kProtected);
} else if (page_node->GetTimeSinceLastAudibleChange().value_or(
base::TimeDelta::Max()) < kTabAudioProtectionTime) {
add_reason_and_update_result(CannotDiscardReason::kRecentlyAudible,
CanDiscardResult::kProtected);
}
// Don't discard pages that are displaying content in picture-in-picture.
if (page_node->HasPictureInPicture()) {
add_reason_and_update_result(CannotDiscardReason::kPictureInPicture,
CanDiscardResult::kProtected);
}
// Do not discard PDFs as they might contain entry that is not saved and they
// don't remember their scrolling positions. See crbug.com/547286 and
// crbug.com/65244.
if (page_node->GetContentsMimeType() == "application/pdf") {
add_reason_and_update_result(CannotDiscardReason::kPdf,
CanDiscardResult::kProtected);
}
const GURL& main_frame_url = page_node->GetMainFrameUrl();
if (!main_frame_url.is_valid() || main_frame_url.is_empty()) {
add_reason_and_update_result(CannotDiscardReason::kInvalidURL,
CanDiscardResult::kProtected);
}
#if BUILDFLAG(ENABLE_GLIC)
// Do not discard pages that are pin-shared with Glic.
if (page_node->GetWebContents() && is_proactive_or_suggested) {
auto* tab_interface = tabs::TabInterface::MaybeGetFromContents(
page_node->GetWebContents().get());
if (tab_interface) {
auto* glic_service = glic::GlicKeyedServiceFactory::GetGlicKeyedService(
page_node->GetWebContents()->GetBrowserContext());
if (glic_service && glic_service->sharing_manager().IsTabPinned(
tab_interface->GetHandle())) {
add_reason_and_update_result(CannotDiscardReason::kGlicShared,
CanDiscardResult::kProtected);
}
}
}
#endif
// Only discard http(s) pages and internal pages to make sure that we don't
// discard extensions or other PageNode that don't correspond to a tab.
//
// TODO(crbug.com/40910297): Due to a state tracking bug, sometimes there are
// two frames marked "current". In that case GetMainFrameNode() returns an
// arbitrary one, which may not have the url set correctly. Therefore, use
// GetMainFrameUrl() for the url.
bool is_web_page_or_internal_or_data_page =
main_frame_url.SchemeIsHTTPOrHTTPS() ||
main_frame_url.SchemeIs("chrome") ||
main_frame_url.SchemeIs(url::kDataScheme);
if (!is_web_page_or_internal_or_data_page) {
add_reason_and_update_result(CannotDiscardReason::kNotWebOrInternal,
CanDiscardResult::kProtected);
}
// The enterprise policy to except pages from discarding applies to both
// proactive and urgent discards.
if (IsPageOptedOutOfDiscarding(page_node->GetBrowserContextID(),
main_frame_url)) {
add_reason_and_update_result(CannotDiscardReason::kOptedOut,
CanDiscardResult::kProtected);
}
if (is_proactive_or_suggested &&
page_node->GetNotificationPermissionStatus() ==
blink::mojom::PermissionStatus::GRANTED) {
add_reason_and_update_result(CannotDiscardReason::kNotificationsEnabled,
CanDiscardResult::kProtected);
}
// The live state data won't be available if none of these events ever
// happened on the page.
if (live_state_data) {
// Don't discard the page if an extension is protecting it from discards.
if (!live_state_data->IsAutoDiscardable()) {
add_reason_and_update_result(CannotDiscardReason::kExtensionProtected,
CanDiscardResult::kProtected);
}
if (live_state_data->IsCapturingVideo()) {
add_reason_and_update_result(CannotDiscardReason::kCapturingVideo,
CanDiscardResult::kProtected);
}
if (live_state_data->IsCapturingAudio()) {
add_reason_and_update_result(CannotDiscardReason::kCapturingAudio,
CanDiscardResult::kProtected);
}
if (live_state_data->IsBeingMirrored()) {
add_reason_and_update_result(CannotDiscardReason::kBeingMirrored,
CanDiscardResult::kProtected);
}
if (live_state_data->IsCapturingWindow()) {
add_reason_and_update_result(CannotDiscardReason::kCapturingWindow,
CanDiscardResult::kProtected);
}
if (live_state_data->IsCapturingDisplay()) {
add_reason_and_update_result(CannotDiscardReason::kCapturingDisplay,
CanDiscardResult::kProtected);
}
if (live_state_data->IsConnectedToBluetoothDevice()) {
add_reason_and_update_result(CannotDiscardReason::kConnectedToBluetooth,
CanDiscardResult::kProtected);
}
if (live_state_data->IsConnectedToUSBDevice()) {
add_reason_and_update_result(CannotDiscardReason::kConnectedToUSB,
CanDiscardResult::kProtected);
}
// Don't discard the active tab in any window, even if the window is not
// visible. Otherwise the user would see a blank page when the window
// becomes visible again, as the tab isn't reloaded until they click on it.
if (live_state_data->IsActiveTab()) {
add_reason_and_update_result(CannotDiscardReason::kActiveTab,
CanDiscardResult::kProtected);
}
// Pinning a tab is a strong signal the user wants to keep it.
if (live_state_data->IsPinnedTab()) {
add_reason_and_update_result(CannotDiscardReason::kPinnedTab,
CanDiscardResult::kProtected);
}
// Don't discard pages with devtools attached, because when it's restored
// the devtools window won't come back. The user may be monitoring the page
// in the background with devtools.
if (live_state_data->IsDevToolsOpen() &&
!base::FeatureList::IsEnabled(kAllowDevtoolsConnectedDiscard)) {
add_reason_and_update_result(CannotDiscardReason::kDevToolsOpen,
CanDiscardResult::kProtected);
}
if (is_proactive_or_suggested &&
live_state_data->UpdatedTitleOrFaviconInBackground()) {
add_reason_and_update_result(CannotDiscardReason::kBackgroundActivity,
CanDiscardResult::kProtected);
}
}
// `HadUserEdits()` is currently a superset of `HadFormInteraction()` but
// that may change so check both here (the check is not expensive).
if (page_node->HadFormInteraction()) {
add_reason_and_update_result(CannotDiscardReason::kFormInteractions,
CanDiscardResult::kProtected);
}
if (page_node->HadUserEdits()) {
add_reason_and_update_result(CannotDiscardReason::kUserEdits,
CanDiscardResult::kProtected);
}
return result;
}
bool DiscardEligibilityPolicy::IsPageOptedOutOfDiscarding(
const std::string& browser_context_id,
const GURL& url) const {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
auto it = profiles_no_discard_patterns_.find(browser_context_id);
if (it == profiles_no_discard_patterns_.end()) {
// There can be a narrow window between profile creation and when prefs are
// read, which is when `profiles_no_discard_patterns_` is populated. During
// that time assume that a page might be opted out of discarding.
return true;
}
return !it->second->MatchURL(url).empty();
}
void DiscardEligibilityPolicy::OnMainFrameDocumentChanged(
const PageNode* page_node) {
// When activated a discarded tab will re-navigate, instantiating a new
// document. Ensure the DiscardAttemptMarker is cleared in these cases to
// ensure a given tab remains eligible for discarding.
DiscardAttemptMarker::Destroy(PageNodeImpl::FromNode(page_node));
}
base::Value::Dict DiscardEligibilityPolicy::DescribePageNodeData(
const PageNode* node) const {
auto can_discard = [this, node](DiscardReason discard_reason) {
switch (this->CanDiscard(node, discard_reason, base::TimeDelta())) {
case CanDiscardResult::kEligible:
return "eligible";
case CanDiscardResult::kProtected:
return "protected";
case CanDiscardResult::kDisallowed:
return "disallowed";
}
};
base::Value::Dict ret;
ret.Set("can_urgently_discard", can_discard(DiscardReason::URGENT));
ret.Set("can_proactively_discard", can_discard(DiscardReason::PROACTIVE));
if (!node->GetMainFrameUrl().is_empty()) {
ret.Set("opted_out", IsPageOptedOutOfDiscarding(node->GetBrowserContextID(),
node->GetMainFrameUrl()));
}
return ret;
}
} // namespace performance_manager::policies