zapretgui/tests/test_engine_process_control.py
loop-uh 63d3389b8f
All checks were successful
Zapret source guards / validate (push) Successful in 18s
Подбор стратегий: «Применить» проверяет сайт через пресет, winws2 пробы не переживает программу, «Стоп» сразу
- После «Применить» программа ждёт запуска Zapret и сама открывает сайт уже через пресет пользователя — теми же адресами из разных подсетей и с проверкой объёма; если не открылся — повтор через 4 с и честное уведомление, что сайт, скорее всего, перехватывает другой профиль выше (strategy_apply_check). Раньше было «проверьте сами».
- Профиль сайта пишется на имя без www.: цель www.googlevideo.com давала --hostlist-domains=www.googlevideo.com, который не ловил ни одного видеосервера rr…googlevideo.com; www.youtube.com теперь даёт youtube.com (и www., и m.). Старый профиль на www.-имя считается тем же сайтом и обновляется.
- winws2 пробы сидит в объекте задания Windows с «завершить при закрытии»: упадёт программа или её закроют посреди подбора — Windows сама завершит его, и он не останется резать трафик (проверено на win10: с заданием дочерний процесс гибнет вместе с родителем, без задания — живёт). Привязки CreateJobObjectW / SetInformationJobObject / AssignProcessToJobObject — в общем слое WinAPI с типами.
- «Стоп» обрывает ожидание готовности winws2, паузы при Kaspersky и ожидание сети сразу, а «Стоп» во время запуска не считается падением стратегии.
- Пока подбор держит winws2, обходные пути запуска Zapret (кнопка в редакторе пресета и т. п.) не создают второй winws2, а объясняют, что идёт подбор; главная кнопка по-прежнему сначала останавливает подбор.
- История подбора пишется по ходу, каждые 10 итогов, а не только в конце.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:32:53 +03:00

518 lines
23 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.

