From 47714c96224b51dc666be4ae6c543d92a22d47d5 Mon Sep 17 00:00:00 2001 From: cnbugs Date: Tue, 11 Aug 2026 00:07:31 +0800 Subject: [PATCH] =?UTF-8?q?fix(socks5):=20stop=5Finstance=20=E7=BD=AE=20en?= =?UTF-8?q?abled=3DFalse,=20=E9=98=B2=20sync=20loop=20=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E9=87=8D=E5=90=AF=E7=94=A8=E6=88=B7=E6=89=8B=E5=8A=A8=E5=81=9C?= =?UTF-8?q?=E6=AD=A2=E7=9A=84=E5=AE=9E=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 回归 (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 --- engine/instances.py | 16 ++++- tests/e2e_stop_semantics.py | 126 ++++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 tests/e2e_stop_semantics.py diff --git a/engine/instances.py b/engine/instances.py index 1ea1c34..17b216f 100644 --- a/engine/instances.py +++ b/engine/instances.py @@ -290,17 +290,28 @@ class InstanceManager: # 端口占用等启动失败: 回滚状态, 清理缓存, 返回 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(): @@ -310,7 +321,8 @@ class InstanceManager: srv = self._instances.pop(inst.name, None) if srv: srv.stop() - del self._config_cache[inst.name] + self._config_cache.pop(inst.name, None) + inst.enabled = False # 手动停止: sync loop 不得自动重启 inst.running = False inst.active_connections = 0 db.session.commit() diff --git a/tests/e2e_stop_semantics.py b/tests/e2e_stop_semantics.py new file mode 100644 index 0000000..ebdf40e --- /dev/null +++ b/tests/e2e_stop_semantics.py @@ -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)