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
This commit is contained in:
cnbugs
2026-08-11 00:07:31 +08:00
parent eba7ae831f
commit 47714c9622
2 changed files with 140 additions and 2 deletions
+14 -2
View File
@@ -290,17 +290,28 @@ class InstanceManager:
# 端口占用等启动失败: 回滚状态, 清理缓存, 返回 False # 端口占用等启动失败: 回滚状态, 清理缓存, 返回 False
log.error("[%s] 启动实例失败: %s", inst.name, e) log.error("[%s] 启动实例失败: %s", inst.name, e)
self._config_cache.pop(inst.name, None) self._config_cache.pop(inst.name, None)
# enabled 保持 True: 用户意图是运行, sync loop 下周期会重试
# (端口冲突常是暂时的, 自动重试比让用户手动点更合理)
inst.enabled = True
inst.running = False inst.running = False
db.session.commit() db.session.commit()
raise raise
with self._lock: with self._lock:
self._instances[inst.name] = srv self._instances[inst.name] = srv
# enabled = 期望运行状态, 是 sync loop 的权威依据。
# 手动启动必须置 True, 否则下轮 sync 会把它当"多余实例"停掉。
inst.enabled = True
inst.running = True inst.running = True
db.session.commit() db.session.commit()
return True return True
def stop_instance(self, instance_id): def stop_instance(self, instance_id):
"""停止单个实例。""" """停止单个实例。
关键: 必须同时置 enabled=False。enabled 是 sync loop 判断"该实例是否
应该在跑"的唯一依据——只停进程不改 enabled, 30 秒内 sync loop 会把它
"死掉的实例"自动重启, 用户的手动停止就失效了。
"""
from models import Instance from models import Instance
from database import db from database import db
with db.app.app_context(): with db.app.app_context():
@@ -310,7 +321,8 @@ class InstanceManager:
srv = self._instances.pop(inst.name, None) srv = self._instances.pop(inst.name, None)
if srv: if srv:
srv.stop() srv.stop()
del self._config_cache[inst.name] self._config_cache.pop(inst.name, None)
inst.enabled = False # 手动停止: sync loop 不得自动重启
inst.running = False inst.running = False
inst.active_connections = 0 inst.active_connections = 0
db.session.commit() db.session.commit()
+126
View File
@@ -0,0 +1,126 @@
"""测试手动停止语义: 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)