Files
socks-manager/engine/instances.py
T
cnbugs 47714c9622 fix(socks5): stop_instance 置 enabled=False, 防 sync loop 自动重启用户手动停止的实例
回归 (eba7ae8 引入):
  sync loop 周期调 sync_instances, 它只看 DB 里 enabled=True 的实例。
  但 stop_instance 只停进程 + 设 running=False, 不改 enabled。
  结果: 用户手动停止实例后, 30 秒内 sync loop 会把它当'死掉的实例'
  自动拉回来——手动停止完全失效。

语义修正:
  enabled = 期望运行状态, 是 sync loop 判断'该实例是否应该在跑'的唯一权威。
  - stop_instance: enabled=False (手动停止, sync loop 不得自动重启)
  - start_instance: enabled=True (手动启动, 否则下轮 sync 会把它当多余停掉)
  - start 失败: enabled 保持 True (端口冲突常是暂时的, sync loop 自动重试)

测试:
  + tests/e2e_stop_semantics.py
    stop 后等 2 个 loop 周期 (5s), 断言端口不被自动拉回 + DB.enabled=False;
    start 后断言端口恢复 + DB.enabled=True + DB.running=True
  本地 PASS

全套回归:
  smoke tunnel / e2e lifecycle / e2e health / e2e sync loop / e2e stop semantics
  全部 PASS
2026-08-11 00:07:31 +08:00

