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:
ddadminandClaude Fable 5 committed 2026-07-20 07:02:33 -04:00
1 parent f2d303c233
commit 5ace0cfae0
6 files changed
+890 -100

No files matched your search

+84
View File
@@ -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()