This commit is contained in:
snjågloe 2026-01-05 12:38:24 +01:00 committed by GitHub
commit 39b5b46a8d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 341 additions and 1 deletions

View File

@ -293,6 +293,9 @@
|<<prompt.radius,prompt.radius>>|Rounding radius (in pixels) for the edges of prompts.
|<<qt.args,qt.args>>|Additional arguments to pass to Qt, without leading `--`.
|<<qt.chromium.experimental_web_platform_features,qt.chromium.experimental_web_platform_features>>|Enables Web Platform features that are in development.
|<<qt.chromium.lifecycle_state.discard_delay,qt.chromium.lifecycle_state.discard_delay>>|The amount of time (in milliseconds) to wait before transitioning a page to the discarded lifecycle state. This state is an extreme resource-saving state, where the browsing context of the web view is discarded and the renderer process is shut down. Resource usage is therefore reduced to near-zero. The web page is automatically reloaded when needed.
|<<qt.chromium.lifecycle_state.enabled,qt.chromium.lifecycle_state.enabled>>|Use recommended page lifecycle state.
|<<qt.chromium.lifecycle_state.freeze_delay,qt.chromium.lifecycle_state.freeze_delay>>|The amount of time (in milliseconds) to wait before transitioning a page to the frozen lifecycle state. This is a low-CPU state, where most DOM event processing, JavaScript execution, and other tasks are suspended.
|<<qt.chromium.low_end_device_mode,qt.chromium.low_end_device_mode>>|When to use Chromium's low-end device mode.
|<<qt.chromium.process_model,qt.chromium.process_model>>|Which Chromium process model to use.
|<<qt.chromium.sandboxing,qt.chromium.sandboxing>>|What sandboxing mechanisms in Chromium to use.
@ -3845,6 +3848,42 @@ Valid values:
Default: +pass:[auto]+
[[qt.chromium.lifecycle_state.discard_delay]]
=== qt.chromium.lifecycle_state.discard_delay
The amount of time (in milliseconds) to wait before transitioning a page to the discarded lifecycle state. This state is an extreme resource-saving state, where the browsing context of the web view is discarded and the renderer process is shut down. Resource usage is therefore reduced to near-zero. The web page is automatically reloaded when needed.
Set to -1 to disable this state.
This setting is only available with the QtWebEngine backend.
Type: <<types,Int>>
Default: +pass:[-1]+
[[qt.chromium.lifecycle_state.enabled]]
=== qt.chromium.lifecycle_state.enabled
Use recommended page lifecycle state.
This puts webpages into one of three lifecycle states: active, frozen, or discarded. Using the recommended lifecycle state lets the browser use less resources by freezing or discarding web views when it's safe to do so.
This results in significant battery life savings.
Ongoing page activity is taken into account when determining the recommended lifecycle state, as to not disrupt your browsing.
This feature is only available on QtWebEngine 6.5+. On older versions this setting is ignored.
See the Qt documentation for more details: https://doc.qt.io/qt-6/qtwebengine-features.html#page-lifecycle-api
This setting is only available with the QtWebEngine backend.
Type: <<types,Bool>>
Default: +pass:[true]+
[[qt.chromium.lifecycle_state.freeze_delay]]
=== qt.chromium.lifecycle_state.freeze_delay
The amount of time (in milliseconds) to wait before transitioning a page to the frozen lifecycle state. This is a low-CPU state, where most DOM event processing, JavaScript execution, and other tasks are suspended.
This setting is only available with the QtWebEngine backend.
Type: <<types,Int>>
Default: +pass:[30000]+
[[qt.chromium.low_end_device_mode]]
=== qt.chromium.low_end_device_mode
When to use Chromium's low-end device mode.

View File

