feat(xray-proxy): добавлен безопасный жизненный цикл управляемого Xray
- Зачем:
- stop/status опирались на обход argv и могли счесть чужой или
переиспользованный PID управляемым процессом, а параллельный start
и аварийная гибель супервизора не имели определённого поведения.
- Что:
- введена модель идентичности: PID + starttime (поле 22 /proc/PID/stat),
/proc/PID/exe для ребёнка, канонический путь скрипта для супервизора,
boot_id как граница загрузки; перечитка перед каждым сигналом.
- добавлены lifetime-flock супервизора, три исхода проверки состояния
(здоровая пара / подтверждённый сирота / непроверяемое), отказ при
занятом порте пробным bind и очистка stale только под lock.
- тесты расширены до 49 (blank flock, подмена starttime, чужой exe,
boot_id, SIGKILL супервизора, занятый порт, парсер stat); спека
приведена к модели идентичности; тикет 03 закрыт, леджер дополнен.
- Проверка:
- python3 -m unittest discover -s tests — 49 OK.
- bash tests/test_proxy_launchers.sh — 8 PASS.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f2d303c233
commit
5ace0cfae0
6 files changed
+890
-100
No files matched your search
@@ -1,10 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
import runpy
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -154,6 +156,88 @@ class XrayProxyCliTest(unittest.TestCase):
|
||||
self.assertIn("требует Python 3.8", result.stderr)
|
||||
self.assertIn("Python 3.7", result.stderr)
|
||||
|
||||
def test_proc_stat_parser_handles_spaces_and_closing_parentheses_in_comm(self):
|
||||
module = runpy.run_path(str(CLI))
|
||||
parse_starttime = module["parse_proc_stat_starttime"]
|
||||
tail = ["S"] + [str(value) for value in range(4, 22)] + ["987654"]
|
||||
|
||||
for comm in ("worker", "worker name", "worker name ) with parens"):
|
||||
with self.subTest(comm=comm):
|
||||
self.assertEqual(
|
||||
987654,
|
||||
parse_starttime("321 ({}) ".format(comm) + " ".join(tail)),
|
||||
)
|
||||
for malformed in ("321 worker S 1 2", "321 (worker) S 1 2"):
|
||||
with self.subTest(malformed=malformed):
|
||||
with self.assertRaises(ValueError):
|
||||
parse_starttime(malformed)
|
||||
|
||||
def test_process_permission_error_is_not_treated_as_dead(self):
|
||||
module = runpy.run_path(str(CLI))
|
||||
with mock.patch.object(module["Path"], "stat", side_effect=PermissionError()):
|
||||
self.assertTrue(module["process_alive"](12345))
|
||||
|
||||
def test_same_pid_cannot_be_a_healthy_managed_pair(self):
|
||||
module = runpy.run_path(str(CLI))
|
||||
function_globals = module["managed_state_outcome"].__globals__
|
||||
state = {"supervisor_pid": 12345, "xray_pid": 12345, "boot_id": "boot"}
|
||||
replacements = {
|
||||
"read_boot_id": mock.Mock(return_value="boot"),
|
||||
"process_alive": mock.Mock(return_value=True),
|
||||
"supervisor_identity_matches": mock.Mock(return_value=True),
|
||||
"xray_identity_matches": mock.Mock(return_value=True),
|
||||
}
|
||||
with mock.patch.dict(function_globals, replacements):
|
||||
self.assertEqual("unverifiable", module["managed_state_outcome"](state))
|
||||
|
||||
def test_identity_reads_starttime_after_process_paths(self):
|
||||
module = runpy.run_path(str(CLI))
|
||||
function_globals = module["xray_identity_matches"].__globals__
|
||||
state = {
|
||||
"supervisor_pid": 101,
|
||||
"supervisor_starttime": 201,
|
||||
"supervisor_path": "/cli",
|
||||
"xray_pid": 102,
|
||||
"xray_starttime": 202,
|
||||
"xray_path": "/xray",
|
||||
}
|
||||
|
||||
xray_events = []
|
||||
xray_replacements = {
|
||||
"process_executable": lambda _pid: xray_events.append("exe") or "/xray",
|
||||
"process_starttime": lambda _pid: xray_events.append("stat") or 202,
|
||||
}
|
||||
with mock.patch.dict(function_globals, xray_replacements):
|
||||
self.assertTrue(module["xray_identity_matches"](state))
|
||||
self.assertEqual(["exe", "stat"], xray_events)
|
||||
|
||||
supervisor_events = []
|
||||
supervisor_replacements = {
|
||||
"process_cmdline": lambda _pid: supervisor_events.append("cmdline")
|
||||
or ["python", "/cli"],
|
||||
"process_argument_resolves_to": lambda *_args: supervisor_events.append(
|
||||
"script"
|
||||
)
|
||||
or True,
|
||||
"process_starttime": lambda _pid: supervisor_events.append("stat") or 201,
|
||||
}
|
||||
with mock.patch.dict(function_globals, supervisor_replacements):
|
||||
self.assertTrue(module["supervisor_identity_matches"](state))
|
||||
self.assertEqual(["cmdline", "script", "stat"], supervisor_events)
|
||||
|
||||
def test_initial_child_starttime_retries_while_child_is_alive(self):
|
||||
module = runpy.run_path(str(CLI))
|
||||
wait_for_starttime = module["wait_for_child_starttime"]
|
||||
function_globals = wait_for_starttime.__globals__
|
||||
child = mock.Mock(pid=12345)
|
||||
child.poll.return_value = None
|
||||
replacements = {
|
||||
"process_starttime": mock.Mock(side_effect=[None, 7654321]),
|
||||
"time": mock.Mock(),
|
||||
}
|
||||
with mock.patch.dict(function_globals, replacements):
|
||||
self.assertEqual(7654321, wait_for_starttime(child))
|
||||
|
||||
def test_broken_config_symlink_is_not_treated_as_missing_config(self):
|
||||
config_dir = self.config_home / "proxy"
|
||||
config_dir.mkdir()
|
||||
|
||||
Reference in new issue
Block a user