zapretgui/tests/test_exit_watch_autorestart.py
loop-uh b554a6fd29 Диагностировать молчаливое завершение winws по фактам
winws2 всегда печатает баннер версии, поэтому проверка «вывод пустой»
не срабатывала никогда: пользователю показывалась строка версии вместо
причины, а ветка диагностики молчаливой смерти была мертва.

- winws_output: единый разбор вывода (баннер отделён от диагностики)
  вместо трёх копий выборки строки;
- silent_exit_probe: причина устанавливается проверками (файл исчез,
  запись в журнале Windows, срабатывание Defender, наличие антивируса),
  а не угадывается; «не смогли проверить» не выдаётся за «в норме»;
- SpawnFailureKind.SILENT_EXIT вместо догадки про внешнее завершение;
- полный стартовый вывод winws2 пишется в общий лог при отказе.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 15:02:12 +03:00

393 lines
16 KiB
Python

"""Exit-watch / foreign winws / auto-restart acceptance tests
(.agent/tasks/exit-watch-autorestart/spec.md).
AC1: exit watcher fires for our dead current process, suppresses on identity
mismatch and intentional-stop states, starts only on confirmed spawn.
AC3/AC4: main-thread handler outcomes and the auto-restart budget.
AC5: foreign winws classification and the warn-once observer.
AC6: scan guard TTL + blockcheck wiring contract.
AC7: structured post-mortem resolution (transient flag, scan-guard silence).
"""
from __future__ import annotations
import inspect
import threading
import time
from pathlib import Path
from types import SimpleNamespace
import sys
import tempfile
import unittest
from unittest.mock import Mock, patch
PROJECT_SRC = Path(__file__).resolve().parents[1] / "src"
if str(PROJECT_SRC) not in sys.path:
sys.path.insert(0, str(PROJECT_SRC))
class _LogRecorder:
def __init__(self) -> None:
self.records: list[tuple[str, str]] = []
def __call__(self, message, level="INFO", *args, **kwargs) -> None:
self.records.append((str(message), str(level)))
def messages(self, level: str) -> list[str]:
return [message for message, lvl in self.records if lvl == level]
def _make_base_runner():
from winws_runtime.runners.zapret1_runner import Winws1StrategyRunner
runner = object.__new__(Winws1StrategyRunner)
runner.running_process = None
runner.current_launch_label = None
runner._unexpected_process_exit_callback = None
return runner
class ExitWatcherTests(unittest.TestCase):
def test_callback_fires_for_current_dead_process_in_running_state(self) -> None:
from winws_runtime.runners.preset_runner_support import PresetRunnerState
runner = _make_base_runner()
process = SimpleNamespace(poll=lambda: 1)
runner.running_process = process
runner.get_runner_state_snapshot = lambda: SimpleNamespace(state=PresetRunnerState.RUNNING)
callback = Mock()
runner._unexpected_process_exit_callback = callback
runner._on_watched_process_exit(process)
callback.assert_called_once_with()
def test_suppressed_when_process_was_replaced(self) -> None:
runner = _make_base_runner()
old_process = SimpleNamespace(poll=lambda: 1)
runner.running_process = SimpleNamespace(poll=lambda: None) # new process
callback = Mock()
runner._unexpected_process_exit_callback = callback
runner._on_watched_process_exit(old_process)
callback.assert_not_called()
def test_suppressed_during_intentional_stop_states(self) -> None:
from winws_runtime.runners.preset_runner_support import PresetRunnerState
for state in (PresetRunnerState.STOPPING, PresetRunnerState.IDLE):
runner = _make_base_runner()
process = SimpleNamespace(poll=lambda: 1)
runner.running_process = process
runner.get_runner_state_snapshot = lambda s=state: SimpleNamespace(state=s)
callback = Mock()
runner._unexpected_process_exit_callback = callback
runner._on_watched_process_exit(process)
callback.assert_not_called()
def test_watcher_thread_invokes_exit_handler_after_wait(self) -> None:
runner = _make_base_runner()
fired = threading.Event()
runner._on_watched_process_exit = lambda _process: fired.set()
process = SimpleNamespace(wait=lambda: 0)
runner._start_process_exit_watcher(process)
self.assertTrue(fired.wait(timeout=2.0))
def test_zapret2_spawn_success_starts_watcher(self) -> None:
from winws_runtime.runners.zapret2_runner import Winws2StrategyRunner
with tempfile.TemporaryDirectory() as tmp:
runner = object.__new__(Winws2StrategyRunner)
runner.winws_exe = "winws2.exe"
runner.work_dir = tmp
runner.running_process = None
runner.current_launch_label = None
runner.current_strategy_args = None
runner._last_spawn_exit_code = None
runner._last_spawn_stderr = ""
runner._last_startup_output_path = ""
runner._prepare_state_for_spawn_locked = Mock()
runner._set_runner_state_locked = Mock()
runner._log_winws2_launch_command = Mock()
runner._create_startup_info = Mock(return_value=None)
runner._set_last_error = Mock()
runner._run_preset_dry_run_locked = Mock(return_value=True)
runner._start_process_exit_watcher = Mock()
artifact = SimpleNamespace(
launch_args=("@config.txt",),
preset_path="preset.txt",
normalized_text="--wf-tcp-out=443\n",
)
fake_process = SimpleNamespace(pid=1234, returncode=None)
with (
patch("winws_runtime.runners.zapret2_runner.subprocess.Popen", return_value=fake_process),
patch("winws_runtime.runners.zapret2_runner.wait_for_process_stable_start", return_value=True),
patch("winws_runtime.runners.zapret2_runner.time.sleep"),
):
self.assertTrue(
runner._spawn_process_locked(artifact, "Preset", preset_switch=False)
)
runner._start_process_exit_watcher.assert_called_once_with(fake_process)
class UnexpectedExitHandlerTests(unittest.TestCase):
def _make_events(self, *, phase: str = "running", command_port=None):
from app.feature_facades.runtime_parts import RuntimeEvents
runtime_service = SimpleNamespace(
snapshot=lambda: SimpleNamespace(phase=phase),
mark_start_failed=Mock(),
)
return RuntimeEvents(runtime_service=runtime_service, command_port=command_port)
def _resolution(self, *, transient: bool):
from winws_runtime.health.post_mortem import PostMortemResolution
return PostMortemResolution(
message="winws2.exe неожиданно завершился: тестовая причина",
kind="transient_dll_init" if transient else "silent_exit",
transient=transient,
exit_code=0xC0000142 if transient else 1,
)
def test_no_action_when_resolution_is_none(self) -> None:
events = self._make_events()
recorder = _LogRecorder()
with patch("log.log.log", recorder):
events.handle_unexpected_process_exit(None)
events.runtime_service.mark_start_failed.assert_not_called()
self.assertEqual(recorder.messages("ERROR"), [])
def test_no_action_when_phase_is_not_active(self) -> None:
events = self._make_events(phase="stopped")
events.handle_unexpected_process_exit(self._resolution(transient=False))
events.runtime_service.mark_start_failed.assert_not_called()
def test_non_transient_failure_publishes_single_error(self) -> None:
events = self._make_events()
recorder = _LogRecorder()
resolution = self._resolution(transient=False)
with patch("log.log.log", recorder):
events.handle_unexpected_process_exit(resolution)
self.assertEqual(recorder.messages("ERROR"), [resolution.message])
events.runtime_service.mark_start_failed.assert_called_once_with(resolution.message)
def test_transient_failure_triggers_auto_restart(self) -> None:
command_port = SimpleNamespace(start=Mock(return_value=True))
events = self._make_events(command_port=command_port)
recorder = _LogRecorder()
resolution = self._resolution(transient=True)
with patch("log.log.log", recorder):
events.handle_unexpected_process_exit(resolution)
command_port.start.assert_called_once_with()
self.assertEqual(recorder.messages("ERROR"), [])
self.assertTrue(
any("автоматический перезапуск" in msg.lower() for msg in recorder.messages("WARNING"))
)
events.runtime_service.mark_start_failed.assert_called_once_with(resolution.message)
def test_exhausted_budget_disables_auto_restart(self) -> None:
command_port = SimpleNamespace(start=Mock(return_value=True))
events = self._make_events(command_port=command_port)
now = time.monotonic()
events.auto_restart_history = [now - 1.0, now - 2.0]
recorder = _LogRecorder()
resolution = self._resolution(transient=True)
with patch("log.log.log", recorder):
events.handle_unexpected_process_exit(resolution)
command_port.start.assert_not_called()
errors = recorder.messages("ERROR")
self.assertEqual(len(errors), 1)
self.assertIn("Автоперезапуск отключён", errors[0])
events.runtime_service.mark_start_failed.assert_called_once()
def test_auto_restart_budget_window_slides(self) -> None:
events = self._make_events()
self.assertTrue(events._try_reserve_auto_restart(now=1000.0))
self.assertTrue(events._try_reserve_auto_restart(now=1001.0))
self.assertFalse(events._try_reserve_auto_restart(now=1002.0))
self.assertTrue(events._try_reserve_auto_restart(now=1000.0 + 601.0 + 1.0))
class ForeignWinwsDetectionTests(unittest.TestCase):
def test_find_foreign_classifies_by_path(self) -> None:
from winws_runtime.runtime import process_probe
entries = [(100, "winws2.exe"), (200, "winws.exe"), (300, "winws2.exe")]
paths = {
100: r"c:\zapret\exe\winws2.exe", # canonical
200: r"c:\other\winws.exe", # foreign
300: "", # unqueryable → skipped
}
with (
patch.object(process_probe, "_iter_winws_process_entries", return_value=entries),
patch.object(process_probe, "_query_process_image_path", side_effect=lambda pid: paths[pid]),
patch.object(
process_probe,
"get_expected_winws_paths",
return_value={
"winws2.exe": r"c:\zapret\exe\winws2.exe",
"winws.exe": r"c:\zapret\exe\winws.exe",
},
),
):
foreign = process_probe.find_foreign_winws_processes()
self.assertEqual([record.pid for record in foreign], [200])
self.assertEqual(foreign[0].exe_path, r"c:\other\winws.exe")
def _make_objects(self, *, running: bool = True):
from app.feature_facades.runtime_parts import RuntimeObjects
runtime_service = SimpleNamespace(
snapshot=lambda: SimpleNamespace(running=running),
)
return RuntimeObjects(runtime_service=runtime_service)
def test_warns_once_per_foreign_pid_set_while_running(self) -> None:
objects = self._make_objects(running=True)
recorder = _LogRecorder()
foreign = {123: r"c:\other\winws.exe"}
with (
patch("log.log.log", recorder),
patch(
"winws_runtime.runtime.scan_guard.is_external_winws_scan_active",
return_value=False,
),
):
objects.observe_foreign_processes(foreign)
objects.observe_foreign_processes(foreign) # same set → no repeat
objects.observe_foreign_processes({}) # cleared → reset
objects.observe_foreign_processes(foreign) # returns → warn again
errors = recorder.messages("ERROR")
self.assertEqual(len(errors), 2)
self.assertIn("посторонний winws", errors[0])
self.assertIn("PID 123", errors[0])
def test_silent_when_dpi_not_running(self) -> None:
objects = self._make_objects(running=False)
recorder = _LogRecorder()
with patch("log.log.log", recorder):
objects.observe_foreign_processes({123: r"c:\other\winws.exe"})
self.assertEqual(recorder.messages("ERROR"), [])
def test_silent_while_external_scan_active(self) -> None:
objects = self._make_objects(running=True)
recorder = _LogRecorder()
with (
patch("log.log.log", recorder),
patch(
"winws_runtime.runtime.scan_guard.is_external_winws_scan_active",
return_value=True,
),
):
objects.observe_foreign_processes({123: r"c:\other\winws.exe"})
self.assertEqual(recorder.messages("ERROR"), [])
class ScanGuardTests(unittest.TestCase):
def test_flag_lifecycle_and_ttl(self) -> None:
from winws_runtime.runtime import scan_guard
fake_now = [1000.0]
with patch.object(scan_guard.time, "monotonic", side_effect=lambda: fake_now[0]):
scan_guard.mark_external_winws_scan_active(True, ttl_seconds=10.0)
self.assertTrue(scan_guard.is_external_winws_scan_active())
fake_now[0] = 1011.0 # TTL expired — a dead scanner cannot mute us forever
self.assertFalse(scan_guard.is_external_winws_scan_active())
fake_now[0] = 1012.0
scan_guard.mark_external_winws_scan_active(True, ttl_seconds=10.0)
self.assertTrue(scan_guard.is_external_winws_scan_active())
scan_guard.mark_external_winws_scan_active(False)
self.assertFalse(scan_guard.is_external_winws_scan_active())
def test_blockcheck_scanner_wraps_run_with_scan_flag(self) -> None:
from blockcheck.strategy_scanner import StrategyScanner
source = inspect.getsource(StrategyScanner.run)
self.assertIn("mark_external_winws_scan_active(True)", source)
self.assertIn("finally", source)
self.assertIn("mark_external_winws_scan_active(False)", source)
class ResolveUnexpectedExitTests(unittest.TestCase):
def _fake_runner(self, exit_code: int, output: str = ""):
return SimpleNamespace(
winws_exe=r"g:\zapret\exe\winws2.exe",
build_post_mortem_snapshot=lambda: {
"exit_code": exit_code,
"output": output,
"strategy_name": "Default v1",
},
)
def test_transient_for_dll_init_failure(self) -> None:
from winws_runtime.health.post_mortem import resolve_unexpected_exit
with (
patch(
"winws_runtime.runners.runner_factory.get_current_runner",
return_value=self._fake_runner(0xC0000142),
),
patch(
"winws_runtime.runtime.scan_guard.is_external_winws_scan_active",
return_value=False,
),
):
resolution = resolve_unexpected_exit()
self.assertIsNotNone(resolution)
self.assertTrue(resolution.transient)
self.assertEqual(resolution.exit_code, 0xC0000142)
def test_not_transient_for_external_kill_and_crash(self) -> None:
from winws_runtime.health.post_mortem import resolve_unexpected_exit
for exit_code in (1, 0xC0000005):
with (
patch(
"winws_runtime.runners.runner_factory.get_current_runner",
return_value=self._fake_runner(exit_code),
),
patch(
"winws_runtime.runtime.scan_guard.is_external_winws_scan_active",
return_value=False,
),
):
resolution = resolve_unexpected_exit()
self.assertIsNotNone(resolution, hex(exit_code))
self.assertFalse(resolution.transient, hex(exit_code))
def test_none_while_external_scan_active(self) -> None:
from winws_runtime.health.post_mortem import resolve_unexpected_exit
with (
patch(
"winws_runtime.runtime.scan_guard.is_external_winws_scan_active",
return_value=True,
),
patch("winws_runtime.runners.runner_factory.get_current_runner") as get_runner,
):
self.assertIsNone(resolve_unexpected_exit())
get_runner.assert_not_called()
if __name__ == "__main__":
unittest.main(verbosity=2)