fix(socks5): 加后台 sync loop, 周期探活自动救活死 SOCKS5 实例
背景:
is_alive() 健康探活已就绪, 但 sync_instances 只能由 API 手动触发。
没人调 API 就没人探活, 死掉的 SOCKS5 实例一直挂着, DB.running 还是
true, 用户看到的就是'实例莫名停止'。这是最后一块拼图。
变更:
app.py
+ _start_sync_loop() 后台 daemon 线程, 周期调用 sync_instances
(默认 30s, SM_SYNC_INTERVAL 可调, SM_SYNC_LOOP_ENABLE
可关)。线程级单例, 多次 create_app 只启一次。
+ create_app 末尾调 _start_sync_loop + 初始 sync_instances, 保证
gunicorn worker 启动时就把实例拉起来
engine/instances.py
M is_alive() docstring 补充判定依据 (thread/server/is_serving)
测试:
+ tests/e2e_sync_loop.py create_app 启动 loop -> 插 DB 实例 -> loop 拉起
-> kill server socket -> loop 自动救活 + 换对象
本地 3 次连续运行全 PASS, 端口 ~4.5s 被救活
验证:
- smoke tunnel timeout: PASS
- e2e lifecycle (userpass + add_traffic): PASS
- e2e health check (sync_instances 探活): PASS
- e2e sync loop (后台自动救活): PASS
- app import: OK
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
"""测试 app._start_sync_loop 后台线程能否自动救活死 SOCKS5 实例。
|
||||
|
||||
只用一次 create_app(), 保证只有一个 InstanceManager + 一个 sync loop。
|
||||
让同一个 loop 既负责初次拉起实例, 也负责在实例死后自动救活。
|
||||
|
||||
流程:
|
||||
1. create_app() 启动 sync loop (SM_SYNC_INTERVAL=2s), 此时 DB 无实例
|
||||
2. 往 DB 插入一条 enabled 实例
|
||||
3. 等 loop 周期把它拉起来 (端口 listen)
|
||||
4. 模拟死: 关闭 server socket
|
||||
5. 等 loop 探测到并自动重启 (端口重新 listen, 且是新 Socks5Server 对象)
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.getcwd())
|
||||
|
||||
_tmp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False, dir="/tmp")
|
||||
_tmp_db.close()
|
||||
os.environ["SM_DB_URI"] = f"sqlite:///{_tmp_db.name}"
|
||||
os.environ["SM_ADMIN_PASSWORD"] = "test123"
|
||||
os.environ["SM_SECRET_KEY"] = "test-secret-key"
|
||||
os.environ["SM_LOG_LEVEL"] = "WARNING"
|
||||
# 短周期, 加快测试
|
||||
os.environ["SM_SYNC_INTERVAL"] = "2"
|
||||
os.environ["SM_SYNC_LOOP_ENABLE"] = "true"
|
||||
|
||||
from app import create_app
|
||||
from database import db as _db
|
||||
from models import Instance
|
||||
|
||||
|
||||
def find_free_port():
|
||||
s = socket.socket()
|
||||
s.bind(("127.0.0.1", 0))
|
||||
p = s.getsockname()[1]
|
||||
s.close()
|
||||
return p
|
||||
|
||||
|
||||
def is_listening(port):
|
||||
try:
|
||||
s = socket.socket(); s.settimeout(0.5)
|
||||
s.connect(("127.0.0.1", port)); s.close()
|
||||
return True
|
||||
except (ConnectionRefusedError, socket.timeout, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def wait_listening(port, timeout_s=8.0, step=0.3):
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < timeout_s:
|
||||
if is_listening(port):
|
||||
return True, time.time() - t0
|
||||
time.sleep(step)
|
||||
return False, time.time() - t0
|
||||
|
||||
|
||||
def main():
|
||||
logging.basicConfig(level=logging.WARNING,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
|
||||
port = find_free_port()
|
||||
|
||||
# 唯一一次 create_app: 启动 sync loop + 初始 sync_instances (此时 DB 无实例)
|
||||
flask_app = create_app()
|
||||
mgr = flask_app.instance_manager # type: ignore[attr-defined]
|
||||
|
||||
# 往 DB 插入 enabled 实例, 让 sync loop 在下一周期拉起它
|
||||
with flask_app.app_context():
|
||||
inst = Instance(
|
||||
name="looper", listen_host="127.0.0.1", listen_port=port,
|
||||
timeout=5, enabled=True, auth_method="none",
|
||||
bandwidth_down=0, bandwidth_up=0, max_concurrent=10,
|
||||
)
|
||||
_db.session.add(inst)
|
||||
_db.session.commit()
|
||||
print(f"[setup] inserted instance port={port}")
|
||||
|
||||
# 等 sync loop 初次拉起实例
|
||||
ok, elapsed = wait_listening(port, timeout_s=8.0)
|
||||
assert ok, f"sync loop 没在 8s 内拉起 port {port}"
|
||||
print(f"[1] sync loop 初次拉起 OK, port {port} listening (耗时 {elapsed:.1f}s)")
|
||||
|
||||
# 模拟死: 关闭 server socket
|
||||
srv = mgr._instances["looper"]
|
||||
srv.loop.call_soon_threadsafe(srv.server.close)
|
||||
time.sleep(0.6)
|
||||
assert not is_listening(port), f"关闭后 port {port} 仍在 listen"
|
||||
print(f"[2] killed port {port}, 等 sync loop 救活...")
|
||||
|
||||
# 等 sync loop 探测并重启
|
||||
ok, elapsed = wait_listening(port, timeout_s=8.0)
|
||||
assert ok, f"sync loop 没在 8s 内救活 port {port}"
|
||||
print(f"[3] port {port} 被 sync loop 救活 (耗时 {elapsed:.1f}s)")
|
||||
|
||||
# 验证是新的 Socks5Server 对象 (不是复用死掉的那个)
|
||||
new_srv = mgr._instances["looper"]
|
||||
assert new_srv is not srv, "sync loop 复用了死掉的 Socks5Server, 没换对象"
|
||||
print(f"[4] 确认是新 Socks5Server 对象 (旧 id={id(srv)}, 新 id={id(new_srv)})")
|
||||
|
||||
# 清理
|
||||
new_srv.stop()
|
||||
try: os.unlink(_tmp_db.name)
|
||||
except Exception: pass
|
||||
print("\nPASS: 后台 sync loop 自动救活死 SOCKS5 实例 (含对象替换)")
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
rc = main()
|
||||
sys.exit(0 if rc else 1)
|
||||
Reference in New Issue
Block a user