@ -1316,6 +1316,14 @@ class WebEngineTab(browsertab.AbstractTab):
self._child_event_filter = None
self._saved_zoom = None
self._scripts.init()
self._lifecycle_timer_freeze = usertypes.Timer(self)
self._lifecycle_timer_freeze.setSingleShot(True)
self._lifecycle_timer_freeze.timeout.connect(functools.partial(self._set_lifecycle_state, QWebEnginePage.LifecycleState.Frozen))
self._lifecycle_timer_discard = usertypes.Timer(self)
self._lifecycle_timer_discard.setSingleShot(True)
self._lifecycle_timer_discard.timeout.connect(functools.partial(self._set_lifecycle_state, QWebEnginePage.LifecycleState.Discarded))
# WORKAROUND for https://bugreports.qt.io/browse/QTBUG-65223
self._needs_qtbug65223_workaround = (
version.qtwebengine_versions().webengine < utils.VersionNumber(5, 15, 5))
@ -1424,6 +1432,14 @@ class WebEngineTab(browsertab.AbstractTab):
# percent encoded content is 2 megabytes minus 30 bytes.
self._widget.setHtml(html, base_url)
def _set_lifecycle_state(
self,
new_state: QWebEnginePage.LifecycleState,
) -> None:
"""Set the lifecycle state of the current tab."""
log.webview.debug(f"Setting page lifecycle state of {self} to {new_state}")
self._widget.page().setLifecycleState(new_state)
def _show_error_page(self, url, error):
"""Show an error page in the tab."""
log.misc.debug("Showing error page for {}".format(error))
@ -1724,6 +1740,62 @@ class WebEngineTab(browsertab.AbstractTab):
else:
selection.selectNone()
def _schedule_lifecycle_transition(
self,
state: Optional[QWebEnginePage.LifecycleState] = None,
) -> None:
"""Schedule, or cancel, a page lifecycle transition.
Schedule a lifecycle transition to `state`, according to the user's
config.
If a transition into `state` is already schedule, do nothing.
If `state` is `None`, cancel any scheduled transition.
"""
timers = {
QWebEnginePage.LifecycleState.Frozen: (
self._lifecycle_timer_freeze,
config.val.qt.chromium.lifecycle_state.freeze_delay,
),
QWebEnginePage.LifecycleState.Discarded: (
self._lifecycle_timer_discard,
config.val.qt.chromium.lifecycle_state.discard_delay,
),
}
to_start = delay = None
if state is not None:
try:
to_start, delay = timers[state]
except KeyError:
raise utils.Unreachable(state)
for timer, _ in timers.values():
if timer != to_start:
timer.stop()
if to_start and not to_start.isActive() and delay != -1:
log.webview.debug(f"Scheduling recommended lifecycle change {delay=} {state=} tab={self}")
to_start.start(delay)
@pyqtSlot(QWebEnginePage.LifecycleState)
def _on_recommended_state_changed(
self,
recommended_state: QWebEnginePage.LifecycleState,
) -> None:
if self._widget.page().lifecycleState() == recommended_state:
self._schedule_lifecycle_transition(None)
return
disabled = not config.val.qt.chromium.lifecycle_state.enabled
if recommended_state == QWebEnginePage.LifecycleState.Active:
self._schedule_lifecycle_transition(None)
self._set_lifecycle_state(recommended_state)
elif disabled or self.data.pinned:
self._schedule_lifecycle_transition(None)
else:
self._schedule_lifecycle_transition(recommended_state)
def _connect_signals(self):
view = self._widget
page = view.page()
@ -1742,6 +1814,9 @@ class WebEngineTab(browsertab.AbstractTab):
page.printRequested.connect(self._on_print_requested)
page.selectClientCertificate.connect(self._on_select_client_certificate)
if version.qtwebengine_versions().webengine >= utils.VersionNumber(6, 5):
page.recommendedStateChanged.connect(self._on_recommended_state_changed)
view.titleChanged.connect(self.title_changed)
view.urlChanged.connect(self._on_url_changed)
view.renderProcessTerminated.connect(

View File

@ -345,6 +345,59 @@ qt.chromium.experimental_web_platform_features:
Chromium. By default, this is enabled with Qt 5 to maximize compatibility
despite an aging Chromium base.
qt.chromium.lifecycle_state.enabled:
type: Bool
default: true
backend: QtWebEngine
# yamllint disable rule:line-length
desc: >-
Use recommended page lifecycle state.
This puts webpages into one of three lifecycle states: active, frozen, or
discarded. Using the recommended lifecycle state lets the browser use less
resources by freezing or discarding web views when it's safe to do so.
This results in significant battery life savings.
Ongoing page activity is taken into account when determining the
recommended lifecycle state, as to not disrupt your browsing.
This feature is only available on QtWebEngine 6.5+. On older versions
this setting is ignored.
See the Qt documentation for more details: https://doc.qt.io/qt-6/qtwebengine-features.html#page-lifecycle-api
# yamllint enable rule:line-length
qt.chromium.lifecycle_state.freeze_delay:
type:
name: Int
minval: 0
maxval: maxint
default: 30_000
backend: QtWebEngine
desc: >-
The amount of time (in milliseconds) to wait before transitioning a page to
the frozen lifecycle state.
This is a low-CPU state, where most DOM event processing, JavaScript
execution, and other tasks are suspended.
qt.chromium.lifecycle_state.discard_delay:
type:
name: Int
minval: -1
maxval: maxint
default: -1
backend: QtWebEngine
desc: >-
The amount of time (in milliseconds) to wait before transitioning a page to
the discarded lifecycle state.
This state is an extreme resource-saving state, where the browsing context
of the web view is discarded and the renderer process is shut down.
Resource usage is therefore reduced to near-zero. The web page is
automatically reloaded when needed.
Set to -1 to disable this state.
qt.highdpi:
type: Bool
default: false

View File

@ -1989,3 +1989,14 @@ Feature: Tab management
And I open data/numbers/2.txt in a new tab
And I run :tab-prev
Then "Entering mode KeyMode.insert (reason: mode_override)" should be logged
@qt>=6.5
Scenario: Lifecycle change on tab switch
When I set qt.chromium.lifecycle_state.enabled to true
And I set qt.chromium.lifecycle_state.freeze_delay to 0
And I set qt.chromium.lifecycle_state.discard_delay to 0
And I open about:blank?1
And I open about:blank?2 in a new tab
And I run :tab-prev
Then "Setting page lifecycle state of <qutebrowser.browser.webengine.webenginetab.WebEngineTab tab_id=* url='about:blank?2'> to LifecycleState.Frozen" should be logged
And "Setting page lifecycle state of <qutebrowser.browser.webengine.webenginetab.WebEngineTab tab_id=* url='about:blank?2'> to LifecycleState.Discarded" should be logged

View File

@ -14,12 +14,14 @@ QWebEngineScriptCollection = QtWebEngineCore.QWebEngineScriptCollection
QWebEngineScript = QtWebEngineCore.QWebEngineScript
from qutebrowser.browser import greasemonkey
from qutebrowser.utils import usertypes
from qutebrowser.utils import usertypes, utils, version
webenginetab = pytest.importorskip(
"qutebrowser.browser.webengine.webenginetab")
pytestmark = pytest.mark.usefixtures('greasemonkey_manager')
versions = version.qtwebengine_versions(avoid_init=True)
class ScriptsHelper:
@ -244,3 +246,163 @@ class TestWebEnginePermissions:
pytest.skip("enum member not available")
assert clipboard in permissions_cls._options
assert clipboard in permissions_cls._messages
class TestPageLifecycle:
@pytest.fixture(autouse=True)
def check_version(self):
# While the lifecycle feature was introduced in 5.14, PyQt seems to
# have trouble connecting to the signal we require on 6.4 and prior.
# https://github.com/qutebrowser/qutebrowser/pull/8547#issuecomment-2890997662
if versions.webengine < utils.VersionNumber(6, 5):
pytest.skip("Lifecycle feature requires Webengine 6.5+")
@pytest.fixture
def set_state_mock(
self,
webengine_tab: webenginetab.WebEngineTab,
monkeypatch,
mocker,
):
set_state_mock = mocker.Mock()
monkeypatch.setattr(
webengine_tab._widget.page(),
"setLifecycleState",
set_state_mock,
)
return set_state_mock
@pytest.fixture(autouse=True)
def set_config_defaults(
self,
config_stub,
set_state_mock,
):
self.set_config(config_stub)
def set_config(
self,
config_stub,
freeze_delay=0,
discard_delay=0,
enabled=True,
):
config_stub.val.qt.chromium.lifecycle_state.freeze_delay = freeze_delay
config_stub.val.qt.chromium.lifecycle_state.discard_delay = discard_delay
config_stub.val.qt.chromium.lifecycle_state.enabled = enabled
def timer_for(self, tab, state): # pylint: disable=inconsistent-return-statements
if state == QWebEnginePage.LifecycleState.Frozen:
return tab._lifecycle_timer_freeze
elif state == QWebEnginePage.LifecycleState.Discarded:
return tab._lifecycle_timer_discard
else:
pytest.fail(f"Unknown lifecycle state `{state}`")
def test_qt_method_is_called(
self,
webengine_tab: webenginetab.WebEngineTab,
set_state_mock,
qtbot,
):
"""Basic test to show that we call QT after going through our code."""
state = QWebEnginePage.LifecycleState.Discarded
webengine_tab._on_recommended_state_changed(state)
with qtbot.wait_signal(self.timer_for(webengine_tab, state).timeout):
pass
set_state_mock.assert_called_once_with(QWebEnginePage.LifecycleState.Discarded)
@pytest.mark.parametrize(
"new_state, freeze_delay, discard_delay",
[
(QWebEnginePage.LifecycleState.Discarded, 2000, 10,),
(QWebEnginePage.LifecycleState.Frozen, 10, 2000,),
]
)
def test_per_state_delay(
self,
webengine_tab: webenginetab.WebEngineTab,
monkeypatch,
mocker,
set_state_mock,
config_stub,
qtbot,
new_state,
freeze_delay,
discard_delay,
):
"""Show that a different time delay can get set for each state."""
self.set_config(
config_stub,
freeze_delay=freeze_delay,
discard_delay=discard_delay,
)
webengine_tab._on_recommended_state_changed(new_state)
timer = self.timer_for(webengine_tab, new_state)
assert timer.remainingTime() == (
freeze_delay
if new_state == QWebEnginePage.LifecycleState.Frozen
else discard_delay
)
with qtbot.wait_signal(timer.timeout, timeout=100):
pass
set_state_mock.assert_called_once_with(new_state)
def test_state_disabled(
self,
webengine_tab: webenginetab.WebEngineTab,
monkeypatch,
config_stub,
):
"""For negative delay values, the timer shouldn't be scheduled."""
self.set_config(
config_stub,
discard_delay=-1,
)
state = QWebEnginePage.LifecycleState.Discarded
webengine_tab._on_recommended_state_changed(state)
timer = self.timer_for(webengine_tab, state)
assert not timer.isActive()
def test_pinned_tabs_untouched(
self,
webengine_tab: webenginetab.WebEngineTab,
monkeypatch,
config_stub,
):
"""Don't change lifecycle state for a pinned tab."""
webengine_tab.set_pinned(True)
state = QWebEnginePage.LifecycleState.Frozen
webengine_tab._on_recommended_state_changed(state)
timer = self.timer_for(webengine_tab, state)
assert not timer.isActive()
def test_timer_interrupted(
self,
webengine_tab: webenginetab.WebEngineTab,
set_state_mock,
config_stub,
qtbot,
):
"""Pending time should be cancelled when a new signal comes in."""
self.set_config(
config_stub,
freeze_delay=1,
discard_delay=3,
)
freeze_timer = webengine_tab._lifecycle_timer_freeze
discard_timer = webengine_tab._lifecycle_timer_discard
webengine_tab._on_recommended_state_changed(QWebEnginePage.LifecycleState.Frozen)
assert freeze_timer.remainingTime() == 1
webengine_tab._on_recommended_state_changed(QWebEnginePage.LifecycleState.Discarded)
assert discard_timer.remainingTime() == 3
with qtbot.wait_signal(discard_timer.timeout):
pass
set_state_mock.assert_called_once_with(QWebEnginePage.LifecycleState.Discarded)