zapret-kvn/tests/test_worker_keeper.py
loop-uh d301e8a49a Hysteria: рабочий сервер не заменяется из-за случайной ошибки; подписки — гонка маршрутов
Инцидент 01.10.2026 (диагностика 0.8.4): простаивавшая QUIC-сессия Hysteria2
оборвалась («timeout: no recent network activity»), официальный клиент
переподключился в ту же секунду, но приложение по одной строке лога запустило
восстановление: остановило туннель, взяло первый по порядку сервер (он
отвечал «authentication error, HTTP 301») и осталось отключённым до ручного
подключения. Пользователь видел это как «интернет отваливается и не встаёт».

Hysteria
- Ошибка транспорта в логе работающего туннеля — повод для проверки, а не
  приговор (HysteriaManager._begin_tunnel_check). Сервер остаётся, если он
  снова авторизовал клиента («connected to server») или HTTPS-проба через
  relay прошла; failure публикуется только после двух проваленных волн.
  Ошибки безопасности (TLS/pin/auth/obfs) по-прежнему публикуются сразу.
- Если замена всё же понадобилась и резервный сервер отказал, он исключается
  (auth/TLS — на час, сеть — на 5 минут), а приложение возвращается на
  выбранный пользователем сервер вместо отключения. То же при отсутствии
  резервного сервера. Ручное действие отменяет отложенный возврат.
- Кандидат с is_alive=False в замену не берётся.

Подписки
- fetch_subscription: прямой маршрут и VPN-прокси идут гонкой (фора прямого
  1 с) под общим дедлайном 30 с вместо последовательных попыток со сложением
  таймаутов; чтение тела ограничено тем же сроком. Ответ сервера напрямую
  остаётся окончательным, отказ через прокси ждёт прямой маршрут 3 с.
- Статус «Обновление…» не зависает: любая ошибка применения результата
  завершает обновление; в колонке виден этап и секунды; обновления пишутся
  в лог; выход из приложения отменяет загрузку.

Потоки
- Пинг — долгоживущая служба PingService без QThread; короткие QThread-воркеры
  живут в корзине до своего finished (worker_keeper). Убирает вылет
  «QThread: Destroyed while thread is still running».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 19:20:34 +03:00

108 lines
3.7 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.

"""Время жизни QThread-воркеров (xray_fluent/application/worker_keeper.py).
Главный сценарий — вылет «QThread: Destroyed while thread is still running»:
слот сигнала, испущенного внутри ``run()``, обнулял последнюю ссылку на
поток. С корзиной ``keep_until_finished`` поток живёт до своего ``finished``.
"""
from __future__ import annotations
import gc
import threading
import time
import unittest
from PyQt6.QtCore import QCoreApplication, QEventLoop, QObject, QThread, QTimer, pyqtSignal
from xray_fluent.application.worker_keeper import keep_until_finished, start_kept, wait_all
_APP = QCoreApplication.instance() or QCoreApplication([])
def _spin_until(predicate, timeout_ms: int = 3000) -> bool:
deadline = time.monotonic() + timeout_ms / 1000.0
while time.monotonic() < deadline:
if predicate():
return True
loop = QEventLoop()
QTimer.singleShot(5, loop.quit)
loop.exec()
return predicate()
class _Worker(QThread):
completed = pyqtSignal()
def __init__(self) -> None:
super().__init__()
# Событие, а не sleep: слот с gc.collect() в большом процессе идёт
# дольше любой паузы, и поток успевал завершиться до проверки.
self.release = threading.Event()
def run(self) -> None:
self.completed.emit()
# Поток ещё внутри run(), когда GUI-поток обрабатывает completed.
self.release.wait(5)
class _Owner(QObject):
def __init__(self) -> None:
super().__init__()
self.worker: _Worker | None = None
self.bucket: list = []
self.completed = 0
def on_completed(self) -> None:
self.completed += 1
self.worker = None # так делали on_ping_complete / on_speed_complete
gc.collect()
class WorkerKeeperTests(unittest.TestCase):
def test_dropping_current_handle_in_completed_slot_is_safe(self) -> None:
owner = _Owner()
for _ in range(5):
worker = _Worker()
owner.worker = worker
worker.completed.connect(owner.on_completed)
start_kept(owner.bucket, worker)
release = worker.release
del worker
self.assertTrue(_spin_until(lambda: owner.worker is None))
self.assertEqual(len(owner.bucket), 1, "поток ещё работает — корзина его держит")
release.set()
self.assertTrue(_spin_until(lambda: not owner.bucket))
self.assertEqual(owner.completed, 5)
def test_released_once_on_finished(self) -> None:
class _Fake(QObject):
finished = pyqtSignal()
def __init__(self) -> None:
super().__init__()
self.deleted = 0
def deleteLater(self) -> None: # noqa: N802 - QObject API
self.deleted += 1
bucket: list = []
fake = _Fake()
keep_until_finished(bucket, fake)
keep_until_finished(bucket, fake)
self.assertEqual(bucket, [fake])
fake.finished.emit()
fake.finished.emit()
self.assertEqual(bucket, [])
self.assertEqual(fake.deleted, 1)
def test_wait_all_waits_running_workers(self) -> None:
bucket: list = []
worker = start_kept(bucket, _Worker())
worker.release.set()
wait_all(bucket, 2000)
self.assertFalse(worker.isRunning())
self.assertTrue(_spin_until(lambda: not bucket))
if __name__ == "__main__":
unittest.main()