diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aba1da6..8bd4a3e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,9 +7,9 @@ on: workflow_dispatch: inputs: release_tag: - description: Existing formal tag to publish, for example v2.1.4 + description: Existing formal tag to publish, for example v2.1.5 required: true - default: v2.1.4 + default: v2.1.5 type: string concurrency: diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index ac8fac0..ee91489 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,12 +1,15 @@ -# AimiliVPN V2.1.4 正式版 +# AimiliVPN V2.1.5 正式版 -V2.1.4 包含 V2.1.3 的全部稳定性、安全性和节点识别改进,并修复正式版重新安装验收中发现的手动断开状态遗漏。 +V2.1.5 包含 V2.1.4 的全部稳定性、安全性和节点识别改进,并修复正式版重新安装验收中发现的失败切换恢复遗漏。 -## V2.1.4 热修复 +## V2.1.5 热修复 - 修复点击“断开连接”后 OpenVPN、活动节点和 table 100 已清理,但 Web API 的 `tunnel_ready` / `proxy_ready` 仍短暂保留为 `true` 的问题。 - 手动断开现在统一复用完整连接状态清理流程;活动节点、连接中状态、隧道、代理出口、出口 IP 与路由规则会同步归零。 - 新增手动断开状态回归测试,并在真实 VPS 上完成断开与重新连接复验。 +- 修复手动切换失败、原节点恢复失败且连续 3 个备用节点也失效后,日志提示后台重新加载但实际没有启动补位任务的问题。 +- 备用节点耗尽后现在会启动唯一后台节点补齐线程,按退避间隔重新同步并尝试连接,避免一直等到下一轮周期采集。 +- 新增自动切换耗尽回归测试,并在真实 VPS 上验证补位线程会创建且不会重复启动。 ## IP 类型识别 @@ -37,7 +40,7 @@ V2.1.4 包含 V2.1.3 的全部稳定性、安全性和节点识别改进,并 ## 验证与兼容性 -- 本地 Python 单元测试 52/52 通过,Python 编译、Dashboard JavaScript 语法、安装脚本语法和补丁格式检查通过。 +- 本地 Python 单元测试 53/53 通过,Python 编译、Dashboard JavaScript 语法、安装脚本语法和补丁格式检查通过。 - Ubuntu 22.04 x86_64 VPS 已完成真实卸载、重新安装、节点源、连接切换、代理出口、Web API、权限和路由残留验证。 - 发布流水线会在 Python 3.9、3.11、3.13 上运行完整测试。 - Docker 会分别构建验证 `linux/amd64`、`linux/386`、`linux/arm64`、`linux/arm/v7`,并发布统一多架构镜像清单。 @@ -50,8 +53,8 @@ V2.1.4 包含 V2.1.3 的全部稳定性、安全性和节点识别改进,并 ## 下载与更新 -- GitHub Release:`aimilivpn-v2.1.4-linux-source.tar.gz` 和 `sha256sums.txt`。 -- GHCR:`2.1.4`、`2.1`、`latest` 多架构镜像标签。 +- GitHub Release:`aimilivpn-v2.1.5-linux-source.tar.gz` 和 `sha256sums.txt`。 +- GHCR:`2.1.5`、`2.1`、`latest` 多架构镜像标签。 Python 源码安装更新: diff --git a/VERSION b/VERSION index 7d2ed7c..cd57a8b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.1.4 +2.1.5 diff --git a/install.sh b/install.sh index f46cd12..44f10a8 100644 --- a/install.sh +++ b/install.sh @@ -204,9 +204,9 @@ def get_app_version(): try: with open(os.path.join(INSTALL_DIR, "VERSION"), "r", encoding="utf-8") as f: version = f.read().strip().lstrip("vV") - return version or "2.1.4" + return version or "2.1.5" except Exception: - return "2.1.4" + return "2.1.5" def load_ui_cfg(): import json diff --git a/tests/test_manager_logic.py b/tests/test_manager_logic.py index 4954770..6617d40 100644 --- a/tests/test_manager_logic.py +++ b/tests/test_manager_logic.py @@ -631,6 +631,16 @@ class ManagerLogicTests(unittest.TestCase): connect_mock.assert_called_once_with("old-node") auto_switch_mock.assert_not_called() + def test_auto_switch_exhaustion_schedules_background_refill(self) -> None: + with ( + mock.patch.object(manager, "schedule_background_refill", return_value=True) as schedule_mock, + mock.patch.object(manager, "log_to_json") as log_mock, + ): + manager.auto_switch_node(attempt=3) + + schedule_mock.assert_called_once_with() + log_mock.assert_called_once_with("INFO", "Main", "连续自动切换失败,已启动唯一后台节点补齐任务") + def test_physical_interface_detection_is_cached(self) -> None: original_cache = manager.vpn_utils.physical_interface_cache manager.vpn_utils.physical_interface_cache = (None, 0.0) @@ -828,8 +838,8 @@ class ManagerLogicTests(unittest.TestCase): self.assertEqual(519, entries[-1]["index"]) def test_web_update_controls_only_expose_stable_main_channel(self) -> None: - self.assertEqual("2.1.4", manager.APP_VERSION) - self.assertEqual("V2.1.4 正式版", manager.APP_VERSION_LABEL) + self.assertEqual("2.1.5", manager.APP_VERSION) + self.assertEqual("V2.1.5 正式版", manager.APP_VERSION_LABEL) self.assertIn("检测更新", manager.INDEX_HTML) self.assertIn("/api/check_update", manager.INDEX_HTML) self.assertIn("/tree/main", manager.INDEX_HTML) @@ -874,7 +884,7 @@ class ManagerLogicTests(unittest.TestCase): def test_release_workflow_uses_full_patch_version(self) -> None: workflow_text = (manager.ROOT_DIR / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") - self.assertIn("default: v2.1.4", workflow_text) + self.assertIn("default: v2.1.5", workflow_text) self.assertIn("AimiliVPN V$(tr -d '\\r\\n' < VERSION) 正式版", workflow_text) self.assertNotIn("cut -d. -f1,2 VERSION", workflow_text) @@ -901,8 +911,8 @@ class ManagerLogicTests(unittest.TestCase): def test_latest_release_check_reports_current_formal_version(self) -> None: release = { - "tag_name": "v2.1.4", - "name": "AimiliVPN V2.1.4 正式版", + "tag_name": "v2.1.5", + "name": "AimiliVPN V2.1.5 正式版", "draft": False, "prerelease": False, } @@ -910,7 +920,7 @@ class ManagerLogicTests(unittest.TestCase): result = manager.check_latest_release() self.assertFalse(result["update_available"]) - self.assertEqual("V2.1.4 正式版", result["current_version_label"]) + self.assertEqual("V2.1.5 正式版", result["current_version_label"]) def test_latest_release_check_reports_source_update_command(self) -> None: release = {"tag_name": "v2.2.0", "draft": False, "prerelease": False} diff --git a/vpngate_manager.py b/vpngate_manager.py index 2c8e225..caeb363 100644 --- a/vpngate_manager.py +++ b/vpngate_manager.py @@ -141,7 +141,7 @@ UPDATE_COMMAND = ( ) ROOT_DIR = Path(sys.executable).resolve().parent if globals().get("__compiled__") else Path(__file__).resolve().parent -DEFAULT_APP_VERSION = "2.1.4" +DEFAULT_APP_VERSION = "2.1.5" try: _version_text = (ROOT_DIR / "VERSION").read_text(encoding="utf-8").strip() except OSError: @@ -2122,6 +2122,8 @@ def schedule_background_refill() -> bool: def auto_switch_node(attempt: int = 0) -> None: if attempt >= 3: print("[自动切换] 连续切换失败已达 3 次,停止切换以防止主线程死锁,将在后台重新加载节点...", flush=True) + if schedule_background_refill(): + log_to_json("INFO", "Main", "连续自动切换失败,已启动唯一后台节点补齐任务") return ui_cfg = load_ui_config() @@ -4165,12 +4167,12 @@ INDEX_HTML = r"""