Инцидент 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>
92 lines
3.2 KiB
Python
92 lines
3.2 KiB
Python
import unittest
|
|
from types import SimpleNamespace
|
|
from unittest.mock import Mock
|
|
|
|
from xray_fluent.application.worker_service import (
|
|
on_ping_measured,
|
|
on_ping_progress,
|
|
on_speed_complete,
|
|
on_speed_result,
|
|
)
|
|
from xray_fluent.network.ping_service import PingOutcome, PingTarget
|
|
from xray_fluent.profiles.models import Node
|
|
|
|
|
|
class _Signal:
|
|
def __init__(self):
|
|
self.emit = Mock()
|
|
|
|
|
|
class WorkerServiceBatchingTests(unittest.TestCase):
|
|
def test_speed_results_are_saved_once_when_the_batch_finishes(self) -> None:
|
|
node = Node(id="node-1")
|
|
worker = SimpleNamespace(was_cancelled=False, completed_nodes=1)
|
|
controller = SimpleNamespace(
|
|
_speed_worker=worker,
|
|
_speed_total=1,
|
|
_speed_completed=0,
|
|
sender=lambda: worker,
|
|
_get_node_by_id=lambda node_id: node if node_id == node.id else None,
|
|
speed_updated=_Signal(),
|
|
speed_test_cancelled=_Signal(),
|
|
bulk_task_progress=_Signal(),
|
|
status=_Signal(),
|
|
save=Mock(),
|
|
)
|
|
|
|
on_speed_result(controller, node.id, 12.5, True)
|
|
controller.save.assert_not_called()
|
|
self.assertEqual(node.speed_mbps, 12.5)
|
|
|
|
on_speed_complete(controller)
|
|
controller.save.assert_called_once_with()
|
|
controller.bulk_task_progress.emit.assert_called_once_with("speed", 1, 1, True)
|
|
|
|
def test_ping_round_end_uses_debounced_schedule_save(self) -> None:
|
|
controller = SimpleNamespace(
|
|
bulk_task_progress=_Signal(),
|
|
save=Mock(),
|
|
schedule_save=Mock(),
|
|
)
|
|
|
|
on_ping_progress(controller, 2, 3, False)
|
|
controller.schedule_save.assert_not_called()
|
|
|
|
on_ping_progress(controller, 3, 3, True)
|
|
|
|
controller.save.assert_not_called()
|
|
controller.schedule_save.assert_called_once_with()
|
|
controller.bulk_task_progress.emit.assert_called_with("ping", 3, 3, True)
|
|
|
|
def test_ping_result_for_a_moved_server_is_not_applied(self) -> None:
|
|
node = Node(id="node-1", server="old.example", port=443, ping_ms=40)
|
|
target = PingTarget.of(node)
|
|
node.server = "new.example"
|
|
controller = SimpleNamespace(
|
|
_get_node_by_id=lambda node_id: node if node_id == node.id else None,
|
|
ping_updated=_Signal(),
|
|
)
|
|
|
|
on_ping_measured(controller, PingOutcome(target, 999, ("1.2.3.4",)))
|
|
|
|
self.assertEqual(node.ping_ms, 40)
|
|
self.assertEqual(node.ping_history, [])
|
|
controller.ping_updated.emit.assert_called_once_with("node-1", 40)
|
|
|
|
def test_ping_result_is_applied_and_recorded(self) -> None:
|
|
node = Node(id="node-1", server="vpn.example", port=443)
|
|
controller = SimpleNamespace(
|
|
_get_node_by_id=lambda node_id: node if node_id == node.id else None,
|
|
ping_updated=_Signal(),
|
|
)
|
|
|
|
on_ping_measured(controller, PingOutcome(PingTarget.of(node), 25, ()))
|
|
|
|
self.assertEqual(node.ping_ms, 25)
|
|
self.assertTrue(node.is_alive)
|
|
self.assertEqual(len(node.ping_history), 1)
|
|
controller.ping_updated.emit.assert_called_once_with("node-1", 25)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|