fix(socks5): 加 Socks5Server.is_alive() 健康探活, sync_instances 自动重启死实例

背景:
  Socks5Server.running 是乐观标记, start() 之后到 stop() 之前一直 True。
  但子线程 event loop 崩了/端口被外部抢占/worker 被 gunicorn 杀 等情况,
  self.running 仍为 True, DB 里 inst.running=true 是谎言。
  上次 40080 实例莫名停止就是这种状态。

变更:
  + Socks5Server.is_alive()    thread.is_alive() + server.is_serving()
  M InstanceManager.sync_instances  启动前先探活, 死的从 _instances 摘掉
                                  让'启动缺失'循环用 DB 配置重建它
  M inst.running = srv.is_alive()  不再用乐观的 srv.running

测试:
  + tests/e2e_socks5_lifecycle.py   完整 SOCKS5 userpass 生命周期, 拦住
                                   _record_stats 里 await 同步方法的回归
  + tests/e2e_health_check.py      模拟 Socks5Server 死了, sync_instances
                                   自动重启; 反向验证: 注释掉健康检查就 fail

验证:
  - 正向 (修复在): lifecycle PASS, health PASS, smoke tunnel PASS
  - 反向 (回滚修复): 两个 e2e 都正确 FAIL, 证明测试真的能抓回归
This commit is contained in:
cnbugs
2026-08-10 23:45:29 +08:00
parent 22b9427ca5
commit 74a68fcbd0
3 changed files with 356 additions and 4 deletions
+41 -4
View File
@@ -150,6 +150,27 @@ class Socks5Server:
with self._lock:
return self._active_connections
def is_alive(self):
"""真实健康检查: 线程活 + server socket 在服务。
self.running 是乐观标记, start() 之后一直为 True 直到 stop() 被显式调用。
如果子线程 event loop 崩了 (例如 _record_stats 抛 TypeError 把 worker 卡死,
上游 gunicorn timeout kill 进程, 或端口被外部抢占), self.running 还是
True 但 server 实际不再 accept。
调用方应据此重启实例, 否则 DB 里 running=true 是谎言。
"""
if not self.running:
return False
if self.thread is None or not self.thread.is_alive():
return False
if self.server is None:
return False
# server.is_serving() 反映 socket 是否在 listen, 比检查 fd 状态靠谱
if not self.server.is_serving():
return False
return True
import time # noqa: E402
@@ -185,7 +206,12 @@ class InstanceManager:
self._config_cache = new_configs
def sync_instances(self):
"""根据数据库配置同步实例运行状态。"""
"""根据数据库配置同步实例运行状态。
健康检查: 对每个 _instances[name], 调用 is_alive() 验证线程 + server
socket 都活着。如果死了, 从 _instances 删掉, 用同一个 config 重新启动。
避免 40080 那种'DB 写 running=true 但端口没人 listen'的谎言状态。
"""
from models import Instance
from database import db
self.reload_config()
@@ -193,7 +219,18 @@ class InstanceManager:
current_names = set(self._config_cache.keys())
running_names = set(self._instances.keys())
# 启动缺失的实例
# 健康检查: 把死的从 _instances 摘掉, 让下面的'启动缺失'逻辑接管。
# _config_cache 是从 DB reload 的权威配置, 不能动它, 否则重启循环拿不到 cfg。
dead = []
for name, srv in self._instances.items():
if not srv.is_alive():
log.warning("[%s] SOCKS5 进程不健康 (thread/socket dead), 标记待重启", name)
dead.append(name)
for name in dead:
self._instances.pop(name, None)
running_names = set(self._instances.keys())
# 启动缺失的实例 (含刚被健康检查踢出来的)
for name in current_names - running_names:
cfg = self._config_cache[name]
srv = Socks5Server(cfg, self.user_service, self.flask_app)
@@ -210,11 +247,11 @@ class InstanceManager:
srv = self._instances.pop(name)
srv.stop()
# 更新实例运行状态到数据库
# 更新实例运行状态到数据库 (用 is_alive() 而不是乐观 running 标记)
with db.app.app_context():
for inst in Instance.query.all():
srv = self._instances.get(inst.name)
inst.running = srv is not None and srv.running
inst.running = srv is not None and srv.is_alive()
if srv:
inst.active_connections = srv.active_connections
else: