zapret-kvn/xray_fluent/ui/window_presence.py
loop-uh 5965af36c5
Some checks failed
Windows project source guards / test (push) Failing after 26s
Возврат окна из трея: хватка памяти растёт вместе с программой, страницы строятся заранее; в журнал — причины для зеркал
Сегодняшние правки Zapret 2 GUI по журналу пользователя перенесены в KVN. 1) Пока окно скрыто, раз в минуту проверяется рабочий набор, и при росте от 16 МБ хватка и снимок страниц берутся заново (запуск в трей брал их на пятой секунде, пока программа ещё не закончила стартовые работы). 2) Страницы окна (DeferredPage) строились только при первом открытии, то есть под щелчком; ui/page_warmup.py собирает страницы узлов, подписок, настроек, обновлений и журнала заранее, но только пока окно скрыто или свёрнуто, через 2 минуты после запуска и когда человек минуту не трогал мышь и клавиатуру: по одной с паузой 3 с (решение пользователя 2026-10-20 для GUI, цена — память страниц в трее). 3) Общее ядро обновления (копия из Zapret 2 GUI) пишет в журнал, откуда получен список имён git-сервера и почему не открылось ни одно имя.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 05:55:59 -04:00

414 lines
19 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Присутствие окна: смотрит ли сейчас человек на программу и что делать при уходе и возврате.
Окно из панели задач разворачивает Windows, и программа узнавала об этом, когда
Qt уже начал рисовать. На слабом ПК после долгого фона это давало «ничего не
работает» на десятки секунд: Windows за время фона отобрала память программы, и
окно подтягивало её с диска по 4 КБ за обращение (замер на win10: после выгрузки
возврат стоит ~11 000 таких обращений).
Уход в фон (свёрнуто или скрыто в трей). Самый ранний сигнал — ``away_intent``
(команда Windows «свернуть», сворачивание в трей): Windows ещё не успела сжать
рабочий набор свёрнутого окна, поэтому именно сейчас видно, какие страницы
программе нужны. Раньше снимок брался через 5 с после ухода, когда набор уже
был сжат, и подтягивать было нечего.
- запоминается, сколько памяти программа держала на виду (самое большое
значение за время показа), и снимок нужных страниц;
- рабочий набор объявляется «жёстким минимумом» (``hold_working_set``) по этому
размеру: Windows не отбирает эти страницы, пока окно скрыто, и возврат идёт из
памяти, а не с диска;
- через AWAY_SETTLE_MS, если окно всё ещё в фоне, снимок берётся ещё раз, но
более скудный (уже сжатый) снимок не затирает полный, взятый на виду;
- если «свернуть» пришло не командой (Win+D и т. п.), сигналом служит событие
Qt, а снимок и размер берутся из того, что запомнено через SAMPLE_AFTER_SHOW_MS
после показа окна.
Возврат (самый ранний из сигналов: команда Windows «развернуть» ``SC_RESTORE``,
показ из трея, событие показа Qt): хватка снимается, а если окно было в фоне
заметно долго, в отдельном потоке запрашивается подтягивание запомненных
страниц большими пачками (PrefetchVirtualMemory) — до того, как главный поток
начнёт ходить по ним по одной. Через RETURN_REPORT_MS в журнал пишется итог.
Принудительной выгрузки памяти здесь нет и быть не должно: она как раз и
заставляет окно открываться медленно.
"""
from __future__ import annotations
import logging
import sys
import threading
import time
from collections.abc import Callable
from PyQt6.QtCore import QEvent, QObject, QTimer
from ..platform.windows.working_set import (
PrefetchResult,
WorkingSetHold,
WorkingSetSnapshot,
available_physical_bytes,
current_working_set_bytes,
hold_size_for,
hold_working_set,
prefetch_working_set,
process_page_faults,
release_working_set_hold,
snapshot_working_set,
)
LOG = logging.getLogger("xray_fluent.window_presence")
WM_SYSCOMMAND = 0x0112
SC_MINIMIZE = 0xF020
SC_RESTORE = 0xF120
_SC_COMMAND_MASK = 0xFFF0
# Сколько окно должно пробыть в фоне, прежде чем запомнить страницы: короткое
# сворачивание и разворот ничего не стоят.
AWAY_SETTLE_MS = 5 * 1000
# Короче этого срока Windows страницы не отбирает, подтягивать нечего.
MIN_AWAY_FOR_PREFETCH_S = 20.0
# Через сколько после возврата писать итог в журнал.
RETURN_REPORT_MS = 3 * 1000
# Через сколько после показа окна запомнить, сколько памяти и каких страниц ему
# нужно (окно уже отрисовано и «прогрелось»), — на случай ухода без команды «свернуть».
SAMPLE_AFTER_SHOW_MS = 4 * 1000
# Сколько ждать, что окно, собравшееся уйти, действительно ушло.
INTENT_CONFIRM_MS = 2 * 1000
# Пока окно скрыто, раз в столько проверяется, не выросла ли память программы
# настолько, что удержание пора увеличить: запуск в трей берёт хватку на пятой
# секунде, когда программа ещё не закончила стартовые работы, и всё, что выросло
# потом, Windows вправе отобрать.
AWAY_REFRESH_MS = 60 * 1000
# На сколько должно вырасти, чтобы хватку пересчитывать.
HOLD_GROWTH_BYTES = 16 * 1024 * 1024
_WATCHED_EVENTS = (QEvent.Type.Hide, QEvent.Type.Show, QEvent.Type.WindowStateChange)
def _system_command(message) -> int | None:
"""Команда WM_SYSCOMMAND из сообщения Windows (без младших 4 бит) или None."""
if sys.platform != "win32":
return None
try:
from ctypes import wintypes
address = int(message)
if address <= 0:
return None
msg = wintypes.MSG.from_address(address)
if int(msg.message) != WM_SYSCOMMAND:
return None
return int(msg.wParam) & _SC_COMMAND_MASK
except Exception:
return None
def is_native_restore_command(message) -> bool:
"""Команда Windows «развернуть» (клик по окну в панели задач, Alt+Tab).
Приходит раньше, чем Qt покажет и нарисует окно. Сообщение не «съедается».
"""
return _system_command(message) == SC_RESTORE
def is_native_minimize_command(message) -> bool:
"""Команда Windows «свернуть»: приходит до того, как Windows сожмёт рабочий набор окна."""
return _system_command(message) == SC_MINIMIZE
# Читается напрямую через WinAPI: раньше здесь подгружался psutil, и его первый
# импорт читал диск ровно на пути возврата из фона.
_page_faults = process_page_faults
def _run_in_thread(target: Callable[[], None]) -> None:
threading.Thread(target=target, name="window-presence", daemon=True).start()
class WindowPresence(QObject):
"""Следит за окном: защищает память при уходе в фон и подтягивает её при возврате."""
def __init__(
self,
window,
*,
settle_ms: int = AWAY_SETTLE_MS,
report_ms: int = RETURN_REPORT_MS,
sample_ms: int = SAMPLE_AFTER_SHOW_MS,
min_away_for_prefetch_s: float = MIN_AWAY_FOR_PREFETCH_S,
take_snapshot: Callable[[], WorkingSetSnapshot | None] = snapshot_working_set,
prefetch: Callable[[WorkingSetSnapshot | None], PrefetchResult | None] = prefetch_working_set,
hold: Callable[[], WorkingSetHold | None] | None = None,
release_hold: Callable[[], object] = release_working_set_hold,
start_worker: Callable[[Callable[[], None]], object] = _run_in_thread,
clock: Callable[[], float] = time.monotonic,
) -> None:
super().__init__(window)
self._window = window
self._report_ms = int(report_ms)
self._min_away_for_prefetch_s = float(min_away_for_prefetch_s)
self._take_snapshot = take_snapshot
self._prefetch = prefetch
# Без своей подмены удерживаем по размеру, запомненному на виду.
self._hold = hold if hold is not None else self._hold_hot_set
self._release_hold = release_hold
self._start_worker = start_worker
self._clock = clock
self._hot_bytes = 0
self._away = True
self._away_since: float | None = None
self._snapshot: WorkingSetSnapshot | None = None
self._snapshot_lock = threading.Lock()
self._last_prefetch: PrefetchResult | None = None
# Хватку включает и снимает рабочий поток; замок держит их по порядку, а
# проверка «окно всё ещё в фоне» внутри замка не даёт хватке пережить возврат.
self._hold_lock = threading.Lock()
self._held: WorkingSetHold | None = None
self._return_faults: int | None = None
self._return_away_s = 0.0
self._return_hold: WorkingSetHold | None = None
self._settle_timer = QTimer(self)
self._settle_timer.setSingleShot(True)
self._settle_timer.setInterval(int(settle_ms))
self._settle_timer.timeout.connect(self._on_settle_timeout)
self._sample_timer = QTimer(self)
self._sample_timer.setSingleShot(True)
self._sample_timer.setInterval(int(sample_ms))
self._sample_timer.timeout.connect(self._on_sample_timeout)
self._intent_timer = QTimer(self)
self._intent_timer.setSingleShot(True)
self._intent_timer.setInterval(INTENT_CONFIRM_MS)
self._intent_timer.timeout.connect(self._on_intent_timeout)
self._away_refresh_timer = QTimer(self)
self._away_refresh_timer.setInterval(AWAY_REFRESH_MS)
self._away_refresh_timer.timeout.connect(self._on_away_refresh)
self._report_timer = QTimer(self)
self._report_timer.setSingleShot(True)
self._report_timer.setInterval(self._report_ms)
self._report_timer.timeout.connect(self._report_return)
window.installEventFilter(self)
self._sync(initial=True)
if not self._away:
self._sample_timer.start()
# ---- состояние -----------------------------------------------------
@property
def away(self) -> bool:
return self._away
def _window_on_screen(self) -> bool:
window = self._window
return bool(window.isVisible()) and not bool(window.isMinimized())
def _sync(self, *, initial: bool = False) -> None:
on_screen = self._window_on_screen()
if on_screen and self._away:
self._enter_shown()
elif not on_screen and (not self._away or initial):
self._enter_away()
def eventFilter(self, obj, event): # noqa: N802 (Qt API)
if obj is self._window and event.type() in _WATCHED_EVENTS:
self._sync()
return False
def return_intent(self) -> None:
"""Окно сейчас вернут (команда Windows «развернуть», показ из трея).
Повторные вызовы и позднее событие показа ничего не повторяют.
"""
if self._away:
self._enter_shown()
def away_intent(self) -> None:
"""Окно сейчас уйдёт в фон (команда Windows «свернуть», сворачивание в трей).
Windows сжимает рабочий набор свёрнутого окна сразу, поэтому защита
ставится здесь, пока память на месте: запоминается, сколько программа
держала на виду, тут же (в потоке окна, это один быстрый вызов, а фоновый
поток мог бы не успеть) ставится удержание, а снимок страниц берётся в
фоне — удержание уже не даст набору сжаться. Состояние «в фоне» сам этот
сигнал не ставит: сворачивание могло не состояться. Его по-прежнему
переключают события Qt; если окно так и не ушло, защита снимается через
INTENT_CONFIRM_MS.
"""
if self._away:
return
self._remember_hot_bytes()
with self._hold_lock:
try:
self._held = self._hold()
except Exception:
self._held = None
self._start_worker(lambda: self._store_snapshot(from_view=True))
self._intent_timer.start()
def _on_intent_timeout(self) -> None:
"""Окно, которое собралось уйти, осталось на экране: защита больше не нужна."""
if not self._away:
self._held = None
self._start_worker(self._release_if_shown)
def _remember_hot_bytes(self) -> None:
try:
current = current_working_set_bytes() or 0
except Exception:
current = 0
self._hot_bytes = max(self._hot_bytes, int(current))
def _hold_hot_set(self) -> WorkingSetHold | None:
"""Удержать столько, сколько программа держала на виду (в разумных пределах)."""
try:
current = current_working_set_bytes() or 0
except Exception:
current = 0
size = hold_size_for(max(int(current), self._hot_bytes), available_physical_bytes())
return None if size is None else hold_working_set(size)
def _on_sample_timeout(self) -> None:
if self._away:
return
self._remember_hot_bytes()
self._start_worker(lambda: self._store_snapshot(from_view=True))
# ---- границы --------------------------------------------------------
def _enter_away(self) -> None:
self._away = True
self._away_since = self._clock()
self._report_timer.stop()
self._sample_timer.stop()
self._intent_timer.stop()
self._settle_timer.start()
self._away_refresh_timer.start()
self._start_worker(self._hold_if_away)
def _on_settle_timeout(self) -> None:
if self._away:
self._start_worker(self._hold_if_away)
self._start_worker(self._store_snapshot)
def _on_away_refresh(self) -> None:
if self._away:
self._start_worker(self._refresh_hold_if_grown)
def _refresh_hold_if_grown(self) -> None:
"""Память выросла после того, как хватка была взята: берёт её заново, побольше."""
try:
current = int(current_working_set_bytes() or 0)
except Exception:
return
held = self._held
if held is not None and held.ok and current < held.bytes + HOLD_GROWTH_BYTES:
return
self._hold_if_away()
self._store_snapshot()
def _hold_if_away(self) -> None:
with self._hold_lock:
if not self._away:
return
try:
self._held = self._hold()
except Exception:
self._held = None
def _release_if_shown(self) -> None:
with self._hold_lock:
if self._away:
return
try:
self._release_hold()
except Exception:
pass
def _store_snapshot(self, from_view: bool = False) -> None:
"""Запоминает страницы, нужные программе.
Снимок, взятый на виду, полный. Взятый уже в фоне (Windows успела сжать
набор) беднее: он заменяет запомненное, только если не меньше его.
"""
try:
snapshot = self._take_snapshot()
except Exception:
snapshot = None
if snapshot is None:
return
with self._snapshot_lock:
current = self._snapshot
if from_view or current is None or snapshot.total_bytes >= current.total_bytes:
self._snapshot = snapshot
def _enter_shown(self) -> None:
self._away = False
self._settle_timer.stop()
self._away_refresh_timer.stop()
self._sample_timer.start()
since, self._away_since = self._away_since, None
away_s = 0.0 if since is None else max(0.0, self._clock() - since)
with self._snapshot_lock:
snapshot = self._snapshot
self._last_prefetch = None
held, self._held = self._held, None
self._return_hold = held
self._start_worker(self._release_if_shown)
if away_s < self._min_away_for_prefetch_s:
return
if snapshot is not None:
self._start_worker(lambda: self._run_prefetch(snapshot))
self._return_away_s = away_s
self._return_faults = _page_faults()
self._report_timer.start()
def _run_prefetch(self, snapshot: WorkingSetSnapshot) -> None:
try:
self._last_prefetch = self._prefetch(snapshot)
except Exception:
self._last_prefetch = None
def _report_return(self) -> None:
faults_now = _page_faults()
faults = "?" if faults_now is None or self._return_faults is None else str(faults_now - self._return_faults)
prefetch = self._last_prefetch
if prefetch is None:
pulled = "pages not prefetched"
else:
pulled = f"prefetched {prefetch.total_bytes / (1024 * 1024):.0f} MB in {prefetch.elapsed_ms:.0f} ms"
hold = self._return_hold
if hold is None:
kept = "memory not held"
elif hold.ok:
kept = f"held {hold.bytes / (1024 * 1024):.0f} MB"
else:
kept = f"hold failed (code {hold.error})"
LOG.info(
"window returned from background (%.0f s): %s; %s; page faults in %.0f s after return: %s",
self._return_away_s,
kept,
pulled,
self._report_ms / 1000,
faults,
)
__all__ = [
"AWAY_REFRESH_MS",
"AWAY_SETTLE_MS",
"HOLD_GROWTH_BYTES",
"INTENT_CONFIRM_MS",
"MIN_AWAY_FOR_PREFETCH_S",
"RETURN_REPORT_MS",
"SAMPLE_AFTER_SHOW_MS",
"WindowPresence",
"is_native_minimize_command",
"is_native_restore_command",
]