"""Остановка процессов движка с подтверждением и подтверждение запуска."""
from __future__ import annotations
import ctypes
import os
import subprocess
import tempfile
import unittest
from unittest.mock import patch
from utils.windows_process_probe import ProcessSnapshotError
from winws_runtime.engine import process_control, startup, winapi
OWN_EXE = r"C:\Zapret\Dev\exe\winws2.exe"
FOREIGN_EXE = r"C:\Other\zapret\exe\winws2.exe"
class FakeProcess:
"""Подделка Popen: выход наступает после заданного числа ожиданий."""
def __init__(self, *, exits_after_waits: int | None = None, exit_code: int = 0, pid: int = 4321):
self.pid = pid
self._exit_code = exit_code
self._exits_after_waits = exits_after_waits
self.returncode: int | None = None
self.wait_timeouts: list[float] = []
self.terminated = False
def _exit(self) -> None:
self.returncode = self._exit_code
def poll(self):
return self.returncode
def wait(self, timeout=None):
self.wait_timeouts.append(timeout)
if self.returncode is not None:
return self.returncode
if self._exits_after_waits is not None and len(self.wait_timeouts) >= self._exits_after_waits:
self._exit()
return self.returncode
raise subprocess.TimeoutExpired("winws2", timeout)
def terminate(self) -> None:
self.terminated = True
self._exits_after_waits = len(self.wait_timeouts) + 1
class StopProcessTests(unittest.TestCase):
def setUp(self) -> None:
self.signals: list[str] = []
# None — сигнал доставляется; число — код ошибки OpenEventW/SetEvent.
self.signal_error: int | None = None
def fake_signal(name: str) -> None:
self.signals.append(name)
if self.signal_error is not None:
raise winapi.WinApiError("OpenEventW", self.signal_error)
for target, replacement in (
("is_available", lambda: True),
("signal_named_event", fake_signal),
):
patcher = patch.object(winapi, target, replacement)
patcher.start()
self.addCleanup(patcher.stop)
def test_graceful_stop_does_not_terminate_the_process(self) -> None:
process = FakeProcess(exits_after_waits=1)
self.assertTrue(process_control.stop_process(process, timeout=5.0))
self.assertEqual(self.signals, [r"Global\winws2_sig_4321_term"])
self.assertFalse(process.terminated)
# Одно ожидание на хэндле, только в пределах короткого срока.
self.assertEqual(process.wait_timeouts, [process_control.ENGINE_GRACEFUL_STOP_SECONDS])
def test_stop_falls_back_to_terminate_when_signal_cannot_be_delivered(self) -> None:
# Старая сборка движка: события нет, OpenEventW отвечает «файл не найден».
self.signal_error = winapi.ERROR_FILE_NOT_FOUND
process = FakeProcess()
self.assertTrue(process_control.stop_process(process, timeout=5.0))
self.assertEqual(len(self.signals), 1) # сначала была попытка мягкой остановки
self.assertTrue(process.terminated)
# Мягкого ожидания не было: весь срок отдан на подтверждение выхода.
self.assertEqual(process.wait_timeouts, [5.0])
def test_stop_falls_back_to_terminate_when_winapi_is_unavailable(self) -> None:
process = FakeProcess()
with patch.object(winapi, "is_available", lambda: False):
self.assertTrue(process_control.stop_process(process, timeout=5.0))
self.assertEqual(self.signals, [])
self.assertTrue(process.terminated)
def test_stop_falls_back_to_terminate_when_process_ignores_signal(self) -> None:
process = FakeProcess() # сигнал доставлен, но процесс не выходит
self.assertTrue(process_control.stop_process(process, timeout=5.0))
self.assertEqual(len(self.signals), 1)
self.assertTrue(process.terminated)
grace = process_control.ENGINE_GRACEFUL_STOP_SECONDS
# Сначала короткое мягкое ожидание, затем оставшееся время после kill.
self.assertEqual(process.wait_timeouts, [grace, 5.0 - grace])
def test_stop_reports_failure_when_process_does_not_exit(self) -> None:
process = FakeProcess()
process.terminate = lambda: None # процесс не реагирует и на завершение
self.assertFalse(process_control.stop_process(process, timeout=0.5))
def test_already_exited_process_needs_no_signal_or_termination(self) -> None:
process = FakeProcess()
process.returncode = 0
self.assertTrue(process_control.stop_process(process))
self.assertFalse(process.terminated)
self.assertEqual(self.signals, [])
def test_none_process_is_already_stopped(self) -> None:
self.assertTrue(process_control.stop_process(None))
def test_kill_exit_code_differs_from_silent_crash_code(self) -> None:
# Код 1 — молчаливое падение winws; запасное убийство должно быть
# отличимо от него, а код 259 означает «ещё работает».
self.assertNotIn(process_control.ENGINE_KILL_EXIT_CODE, (0, 1, 259))
class _FakeEngineWorld(unittest.TestCase):
"""Подделка системы: процессы движка, их хэндлы и управляющие события."""
def setUp(self) -> None:
self.processes: dict[int, tuple[str, str]] = {}
self.terminated: list[int] = []
self.signalled: list[tuple[int, str]] = []
self.closed: list[int] = []
self.exited: set[int] = set()
self.waits: list[tuple[tuple[int, ...], float]] = []
self.unkillable: set[int] = set() # TerminateProcess не помогает
self.no_event: set[int] = set() # старая сборка: события нет
self.deaf: set[int] = set() # событие есть, но процесс его игнорирует
self.open_errors: dict[int, int] = {}
patches = {
"is_available": lambda: True,
"open_process": self._open_process,
"query_image_path": self._query_image_path,
"terminate_process": self._terminate_process,
"signal_named_event": self._signal_named_event,
"wait_for_all_handles": self._wait_for_all_handles,
"wait_for_handle": self._wait_for_handle,
"close_handle": self.closed.append,
}
for name, replacement in patches.items():
patcher = patch.object(winapi, name, replacement)
patcher.start()
self.addCleanup(patcher.stop)
patcher = patch.object(process_control, "iter_process_records_winapi_strict", self._snapshot)
patcher.start()
self.addCleanup(patcher.stop)
patcher = patch.object(process_control, "normalize_image_path", lambda path: str(path or "").lower())
patcher.start()
self.addCleanup(patcher.stop)
# Хэндл в подделке — это pid со сдвигом, чтобы путаница всплыла сразу.
def _snapshot(self):
return [(pid, name) for pid, (name, _path) in self.processes.items()]
def _open_process(self, pid, _access):
if pid in self.open_errors:
raise winapi.WinApiError("OpenProcess", self.open_errors[pid])
return pid + 100000
def _query_image_path(self, handle):
return self.processes[handle - 100000][1]
def _terminate_process(self, handle, exit_code):
self.assertEqual(exit_code, process_control.ENGINE_KILL_EXIT_CODE)
pid = handle - 100000
self.terminated.append(pid)
if pid not in self.unkillable:
self.exited.add(pid)
def _signal_named_event(self, name):
prefix = "Global\\winws2_sig_"
self.assertTrue(name.startswith(prefix), name)
pid_text, kind = name[len(prefix):].split("_", 1)
pid = int(pid_text)
if pid in self.no_event:
raise winapi.WinApiError("OpenEventW", winapi.ERROR_FILE_NOT_FOUND)
self.signalled.append((pid, kind))
if kind == process_control.ENGINE_SIGNAL_TERM and pid not in self.deaf:
self.exited.add(pid)
def _wait_for_all_handles(self, handles, timeout):
self.waits.append((tuple(handle - 100000 for handle in handles), timeout))
return all((handle - 100000) in self.exited for handle in handles)
def _wait_for_handle(self, handle, _timeout):
return (handle - 100000) in self.exited
class StopEngineProcessesTests(_FakeEngineWorld):
def test_only_engines_from_own_folder_are_stopped(self) -> None:
self.processes = {
10: ("winws2.exe", OWN_EXE),
11: ("winws2.exe", FOREIGN_EXE),
12: ("explorer.exe", r"C:\Windows\explorer.exe"),
}
result = process_control.stop_engine_processes([OWN_EXE])
self.assertTrue(result.ok)
self.assertEqual(result.stopped, (10,))
# Чужому процессу не послан даже сигнал.
self.assertEqual(self.signalled, [(10, "term")])
self.assertEqual([record.pid for record in result.foreign], [11])
self.assertEqual(result.foreign[0].exe_path, FOREIGN_EXE.lower())
def test_graceful_stop_does_not_call_terminate_process(self) -> None:
self.processes = {10: ("winws2.exe", OWN_EXE), 13: ("winws2.exe", OWN_EXE)}
result = process_control.stop_engine_processes([OWN_EXE])
self.assertTrue(result.ok)
self.assertEqual(result.stopped, (10, 13))
self.assertEqual(self.terminated, [])
# Все свои процессы ждутся разом, в пределах короткого срока.
self.assertEqual(self.waits, [((10, 13), process_control.ENGINE_GRACEFUL_STOP_SECONDS)])
def test_engine_without_event_is_terminated(self) -> None:
self.processes = {10: ("winws2.exe", OWN_EXE), 13: ("winws2.exe", OWN_EXE)}
self.no_event = {13}
result = process_control.stop_engine_processes([OWN_EXE])
self.assertTrue(result.ok)
self.assertEqual(result.stopped, (10, 13))
self.assertEqual(self.signalled, [(10, "term")])
# Убит только тот, кому сигнал не доставлен.
self.assertEqual(self.terminated, [13])
def test_engine_that_ignores_signal_is_terminated_after_grace_period(self) -> None:
self.processes = {10: ("winws2.exe", OWN_EXE), 13: ("winws2.exe", OWN_EXE)}
self.deaf = {13}
result = process_control.stop_engine_processes([OWN_EXE], timeout=5.0)
self.assertTrue(result.ok)
self.assertEqual(result.stopped, (10, 13))
self.assertEqual(self.terminated, [13])
grace = process_control.ENGINE_GRACEFUL_STOP_SECONDS
# Сначала мягкое ожидание всех, затем только убитый, на оставшееся время.
self.assertEqual(self.waits, [((10, 13), grace), ((13,), 5.0 - grace)])
def test_every_opened_handle_is_closed(self) -> None:
self.processes = {10: ("winws2.exe", OWN_EXE), 11: ("winws2.exe", FOREIGN_EXE)}
process_control.stop_engine_processes([OWN_EXE])
self.assertEqual(sorted(self.closed), [100010, 100011])
def test_process_that_does_not_exit_is_a_failure(self) -> None:
self.processes = {10: ("winws2.exe", OWN_EXE), 13: ("winws2.exe", OWN_EXE)}
self.deaf = {13}
self.unkillable = {13}
result = process_control.stop_engine_processes([OWN_EXE])
self.assertFalse(result.ok)
self.assertEqual(result.stopped, (10,))
self.assertEqual(result.failed, (13,))
def test_failed_snapshot_is_not_reported_as_nothing_to_stop(self) -> None:
# Прежний код принимал сбой списка процессов за «процессов нет».
def broken():
raise ProcessSnapshotError("snapshot failed")
with patch.object(process_control, "iter_process_records_winapi_strict", broken):
result = process_control.stop_engine_processes([OWN_EXE])
self.assertFalse(result.ok)
self.assertTrue(result.snapshot_failed)
def test_process_that_cannot_be_verified_is_not_killed(self) -> None:
self.processes = {10: ("winws2.exe", OWN_EXE)}
self.open_errors = {10: winapi.ERROR_ACCESS_DENIED}
result = process_control.stop_engine_processes([OWN_EXE])
self.assertEqual(self.terminated, [])
self.assertEqual(self.signalled, [])
self.assertEqual([record.pid for record in result.foreign], [10])
def test_process_that_already_exited_is_ignored(self) -> None:
self.processes = {10: ("winws2.exe", OWN_EXE)}
self.open_errors = {10: winapi.ERROR_INVALID_PARAMETER}
result = process_control.stop_engine_processes([OWN_EXE])
self.assertTrue(result.ok)
self.assertEqual(result.stopped, ())
self.assertEqual(result.foreign, ())
def test_nothing_to_stop_is_success(self) -> None:
self.assertTrue(process_control.stop_engine_processes([OWN_EXE]).ok)
self.assertTrue(process_control.stop_engine_processes([]).ok)
class ReloadEngineListsTests(_FakeEngineWorld):
def test_hup_goes_only_to_own_engines(self) -> None:
self.processes = {
10: ("winws2.exe", OWN_EXE),
11: ("winws2.exe", FOREIGN_EXE),
12: ("explorer.exe", r"C:\Windows\explorer.exe"),
}
accepted = process_control.reload_engine_lists([OWN_EXE])
self.assertEqual(accepted, 1)
self.assertEqual(self.signalled, [(10, "hup")])
# Перечитывание списков не останавливает движок.
self.assertEqual(self.exited, set())
self.assertEqual(self.terminated, [])
self.assertEqual(sorted(self.closed), [100010, 100011])
def test_engine_without_event_is_not_counted(self) -> None:
self.processes = {10: ("winws2.exe", OWN_EXE), 13: ("winws2.exe", OWN_EXE)}
self.no_event = {13}
self.assertEqual(process_control.reload_engine_lists([OWN_EXE]), 1)
def test_nothing_to_reload_is_zero(self) -> None:
self.assertEqual(process_control.reload_engine_lists([OWN_EXE]), 0)
self.assertEqual(process_control.reload_engine_lists([]), 0)
def test_failed_snapshot_does_not_raise(self) -> None:
def broken():
raise ProcessSnapshotError("snapshot failed")
with patch.object(process_control, "iter_process_records_winapi_strict", broken):
self.assertEqual(process_control.reload_engine_lists([OWN_EXE]), 0)
def test_unexpected_errors_never_escape(self) -> None:
self.processes = {10: ("winws2.exe", OWN_EXE)}
def explode(_name):
raise RuntimeError("boom")
with patch.object(winapi, "signal_named_event", explode):
self.assertEqual(process_control.reload_engine_lists([OWN_EXE]), 0)
with patch.object(winapi, "open_process", side_effect=RuntimeError("boom")):
self.assertEqual(process_control.reload_engine_lists([OWN_EXE]), 0)
def test_process_that_cannot_be_verified_gets_no_signal(self) -> None:
self.processes = {10: ("winws2.exe", OWN_EXE)}
self.open_errors = {10: winapi.ERROR_ACCESS_DENIED}
self.assertEqual(process_control.reload_engine_lists([OWN_EXE]), 0)
self.assertEqual(self.signalled, [])
class WaitEngineReadyTests(unittest.TestCase):
def setUp(self) -> None:
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
self.output_path = os.path.join(self.directory.name, "startup.log")
def write(self, text: str, mode: str = "wb") -> None:
with open(self.output_path, mode) as stream:
stream.write(text.encode("utf-8"))
def test_ready_line_confirms_start(self) -> None:
self.write("github version v1\nwindivert initialized. capture is started.\n")
process = FakeProcess()
outcome = startup.wait_engine_ready(process, self.output_path, settle=0.35)
self.assertTrue(outcome.started)
self.assertEqual(outcome.reason, startup.REASON_READY)
self.assertTrue(outcome.marker_seen)
# После строки готовности — одно окно на ошибки Lua-скриптов.
self.assertEqual(process.wait_timeouts, [0.35])
def test_death_before_ready_line_is_a_failed_start(self) -> None:
self.write("github version v1\nwindivert: error opening filter: access denied\n")
process = FakeProcess(exits_after_waits=1, exit_code=5)
outcome = startup.wait_engine_ready(process, self.output_path)
self.assertFalse(outcome.started)
self.assertEqual(outcome.reason, startup.REASON_EXITED)
self.assertEqual(process.poll(), 5)
def test_death_right_after_ready_line_is_a_failed_start(self) -> None:
# Строка печатается до загрузки Lua: ошибка скрипта убивает процесс
# уже после неё.
self.write("windivert initialized. capture is started.\nLUA ERROR: boom\n")
process = FakeProcess(exits_after_waits=1, exit_code=87)
outcome = startup.wait_engine_ready(process, self.output_path)
self.assertFalse(outcome.started)
self.assertEqual(outcome.reason, startup.REASON_EXITED_AFTER_READY)
def test_ready_line_split_across_reads_is_found(self) -> None:
self.write("github version v1\nwindivert initi")
process = FakeProcess()
original_wait = process.wait
def wait_and_finish_line(timeout=None):
if len(process.wait_timeouts) == 0:
self.write("alized. capture is started.\n", mode="ab")
return original_wait(timeout=timeout)
process.wait = wait_and_finish_line
outcome = startup.wait_engine_ready(process, self.output_path)
self.assertTrue(outcome.started)
self.assertEqual(outcome.reason, startup.REASON_READY)
def test_alive_engine_without_ready_line_is_accepted_after_timeout(self) -> None:
# Не хуже прежнего правила «жив — значит запустился», но с пометкой.
self.write("github version v1\n")
process = FakeProcess()
ticks = iter(range(1000))
outcome = startup.wait_engine_ready(
process,
self.output_path,
ready_timeout=5.0,
clock=lambda: float(next(ticks)),
)
self.assertTrue(outcome.started)
self.assertEqual(outcome.reason, startup.REASON_ALIVE_WITHOUT_MARKER)
self.assertFalse(outcome.marker_seen)
def test_without_output_file_only_survival_is_checked(self) -> None:
process = FakeProcess()
outcome = startup.wait_engine_ready(process, "", alive_window=1.0)
self.assertTrue(outcome.started)
self.assertEqual(outcome.reason, startup.REASON_ALIVE_WITHOUT_MARKER)
self.assertEqual(process.wait_timeouts, [1.0])
def test_without_output_file_death_is_a_failed_start(self) -> None:
process = FakeProcess(exits_after_waits=1, exit_code=1)
outcome = startup.wait_engine_ready(process, "", alive_window=1.0)
self.assertFalse(outcome.started)
class WinApiBindingTests(unittest.TestCase):
def test_every_bound_function_declares_argument_types(self) -> None:
# Вызов без argtypes укладывал 64-битный хэндл службы в 32 бита и
# падал; хэндл оставался открытым и держал службу драйвера.
for library_name, table in winapi.signature_tables():
for name, (restype, argtypes) in table.items():
self.assertIsNotNone(restype, f"{library_name}.{name}")
self.assertIsInstance(argtypes, list, f"{library_name}.{name}")
def test_event_signalling_functions_are_bound_with_types(self) -> None:
tables = dict(winapi.signature_tables())
self.assertEqual(
tables["kernel32"]["OpenEventW"],
(winapi.HANDLE, [winapi.DWORD, winapi.BOOL, winapi.LPCWSTR]),
)
self.assertEqual(tables["kernel32"]["SetEvent"], (winapi.BOOL, [winapi.HANDLE]))
self.assertEqual(winapi.EVENT_MODIFY_STATE, 0x0002)
def test_signal_names_use_the_same_prefix_for_both_engines(self) -> None:
self.assertEqual(process_control.engine_signal_name(4321, "term"), r"Global\winws2_sig_4321_term")
self.assertEqual(process_control.engine_signal_name(7, "hup"), r"Global\winws2_sig_7_hup")
def test_handle_closing_functions_take_a_pointer_sized_handle(self) -> None:
tables = dict(winapi.signature_tables())
self.assertEqual(tables["advapi32"]["CloseServiceHandle"][1], [winapi.HANDLE])
self.assertEqual(tables["kernel32"]["CloseHandle"][1], [winapi.HANDLE])
self.assertEqual(ctypes.sizeof(winapi.HANDLE), ctypes.sizeof(ctypes.c_void_p))
def test_structures_have_windows_layout_on_any_platform(self) -> None:
self.assertEqual(ctypes.sizeof(winapi.DWORD), 4)
self.assertEqual(ctypes.sizeof(winapi.SERVICE_STATUS), 28)
self.assertEqual(ctypes.sizeof(winapi.QUERY_SERVICE_CONFIGW), 64)
def test_job_object_layout_and_bindings(self) -> None:
tables = dict(winapi.signature_tables())
self.assertEqual(tables["kernel32"]["AssignProcessToJobObject"], (winapi.BOOL, [winapi.HANDLE, winapi.HANDLE]))
self.assertEqual(tables["kernel32"]["CreateJobObjectW"][0], winapi.HANDLE)
self.assertEqual(winapi.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, 0x2000)
if ctypes.sizeof(ctypes.c_size_t) == 8:
# Размер JOBOBJECT_EXTENDED_LIMIT_INFORMATION в 64-битной Windows.
self.assertEqual(ctypes.sizeof(winapi.JOBOBJECT_EXTENDED_LIMIT_INFORMATION), 144)
def test_closing_handles_never_raises(self) -> None:
winapi.close_handle(None)
winapi.close_handle(0)
winapi._close_service_handle(None)
def test_private_libraries_are_used_not_the_shared_windll(self) -> None:
# Общий ctypes.windll делит прототипы функций между всеми модулями.
import inspect
self.assertNotIn("ctypes.windll", inspect.getsource(winapi).replace("``ctypes.windll``", ""))
if __name__ == "__main__":
unittest.main()