Files
socks-manager/tests/e2e_stop_semantics.py
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

127 lines
4.2 KiB
Python

"""测试手动停止语义: stop_instance 后 sync loop 不得自动重启。
这是 eba7ae8 引入的回归: sync loop 周期调 sync_instances, 而
sync_instances 只看 DB 里 enabled=True 的实例。如果 stop_instance
只停进程不改 enabled, 30 秒内实例会被自动拉回来, 用户手动停止失效。
流程:
1. create_app (sync loop interval=2s)
2. 插 enabled 实例, 等 loop 拉起
3. 调 stop_instance(iid)
4. 等 5s (2 个 loop 周期), 断言端口始终不回来 + DB.enabled=False
5. 调 start_instance(iid), 断言端口回来 + DB.enabled=True
"""
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()
flask_app = create_app()
mgr = flask_app.instance_manager # type: ignore[attr-defined]
# 插入 enabled 实例
with flask_app.app_context():
inst = Instance(
name="semtest", 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()
iid = inst.id
print(f"[setup] inserted instance id={iid} 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 ({elapsed:.1f}s)")
# 手动停止
assert mgr.stop_instance(iid), "stop_instance 返回 False"
time.sleep(0.5)
assert not is_listening(port), "stop_instance 后端口仍 listen"
print(f"[2] stop_instance OK, port {port} 已停")
# 等 2 个 sync loop 周期, 断言不被自动拉回
t0 = time.time()
while time.time() - t0 < 5.0:
assert not is_listening(port), (
f"REGRESSION: stop_instance 后 {time.time()-t0:.1f}s 端口被 sync loop 自动拉回!"
)
time.sleep(0.3)
with flask_app.app_context():
i = Instance.query.get(iid)
assert i.enabled is False, f"stop_instance 后 DB.enabled={i.enabled}, 应为 False"
print(f"[3] 5s (2 个 loop 周期) 内未被自动重启, DB.enabled=False ✓")
# 手动启动, 应恢复
assert mgr.start_instance(iid), "start_instance 返回 False"
ok, elapsed = wait_listening(port, timeout_s=5.0)
assert ok, "start_instance 后端口未恢复"
with flask_app.app_context():
i = Instance.query.get(iid)
assert i.enabled is True, f"start_instance 后 DB.enabled={i.enabled}, 应为 True"
assert i.running is True, f"start_instance 后 DB.running={i.running}, 应为 True"
print(f"[4] start_instance OK, port {port} 恢复, DB.enabled=True ✓")
# 清理
mgr.stop_instance(iid)
try: os.unlink(_tmp_db.name)
except Exception: pass
print("\nPASS: 手动停止不被 sync loop 自动重启; 手动启动正常恢复")
return True
if __name__ == "__main__":
rc = main()
sys.exit(0 if rc else 1)