From 1f0d965ae3702eb98a964b090af2cc0c245b422a Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Sat, 29 Aug 2026 17:34:39 +0200 Subject: [PATCH 1/3] =?UTF-8?q?fix(arknights):=20=E8=BF=9E=E6=8E=A5?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E6=94=B9=E4=B8=BA=E6=8C=87=E6=95=B0=E9=80=80?= =?UTF-8?q?=E9=81=BF=E5=B9=B6=E5=8F=AA=E6=8F=90=E7=A4=BA=E4=B8=80=E6=AC=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Timer.minute_task` 每秒调用一次 `scheduled_task`,其中「未连接且窗口句柄 存在」这一分支没有任何退避、次数上限或放弃条件,只要窗口在但连不上(未进 主界面、controller 拒绝连接等)就会以秒级频率无限重试。每次失败都会 `logger.error` 并向前端推送一条错误通知,用户界面也随之被刷屏。 线上单台机器 24 小时内因此产生 68,558 条上报,占全组织事件量的 80.5%。 - 新增 `connect_retry_delay`:连续失败时 2/4/8… 秒指数退避,封顶 60 秒, 保证用户随时进入游戏后仍能在一分钟内自动接上 - 窗口句柄变化视为新的连接机会,清空退避 - 前端通知只在一轮失败的首次发出,重试期间不再重复弹窗 Co-Authored-By: Claude Opus 5 --- app/MaaFW/ArknightWin32.py | 73 +++++++++++++++----- res/version.json | 3 +- tests/task/test_arknights_connect_backoff.py | 29 ++++++++ 3 files changed, 85 insertions(+), 20 deletions(-) create mode 100644 tests/task/test_arknights_connect_backoff.py diff --git a/app/MaaFW/ArknightWin32.py b/app/MaaFW/ArknightWin32.py index 6cde5ef78..9ef17451b 100644 --- a/app/MaaFW/ArknightWin32.py +++ b/app/MaaFW/ArknightWin32.py @@ -46,14 +46,38 @@ logger = get_logger("明日方舟PC工具") +# 定时任务每秒触发一次,连接失败后若不退避就会以秒级频率反复重试并刷屏; +# 上限取 60 秒,保证用户随时进入游戏后仍能在一分钟内自动接上。 +CONNECT_RETRY_BASE_SECONDS = 2.0 +CONNECT_RETRY_MAX_SECONDS = 60.0 -class _ArknightWin32Toolkit: +def connect_retry_delay(failures: int) -> float: + """ + 按连续失败次数计算下次重试前的等待秒数 + + Args: + failures: 已连续失败的次数, 首次失败传 1 + + Returns: + float: 等待秒数, 指数增长并封顶到 ``CONNECT_RETRY_MAX_SECONDS`` + """ + + return min( + CONNECT_RETRY_BASE_SECONDS * 2 ** max(failures - 1, 0), + CONNECT_RETRY_MAX_SECONDS, + ) + + +class _ArknightWin32Toolkit: def __init__(self): self.arknights_hwnd = -1 self.arknights_window = None + self.connect_failures = 0 + self.next_connect_attempt = 0.0 + self.tasker = Tasker() self.listener = keyboard.Listener() @@ -101,8 +125,9 @@ async def scheduled_task(self) -> None: new_hwnd = win32gui.FindWindow(None, "明日方舟") if self.arknights_hwnd != new_hwnd: - self.arknights_hwnd = new_hwnd + # 窗口发生变化意味着新的连接机会,清掉上一轮的退避 + self.reset_connect_backoff() if new_hwnd == 0: logger.warning("未检测到明日方舟窗口,暂停任务器") @@ -112,9 +137,19 @@ async def scheduled_task(self) -> None: else: await self.connect_arknights() - if not self.get_connect_status() and self.arknights_hwnd > 0: + if ( + not self.get_connect_status() + and self.arknights_hwnd > 0 + and time.monotonic() >= self.next_connect_attempt + ): await self.connect_arknights() + def reset_connect_backoff(self) -> None: + """清空连接失败计数与退避窗口""" + + self.connect_failures = 0 + self.next_connect_attempt = 0.0 + def get_connect_status(self) -> bool: """获取连接状态""" @@ -146,15 +181,24 @@ async def connect_arknights(self) -> None: keyboard_method=MaaWin32InputMethodEnum.Seize, ) logger.success("已连接到明日方舟") + self.reset_connect_backoff() except Exception as e: - logger.error(f"连接明日方舟失败: {e}") - await Publisher.send( - id=protocol.ID_ARKNIGHTS_PC_TOOLKIT, - type=protocol.TOOLKIT_NOTICE, - data=WSTaskNoticeData( - level="error", message=f"无法连接明日方舟: {str(e)}" - ), + self.connect_failures += 1 + delay = connect_retry_delay(self.connect_failures) + self.next_connect_attempt = time.monotonic() + delay + logger.error( + f"连接明日方舟失败(第 {self.connect_failures} 次," + f"{delay:.0f} 秒后重试): {e}" ) + # 仅首次失败提示用户,避免重试期间反复弹出同一条通知 + if self.connect_failures == 1: + await Publisher.send( + id=protocol.ID_ARKNIGHTS_PC_TOOLKIT, + type=protocol.TOOLKIT_NOTICE, + data=WSTaskNoticeData( + level="error", message=f"无法连接明日方舟: {str(e)}" + ), + ) def on_key_release(self, key: keyboard.Key | keyboard.KeyCode | None) -> None: """pynput 回调""" @@ -232,7 +276,6 @@ def get_pause_position(self): @MaaFWManager.resource.custom_action("PlaySelectDeployed[ArknightsPC]") class PlaySelectDeployed(CustomAction): - def run(self, context: Context, argv: CustomAction.RunArg) -> bool: logger.info("开始执行战斗时选中已部署干员动作") @@ -257,7 +300,6 @@ def run(self, context: Context, argv: CustomAction.RunArg) -> bool: @MaaFWManager.resource.custom_action("PauseSelectDeployed[ArknightsPC]") class PauseSelectDeployed(CustomAction): - def run(self, context: Context, argv: CustomAction.RunArg) -> bool: logger.info("开始执行暂停时选中已部署干员动作") @@ -284,7 +326,6 @@ def run(self, context: Context, argv: CustomAction.RunArg) -> bool: @MaaFWManager.resource.custom_action("PlaySkill[ArknightsPC]") class PlaySkill(CustomAction): - def run(self, context: Context, argv: CustomAction.RunArg) -> bool: logger.info("开始执行战斗时释放技能动作") @@ -306,7 +347,6 @@ def run(self, context: Context, argv: CustomAction.RunArg) -> bool: @MaaFWManager.resource.custom_action("PauseSkill[ArknightsPC]") class PauseSkill(CustomAction): - def run(self, context: Context, argv: CustomAction.RunArg) -> bool: logger.info("开始执行暂停时释放技能动作") @@ -332,7 +372,6 @@ def run(self, context: Context, argv: CustomAction.RunArg) -> bool: @MaaFWManager.resource.custom_action("PlayRetreat[ArknightsPC]") class PlayRetreat(CustomAction): - def run(self, context: Context, argv: CustomAction.RunArg) -> bool: logger.info("开始执行战斗时撤退干员动作") @@ -355,7 +394,6 @@ def run(self, context: Context, argv: CustomAction.RunArg) -> bool: @MaaFWManager.resource.custom_action("PauseRetreat[ArknightsPC]") class PauseRetreat(CustomAction): - def run(self, context: Context, argv: CustomAction.RunArg) -> bool: logger.info("开始执行暂停时撤退干员动作") @@ -381,7 +419,6 @@ def run(self, context: Context, argv: CustomAction.RunArg) -> bool: @MaaFWManager.resource.custom_action("NextFrame-0.2x[ArknightsPC]") class NextFrame_0_2x(CustomAction): - def run(self, context: Context, argv: CustomAction.RunArg) -> bool: logger.info("开始执行0.2倍速下一帧动作") @@ -406,7 +443,6 @@ def run(self, context: Context, argv: CustomAction.RunArg) -> bool: @MaaFWManager.resource.custom_action("NextFrame-1x[ArknightsPC]") class NextFrame_1x(CustomAction): - def run(self, context: Context, argv: CustomAction.RunArg) -> bool: logger.info("开始执行1倍速下一帧动作") @@ -431,7 +467,6 @@ def run(self, context: Context, argv: CustomAction.RunArg) -> bool: @MaaFWManager.resource.custom_action("NextFrame-2x[ArknightsPC]") class NextFrame_2x(CustomAction): - def run(self, context: Context, argv: CustomAction.RunArg) -> bool: logger.info("开始执行2倍速下一帧动作") diff --git a/res/version.json b/res/version.json index 7c3eb6f78..f9bec8da7 100644 --- a/res/version.json +++ b/res/version.json @@ -11,7 +11,8 @@ ], "开发流程": [], "修复BUG": [ - "修复 Mirror 酱一次性下载地址被版本检查缓存复用导致更新包下载失败的问题 by [@qiyinxi](https://github.com/qiyinxi)" + "修复 Mirror 酱一次性下载地址被版本检查缓存复用导致更新包下载失败的问题 by [@qiyinxi](https://github.com/qiyinxi)", + "修复明日方舟PC工具连接失败后每秒重试并反复弹出错误提示的问题" ] }, "v5.5.0-beta.1": { diff --git a/tests/task/test_arknights_connect_backoff.py b/tests/task/test_arknights_connect_backoff.py new file mode 100644 index 000000000..37450941e --- /dev/null +++ b/tests/task/test_arknights_connect_backoff.py @@ -0,0 +1,29 @@ +import unittest + +from app.MaaFW.ArknightWin32 import ( + CONNECT_RETRY_BASE_SECONDS, + CONNECT_RETRY_MAX_SECONDS, + connect_retry_delay, +) + + +class ConnectRetryDelayTest(unittest.TestCase): + def test_first_failure_waits_base_interval(self): + self.assertEqual(connect_retry_delay(1), CONNECT_RETRY_BASE_SECONDS) + + def test_delay_grows_exponentially(self): + self.assertEqual(connect_retry_delay(2), CONNECT_RETRY_BASE_SECONDS * 2) + self.assertEqual(connect_retry_delay(3), CONNECT_RETRY_BASE_SECONDS * 4) + + def test_delay_is_capped(self): + self.assertEqual(connect_retry_delay(100), CONNECT_RETRY_MAX_SECONDS) + + def test_delay_never_below_base(self): + """失败次数异常传 0 或负数时不应退化成 0 秒间隔。""" + + self.assertEqual(connect_retry_delay(0), CONNECT_RETRY_BASE_SECONDS) + self.assertEqual(connect_retry_delay(-5), CONNECT_RETRY_BASE_SECONDS) + + +if __name__ == "__main__": + unittest.main() From 70d347110554f1ae834a23aed4e4b3d847ba68bc Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Tue, 1 Sep 2026 22:11:02 +0200 Subject: [PATCH 2/3] =?UTF-8?q?chore(arknights):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E9=80=80=E9=81=BF=E8=AE=A1=E7=AE=97=E7=9A=84=E8=BE=B9=E7=95=8C?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 tests/AGENTS.md 的提交规则,功能边界或 bug 边界的测试只用于本地验证, 不随 PR 提交;connect_retry_delay 的四个用例属于这一类。 Co-Authored-By: Claude Fable 5.1 --- tests/task/test_arknights_connect_backoff.py | 29 -------------------- 1 file changed, 29 deletions(-) delete mode 100644 tests/task/test_arknights_connect_backoff.py diff --git a/tests/task/test_arknights_connect_backoff.py b/tests/task/test_arknights_connect_backoff.py deleted file mode 100644 index 37450941e..000000000 --- a/tests/task/test_arknights_connect_backoff.py +++ /dev/null @@ -1,29 +0,0 @@ -import unittest - -from app.MaaFW.ArknightWin32 import ( - CONNECT_RETRY_BASE_SECONDS, - CONNECT_RETRY_MAX_SECONDS, - connect_retry_delay, -) - - -class ConnectRetryDelayTest(unittest.TestCase): - def test_first_failure_waits_base_interval(self): - self.assertEqual(connect_retry_delay(1), CONNECT_RETRY_BASE_SECONDS) - - def test_delay_grows_exponentially(self): - self.assertEqual(connect_retry_delay(2), CONNECT_RETRY_BASE_SECONDS * 2) - self.assertEqual(connect_retry_delay(3), CONNECT_RETRY_BASE_SECONDS * 4) - - def test_delay_is_capped(self): - self.assertEqual(connect_retry_delay(100), CONNECT_RETRY_MAX_SECONDS) - - def test_delay_never_below_base(self): - """失败次数异常传 0 或负数时不应退化成 0 秒间隔。""" - - self.assertEqual(connect_retry_delay(0), CONNECT_RETRY_BASE_SECONDS) - self.assertEqual(connect_retry_delay(-5), CONNECT_RETRY_BASE_SECONDS) - - -if __name__ == "__main__": - unittest.main() From 209f2225b3e6518e55d518096c6880d1caf9fb75 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Tue, 1 Sep 2026 22:26:00 +0200 Subject: [PATCH 3/3] =?UTF-8?q?fix(arknights):=20=E9=80=80=E9=81=BF?= =?UTF-8?q?=E6=8C=87=E6=95=B0=E5=85=88=E5=B0=81=E9=A1=B6=EF=BC=8C=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=201025=20=E6=AC=A1=E5=90=8E=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E6=BA=A2=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 连续失败次数一直累加时,2 ** (failures - 1) 在第 1025 次与 float 相乘会抛 OverflowError;异常从 except 块里抛出,会终止 timer.second_task,定时启动 队列也随之失效。按 60 秒上限算约 17 小时即可触发。 指数按上限推出的档位封顶(60 / 2 → 5),各档延迟不变,只是不再计算超大整数。 Co-Authored-By: Claude Fable 5.1 --- app/MaaFW/ArknightWin32.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/MaaFW/ArknightWin32.py b/app/MaaFW/ArknightWin32.py index 9ef17451b..cde97db4a 100644 --- a/app/MaaFW/ArknightWin32.py +++ b/app/MaaFW/ArknightWin32.py @@ -21,6 +21,7 @@ import os +import math import time import ctypes import psutil @@ -50,6 +51,11 @@ # 上限取 60 秒,保证用户随时进入游戏后仍能在一分钟内自动接上。 CONNECT_RETRY_BASE_SECONDS = 2.0 CONNECT_RETRY_MAX_SECONDS = 60.0 +# 指数要先封顶:失败次数一直累加时 2 ** (failures - 1) 会在第 1025 次转 float 溢出, +# 异常从 except 块里抛出会直接终止每秒定时任务;封顶到刚越过上限的那一档即可。 +_CONNECT_RETRY_MAX_EXPONENT = math.ceil( + math.log2(CONNECT_RETRY_MAX_SECONDS / CONNECT_RETRY_BASE_SECONDS) +) def connect_retry_delay(failures: int) -> float: @@ -63,8 +69,9 @@ def connect_retry_delay(failures: int) -> float: float: 等待秒数, 指数增长并封顶到 ``CONNECT_RETRY_MAX_SECONDS`` """ + exponent = min(max(failures - 1, 0), _CONNECT_RETRY_MAX_EXPONENT) return min( - CONNECT_RETRY_BASE_SECONDS * 2 ** max(failures - 1, 0), + CONNECT_RETRY_BASE_SECONDS * 2**exponent, CONNECT_RETRY_MAX_SECONDS, )