360 lines
13 KiB
Python
Raw 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.
"""多实例管理器:每个实例是一个独立的 asyncio 服务器。"""
import asyncio
import logging
import threading
import time
from dataclasses import dataclass, field
from engine.server import ConnectionHandler
log = logging.getLogger("socks.instances")
@dataclass
class InstanceConfig:
"""单个实例的配置快照(运行时使用)。"""
instance_id: int
name: str
listen_host: str
listen_port: int
timeout: int
auth_method: str
bandwidth_down: float
bandwidth_up: float
max_concurrent: int
def match(self, new_name, new_host, new_port):
return (self.name == new_name and
self.listen_host == new_host and
self.listen_port == new_port)
class Socks5Server:
"""单个 SOCKS5 服务器的 asyncio 事件循环。"""
def __init__(self, config: InstanceConfig, user_service, flask_app=None):
self.config = config
self.user_service = user_service
self.flask_app = flask_app
self.server = None
self.loop = None
self.thread = None
self.running = False
self._active_connections = 0
self._lock = threading.Lock()
def start(self):
"""启动服务器(在新线程中运行独立事件循环)。"""
if self.running:
return
self.loop = asyncio.new_event_loop()
asyncio.set_event_loop(self.loop)
self._active_connections = 0
self.running = True
self.server = None
self._start_error = None # 用于在主线程透传子线程异常
def run_loop():
asyncio.set_event_loop(self.loop)
try:
self.server = self.loop.run_until_complete(
self._create_server()
)
self.loop.run_forever()
except Exception as e:
# 端口占用等异常:保存异常并停止循环,避免主线程死等
self._start_error = e
log.error("[%s] SOCKS5 服务器启动失败: %s",
self.config.name, e)
try:
self.loop.stop()
except Exception:
pass
self.thread = threading.Thread(target=run_loop, daemon=True,
name=f"socks5-{self.config.name}")
self.thread.start()
# 等 server 创建完成 (或失败) — 设了上限避免永久死等
deadline = time.time() + 5.0
while self.server is None and self.running and self._start_error is None:
time.sleep(0.05)
if time.time() > deadline:
log.error("[%s] SOCKS5 服务器启动超时", self.config.name)
self.running = False
break
# 如果子线程失败, 把异常抛回调用方
if self._start_error is not None:
self.running = False
raise self._start_error
if self.server:
log.info("[%s] SOCKS5 服务器已启动: %s:%d",
self.config.name, self.config.listen_host, self.config.listen_port)
def stop(self):
"""停止服务器。"""
if not self.running:
return
self.running = False
if self.server and self.loop:
# 在子线程事件循环中优雅关闭服务器
import concurrent.futures
async def _shutdown():
self.server.close()
await self.server.wait_closed()
try:
future = asyncio.run_coroutine_threadsafe(_shutdown(), self.loop)
future.result(timeout=5) # 最多等待5秒
except (concurrent.futures.TimeoutError, Exception):
pass
if self.loop:
try:
self.loop.call_soon_threadsafe(self.loop.stop)
except Exception:
pass
if self.thread:
self.thread.join(timeout=5)
log.info("[%s] SOCKS5 服务器已停止", self.config.name)
async def _create_server(self):
return await asyncio.start_server(
self._accept_connection,
self.config.listen_host, self.config.listen_port
)
async def _accept_connection(self, reader, writer):
try:
with self._lock:
self._active_connections += 1
handler = ConnectionHandler(
reader, writer, self.config, self.user_service,
flask_app=self.flask_app,
on_close_cb=self._on_connection_close
)
await handler.handle()
except (asyncio.CancelledError, GeneratorExit):
# 服务器关闭时正常取消,不记录错误
pass
finally:
try:
writer.close()
except Exception:
pass
def _on_connection_close(self, handler):
with self._lock:
self._active_connections = max(0, self._active_connections - 1)
@property
def active_connections(self):
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 是谎言。
判定:
- thread 死了 -> 死 (无法恢复)
- server is None -> 死 (还没起来)
- server is_serving() == False -> 死 (socket 已关, asyncio.Server
不能复用同一个对象重新 serve, 必须新 Socks5Server)
"""
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
if not self.server.is_serving():
return False
return True
import time # noqa: E402
class InstanceManager:
"""管理所有 SOCKS5 实例。"""
def __init__(self, user_service, flask_app=None):
self.user_service = user_service
self.flask_app = flask_app
self._instances = {} # name -> Socks5Server
self._config_cache = {} # name -> InstanceConfig
self._lock = threading.Lock()
def reload_config(self):
"""从数据库重新加载实例配置。"""
from models import Instance
from database import db
new_configs = {}
with db.app.app_context():
for inst in Instance.query.filter_by(enabled=True).all():
new_configs[inst.name] = InstanceConfig(
instance_id=inst.id,
name=inst.name,
listen_host=inst.listen_host,
listen_port=inst.listen_port,
timeout=inst.timeout,
auth_method=inst.auth_method,
bandwidth_down=inst.bandwidth_down,
bandwidth_up=inst.bandwidth_up,
max_concurrent=inst.max_concurrent,
)
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()
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)
try:
srv.start()
except OSError as e:
log.error("[%s] 跳过启动(端口冲突?: %s", name, e)
continue
with self._lock:
self._instances[name] = srv
# 停止多余的实例
for name in running_names - current_names:
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.is_alive()
if srv:
inst.active_connections = srv.active_connections
else:
inst.active_connections = 0
db.session.commit()
def start_instance(self, instance_id):
"""启动单个实例。"""
from models import Instance
from database import db
with db.app.app_context():
inst = Instance.query.get(instance_id)
if not inst:
return False
cfg = InstanceConfig(
instance_id=inst.id,
name=inst.name,
listen_host=inst.listen_host,
listen_port=inst.listen_port,
timeout=inst.timeout,
auth_method=inst.auth_method,
bandwidth_down=inst.bandwidth_down,
bandwidth_up=inst.bandwidth_up,
max_concurrent=inst.max_concurrent,
)
self._config_cache[inst.name] = cfg
srv = Socks5Server(cfg, self.user_service, self.flask_app)
try:
srv.start()
except OSError as e:
# 端口占用等启动失败: 回滚状态, 清理缓存, 返回 False
log.error("[%s] 启动实例失败: %s", inst.name, e)
self._config_cache.pop(inst.name, None)
# enabled 保持 True: 用户意图是运行, sync loop 下周期会重试
# (端口冲突常是暂时的, 自动重试比让用户手动点更合理)
inst.enabled = True
inst.running = False
db.session.commit()
raise
with self._lock:
self._instances[inst.name] = srv
# enabled = 期望运行状态, 是 sync loop 的权威依据。
# 手动启动必须置 True, 否则下轮 sync 会把它当"多余实例"停掉。
inst.enabled = True
inst.running = True
db.session.commit()
return True
def stop_instance(self, instance_id):
"""停止单个实例。
关键: 必须同时置 enabled=False。enabled 是 sync loop 判断"该实例是否
应该在跑"的唯一依据——只停进程不改 enabled, 30 秒内 sync loop 会把它
"死掉的实例"自动重启, 用户的手动停止就失效了。
"""
from models import Instance
from database import db
with db.app.app_context():
inst = Instance.query.get(instance_id)
if not inst:
return False
srv = self._instances.pop(inst.name, None)
if srv:
srv.stop()
self._config_cache.pop(inst.name, None)
inst.enabled = False # 手动停止: sync loop 不得自动重启
inst.running = False
inst.active_connections = 0
db.session.commit()
return True
def restart_instance(self, instance_id):
"""重启单个实例。"""
self.stop_instance(instance_id)
return self.start_instance(instance_id)
def get_status(self):
"""获取所有实例状态。"""
from models import Instance
from database import db
with db.app.app_context():
result = []
for inst in Instance.query.all():
srv = self._instances.get(inst.name)
result.append({
"id": inst.id,
"name": inst.name,
"host": inst.listen_host,
"port": inst.listen_port,
"enabled": inst.enabled,
"running": bool(srv and srv.running),
"active_connections": srv.active_connections if srv else 0,
"auth_method": inst.auth_method,
"timeout": inst.timeout,
"bandwidth_down": inst.bandwidth_down,
"bandwidth_up": inst.bandwidth_up,
"max_concurrent": inst.max_concurrent,
"notes": inst.notes,
})
return result