Skip to content

fix(channel): keep qqbot token refresh retrying - #5414

Merged
wenshao merged 1 commit into
QwenLM:mainfrom
tt-a1i:fix/qqbot-token-refresh-retry
Jun 19, 2026
Merged

fix(channel): keep qqbot token refresh retrying#5414
wenshao merged 1 commit into
QwenLM:mainfrom
tt-a1i:fix/qqbot-token-refresh-retry

Conversation

@tt-a1i

@tt-a1i tt-a1i commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Summary

  • keep QQ Bot token refresh retrying every 60s after repeated token endpoint failures
  • stop the retry loop when the channel is disposed
  • add fake-timer coverage for repeated failures followed by recovery

Fixes #5411

Demo

N/A — background token refresh retry behavior covered by unit tests.

Test plan

  • npx -p node@22 node node_modules/vitest/vitest.mjs run --coverage.enabled=false packages/channels/qqbot/src/send.test.ts packages/channels/qqbot/src/api.test.ts
  • npx eslint packages/channels/qqbot/src/QQChannel.ts packages/channels/qqbot/src/send.test.ts
  • npx -p node@22 node node_modules/typescript/bin/tsc --noEmit --project packages/channels/qqbot/tsconfig.json
  • npx prettier --check packages/channels/qqbot/src/QQChannel.ts packages/channels/qqbot/src/send.test.ts
  • git diff --check

AI Assistance Disclosure

I used Codex to review the changes, sanity-check the implementation against existing patterns, and help spot potential edge cases.

@tt-a1i
tt-a1i marked this pull request as ready for review June 19, 2026 14:34
@wenshao

wenshao commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean extraction of token refresh retry logic. The recursive scheduleTokenRefreshRetry() is mechanically sound — stopTokenRefresh() prevents timer accumulation, disposed guards prevent orphaned retries, and fetchToken() on success properly resumes the normal TTL-based cadence. Build passes, 53/53 tests green, eslint clean.

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator

✅ Local verification — recommend merge

Verified on a clean worktree (real npm ci, built the channel-base + qqbot packages). Beyond the author's unit tests, I drove the actual compiled QQChannel through its real setTimeout retry loop (no fake timers), intercepting only globalThis.fetch so the real fetchAccessToken() sees a token endpoint that fails/recovers on demand. Ran four real-time scenarios under tmux. The fix behaves exactly as described — keeps retrying every 60s, recovers, and stops on dispose.

Scope confirmed

True diff vs origin/main: 53 / 12, 2 files (QQChannel.ts + send.test.ts). Before, a failed scheduled refresh armed one 60s retry and, if that also failed, only logged Token refresh failed again after retry and gave up — so after 2 consecutive failures the bot never refreshes again and dies when the token expires (#5411). After, the failure path calls the new scheduleTokenRefreshRetry(), which recursively reschedules every 60s until a fetch succeeds or the channel is disposed (if (this.disposed) return guards + stopTokenRefresh() to avoid timer leaks).

1) Author's test plan — reproduced

  • vitest (send + api): 53/53 ✔ ; full qqbot suite 61/61
  • eslint ✔ · tsc --noEmit ✔ · prettier --check ✔ · git diff --check
  • A/B: reverting the fix makes the new test fail with expected "spy" to be called 3 times, but got 2 times (old code stops at 2 fetches, never recovers). My revert is byte-identical to origin/main.

2) Real-timer E2E under tmux (the core evidence)

Each run drives the real compiled QQChannel with tokenExpiresAt = now + 120s (first refresh fires at the 60s floor), real setTimeout, real fetchAccessToken → mocked token endpoint. Observed token-endpoint hits with real timestamps:

Scenario (build) token-endpoint hits (real time) result
OLD — always fail 60s, 120s then nothing gives up after 2 (the bug)
NEW — always fail 60s, 120s, 180s, 240s … keeps retrying every 60s
NEW — fail,fail,recover 60s✗, 120s✗, 180s✓accessToken=recovered-token recovers after repeated failures
NEW — dispose at 90s 60s✗, disconnect() @90s → no 120s hit dispose cancels the pending retry (disposed=true)

The OLD vs NEW "always fail" rows are the before/after of the fix: identical setup, but OLD stops at the 2nd attempt while NEW issues the 3rd (180s) and 4th (240s) and would continue indefinitely. The dispose run confirms the loop terminates cleanly on channel teardown (no leaked timer, no retry after disposal).

Conclusion

Correct, minimal, and complete. The retry loop now survives repeated token-endpoint outages and recovers when the endpoint returns, while still stopping immediately on dispose. No regression in the qqbot suite.

Note (non-blocking): retries are a fixed 60s with no backoff/cap — intended here (a bot should keep trying to restore its token; attempts are cheap and 60s-spaced, and stop on dispose). Fine to merge as-is.

🇨🇳 中文版(点击展开)

✅ 本地验证 — 建议合并

我在干净的 worktree 中验证(真实 npm ci,构建了 channel-baseqqbot 包)。除作者的单测外,我用真实的 setTimeout 重试循环驱动实际编译产物 QQChannel(不使用 fake timer),仅拦截 globalThis.fetch,让真实的 fetchAccessToken() 面对一个可按需失败/恢复的 token 端点。在 tmux 下跑了四个实时场景。修复行为与描述完全一致 —— 每 60s 持续重试、能够恢复、且在 dispose 时停止

改动范围确认

相对 origin/main 的真实 diff:53 / 12,2 个文件QQChannel.ts + send.test.ts)。改动前,定时刷新失败只会安排一次 60s 重试;若该次也失败,仅打印 Token refresh failed again after retry 便放弃 —— 于是连续失败 2 次后机器人再也不会刷新 token,待 token 过期后即失效(#5411)。改动后,失败分支调用新增的 scheduleTokenRefreshRetry(),它每 60s 递归重排,直到某次 fetch 成功或 channel 被 dispose(if (this.disposed) return 守卫 + stopTokenRefresh() 防止定时器泄漏)。

1)复现作者的测试计划

  • vitest(send + api):53/53 ✔;qqbot 全量套件 61/61
  • eslint ✔ · tsc --noEmit ✔ · prettier --check ✔ · git diff --check
  • A/B: 回退修复后,新测试以 expected "spy" to be called 3 times, but got 2 times 失败(旧代码在第 2 次 fetch 后停止,永不恢复)。我的回退与 origin/main 逐字节一致。

2)tmux 下的实时端到端(核心证据)

每次运行都用 tokenExpiresAt = now + 120s 驱动真实编译产物 QQChannel(首次刷新在 60s 下限触发),真实 setTimeout、真实 fetchAccessToken → mock token 端点。记录 token 端点命中的真实时间戳:

场景(构建) token 端点命中(真实时间) 结果
旧 — 一直失败 60s, 120s 之后再无 2 次后放弃(即该 bug)
新 — 一直失败 60s, 120s, 180s, 240s … 每 60s 持续重试
新 — 失败,失败,恢复 60s✗, 120s✗, 180s✓accessToken=recovered-token ✅ 反复失败后成功恢复
新 — 90s 时 dispose 60s✗disconnect() @90s → 无 120s 命中 dispose 取消待执行的重试disposed=true

旧/新「一直失败」两行就是修复前后的对照:相同设置下,旧代码在第 2 次尝试后停止,而新代码会发起第 3 次(180s)、第 4 次(240s)并将无限继续。dispose 场景确认循环在 channel 拆除时干净终止(无泄漏定时器,dispose 后不再重试)。

结论

正确、最小、完整。重试循环现在能挺过 token 端点的反复中断、并在端点恢复时自愈,同时在 dispose 时立即停止。qqbot 套件无回归。

说明(不阻塞):重试为固定 60s、无退避/上限 —— 此处是合理的(机器人应持续尝试恢复 token;每次尝试开销很小、间隔 60s,且在 dispose 时停止)。可按现状合并。

@wenshao

wenshao commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

The body doesn't follow the PR template headings (uses Summary/Demo/Test plan instead of What this PR does/Why it's needed/Reviewer Test Plan/Risk & Scope/Linked Issues/中文说明), but the substance is all there — clear description, test commands, and it fixes #5411. Not blocking on format.

On direction: this is a straightforward P1 bug fix. The token refresh retry loop dies after two consecutive failures, leaving a long-running daemon permanently unable to refresh its QQ Bot token. Clearly a real problem for anyone running the QQ channel adapter in production. Fully aligned.

On approach: the scope is tight — one extracted method (scheduleTokenRefreshRetry()) that recursively retries every 60s, respecting disposed and clearing stale timers via stopTokenRefresh(). One new test covering the failure→failure→recovery path. Nothing extra. Clean.

Moving on to code review. 🔍

中文说明

感谢贡献!

PR body 没有使用 PR 模板 的标准标题(用了 Summary/Demo/Test plan 而不是 What this PR does/Why it's needed/Reviewer Test Plan/Risk & Scope/Linked Issues/中文说明),但实质内容齐全——描述清晰、有测试命令、修复了 #5411。不因格式阻塞。

方向:这是一个明确的 P1 bug 修复。Token 刷新重试链在两次连续失败后永久中断,导致长运行 daemon 无法刷新 QQ Bot token。对生产环境使用 QQ 频道的用户是实实在在的问题,完全对齐。

方案:范围紧凑——提取一个 scheduleTokenRefreshRetry() 方法做递归 60s 重试,正确处理 disposed 状态和通过 stopTokenRefresh() 清理旧定时器。新增一个覆盖 失败→失败→恢复 路径的测试。没有多余改动。

进入代码审查 🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

The fix extracts scheduleTokenRefreshRetry() — a small recursive method that retries fetchToken() every 60s on failure. Three things done right:

  • Timer hygiene: calls stopTokenRefresh() before creating a new timer, so no leaked/orphaned timers.
  • Lifecycle safety: checks this.disposed both in the method entry and in the catch callback, so retries stop cleanly on disconnect.
  • Test coverage: the new test simulates failure → failure → recovery with fake timers, advances through three 60s intervals, verifies the token is recovered, then confirms no further calls after success. The afterEach(() => vi.useRealTimers()) addition is good defensive cleanup.

No bugs, no security concerns, no convention violations. The diff is exactly what the bug needs.

Testing

All verification from the PR's test plan passed:

$ node node_modules/.bin/vitest run --coverage.enabled=false \
    packages/channels/qqbot/src/send.test.ts \
    packages/channels/qqbot/src/api.test.ts

 ✓  @qwen-code/channel-qqbot  src/api.test.ts (13 tests) 15ms
[QQ:test-bot] Token refresh failed: Error: token endpoint down, retrying in 60s
[QQ:test-bot] Token refresh failed: Error: still down, retrying in 60s
 ✓  @qwen-code/channel-qqbot  src/send.test.ts (40 tests) 18ms

 Test Files  2 passed (2)
      Tests  53 passed (53)
   Duration  520ms
$ npx eslint packages/channels/qqbot/src/QQChannel.ts packages/channels/qqbot/src/send.test.ts
(clean — no output)
$ npx prettier --check packages/channels/qqbot/src/QQChannel.ts packages/channels/qqbot/src/send.test.ts
Checking formatting...
All matched files use Prettier code style!
$ npx tsc --noEmit --project packages/channels/qqbot/tsconfig.json
(clean — no output)

Tmux real-scenario testing: N/A — this is background token refresh logic, not user-visible behavior. The fake-timer unit test directly exercises the retry loop (failure → failure → recovery) and is the appropriate verification method here.

中文说明

代码审查

修复提取了 scheduleTokenRefreshRetry() —— 一个小型递归方法,失败时每 60s 重试 fetchToken()。三点做得好:

  • 定时器管理:创建新定时器前调用 stopTokenRefresh(),不会泄漏孤立定时器。
  • 生命周期安全:方法入口和 catch 回调中都检查 this.disposed,disconnect 时干净停止重试。
  • 测试覆盖:新测试用 fake timer 模拟 失败→失败→恢复,推进三个 60s 间隔,验证 token 恢复成功,之后不再调用。afterEach(() => vi.useRealTimers()) 是好的防御性清理。

无 bug、无安全问题、无约定违反。diff 正好是修复所需。

测试

PR 测试计划中的所有验证均通过(见上方输出)。

Tmux 真实场景测试:不适用——这是后台 token 刷新逻辑,非用户可见行为。fake-timer 单元测试直接覆盖了重试循环(失败→失败→恢复),是合适的验证方式。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

This is a clean, minimal fix for a real P1 bug. The retry chain in scheduleTokenRefresh() used to die silently after two failures — now it keeps going via a small recursive helper that respects disposal and timer lifecycle. The test covers the exact scenario from the bug report (repeated failures followed by recovery). 53/53 tests pass, lint/typecheck/format all clean.

I don't see anything that needs changing. The only note is the PR body template mismatch, which is cosmetic and doesn't affect reviewability.

Approving. ✅

中文说明

这是一个干净、最小化的 P1 bug 修复。scheduleTokenRefresh() 中的重试链之前在两次失败后静默中断——现在通过一个小型递归 helper 持续重试,正确处理了 disposed 状态和定时器生命周期。测试覆盖了 bug 报告中的场景(连续失败后恢复)。53/53 测试通过,lint/typecheck/format 全部干净。

没有需要修改的地方。唯一的备注是 PR body 模板标题不匹配,属于外观问题,不影响可审查性。

批准 ✅

Qwen Code · qwen3.7-max

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅

@wenshao
wenshao merged commit 617eb20 into QwenLM:main Jun 19, 2026
33 checks passed
@wenshao

wenshao commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator

✅ Local real-build verification — recommend merge

Verified this fix end-to-end on a real local build (not just unit tests). It correctly resolves #5411, CI is green on the current head, and the change is well-scoped. Recommend merge.

Environment: macOS (Darwin 25.5), Node 22.22, isolated git worktree off head 0eb0d241eb (base 715ef938f5).

What the PR does

The old failure path scheduled a single 60s retry; if that retry also failed, the inner .catch only logged Token refresh failed again after retry and scheduled nothing — so after ~2 consecutive failures the token was never refreshed again and a long-running daemon silently dropped every API call (exactly #5411). The fix extracts scheduleTokenRefreshRetry(), which re-schedules itself on every failure (guarded by disposed, clearing any prior timer via stopTokenRefresh()) until a refresh succeeds — at which point fetchToken() returns to the normal 80%-of-TTL cadence.

1. CI — all green on 0eb0d241eb

Lint, Test (ubuntu / macos / windows, Node 22.x), CodeQL, Classify PR → all success.

2. Unit tests (real source, vitest)

send.test.ts + api.test.ts53/53 pass. The new test keeps retrying scheduled token refresh failures until one succeeds exercises the recursion (2 failures → recover on 3rd → quiet afterward).

3. Mutation test (decisive — proves the tests guard the fix)

Reverted only QQChannel.ts to base (kept all PR tests; git diff --stat base empty), then re-ran:

result
keeps retrying … until one succeeds FAILexpected called 3 times, but got 2 times (base logs Token refresh failed again after retry and gives up)
other 39 send.test.ts tests ✅ pass (fix is scoped to the retry path only)

The failure message is the A/B: base stops at 2 attempts, the fix continues. I also added an extended fake-timer test (5 failures → recover on the 6th) that fails identically on base (got 2) and passes on the fix.

4. Real built-dist A/B (the shipped artifact, not transpiled)

Ran the compiled dist/QQChannel.js through a harness that drives the real scheduleTokenRefresh recursion (only the network leaf fetchToken stubbed; the 60s timer compressed so the run is fast/deterministic). Same scenario: 5 consecutive failures, then recover.

Shipped artifact fetchToken calls recovered? accessToken
FIXED dist (scheduleTokenRefreshRetry present) 6 ✅ true recovered-token
BASE dist (failed again after retry present) 2 ❌ false (unset — token permanently dead)

This reproduces #5411 on the real artifact: base makes exactly 2 attempts then stops forever; the fix keeps retrying every 60s and recovers when the endpoint comes back.

5. Dispose safety (reverse-audit)

The new timer callback drops the old top-level if (this.disposed) return guard, but this is safe: disconnect() sets disposed = true and calls stopTokenRefresh() (clearTimeout), so a torn-down channel's pending timer is always cleared before it can fire, and the .catch still guards disposed before logging/rescheduling. Confirmed by a disconnect() halts the unbounded loop test (passes on both base and fix — a no-regression check).

Non-blocking observations

  • Fixed 60s interval, no backoff — matches the issue's "retry every 60s" wording and is fine; a small jitter/backoff could be a later enhancement so a hard-down endpoint isn't polled at a perfectly fixed cadence.
  • The token-refresh retry timer is not .unref()'d, unlike the reconnect timer (QQChannel.ts:850, from sibling fix(channel): track qqbot close reconnect timer #5416). While the token endpoint is permanently down, the now-indefinite retry timer keeps the event loop alive every 60s. For a daemon that is arguably desirable (it stays alive to auto-recover), and disconnect() clears it so there's no leak — but it's an asymmetry with the reconnect timer worth a conscious decision.

Verdict: LGTM — the fix is correct, tested, scoped, and reproducibly resolves #5411. Recommend merge.

🇨🇳 中文版(点击展开)

✅ 本地真实构建验证 —— 建议合并

在本地真实构建上做了端到端验证(不只是跑单测)。该修复正确解决了 #5411,当前 head 上 CI 全绿,改动范围收敛得当。建议合并。

环境: macOS(Darwin 25.5)、Node 22.22,基于 head 0eb0d241eb(base 715ef938f5)的独立 git worktree

这个 PR 做了什么

旧的失败路径只调度一次 60s 重试;如果这次重试又失败,内层 .catch 仅打印 Token refresh failed again after retry不再调度任何后续重试 —— 于是连续失败约 2 次后 token 再也不会刷新,长运行 daemon 会静默丢弃此后所有 API 调用(正是 #5411)。修复抽出了 scheduleTokenRefreshRetry(),它在每次失败后都重新调度自己(受 disposed 保护,并通过 stopTokenRefresh() 清掉旧定时器),直到某次刷新成功 —— 成功后 fetchToken() 会回到正常的「TTL 80%」刷新节奏。

1. CI —— 0eb0d241eb 上全绿

LintTest (ubuntu / macos / windows, Node 22.x)CodeQLClassify PR 全部 success

2. 单元测试(真实源码,vitest)

send.test.ts + api.test.ts53/53 通过。新增测试 keeps retrying scheduled token refresh failures until one succeeds 覆盖了递归重试(2 次失败 → 第 3 次恢复 → 之后不再重复刷新)。

3. 变异测试(决定性 —— 证明测试确实守护了修复)

QQChannel.ts 回退到 base(保留 PR 的所有测试;git diff --stat base 为空),再重跑:

结果
keeps retrying … until one succeeds 失败 —— expected called 3 times, but got 2 times(base 打印 Token refresh failed again after retry 后放弃)
send.test.ts 其余 39 个测试 ✅ 通过(修复只作用于重试路径)

这条失败信息本身就是 A/B:base 在第 2 次尝试后停住,修复后会继续。我还加了一个扩展的 fake-timer 测试(连续失败 5 次 → 第 6 次恢复),在 base 上同样失败(got 2),在修复后通过。

4. 真实构建产物 dist 的 A/B(跑的是发布产物,不是 transpile 的源码)

用一个 harness 跑编译后的 dist/QQChannel.js,驱动真实的 scheduleTokenRefresh 递归(只 stub 了网络叶子 fetchToken;把 60s 定时器压缩以便快速、确定性地跑完)。同样场景:连续失败 5 次后恢复。

发布产物 fetchToken 调用次数 是否恢复 accessToken
修复后 dist(含 scheduleTokenRefreshRetry 6 ✅ 是 recovered-token
base dist(含 failed again after retry 2 ❌ 否 (未设置 —— token 永久失效)

这在真实产物上复现了 #5411:base 恰好尝试 2 次后永久停止;修复后每 60s 持续重试,端点恢复时随之恢复。

5. 销毁安全性(反向审计)

新的定时器回调去掉了旧代码顶部的 if (this.disposed) return 守卫,但这是安全的:disconnect() 会设置 disposed = true 调用 stopTokenRefresh()clearTimeout),所以被销毁的 channel 其待触发定时器总是先被清除、不会再触发;而 .catch 中仍然在打印/重调度前判断了 disposed。已通过 disconnect() halts the unbounded loop 测试确认(base 与修复后都通过 —— 属于无回归检查)。

非阻塞的观察

  • 固定 60s 间隔、无退避 —— 与 issue 中「每 60s 重试」的描述一致,没问题;未来可加一点 jitter/退避,避免对一直挂掉的端点以完全固定的节奏轮询。
  • token 刷新重试定时器没有 .unref(),这点和重连定时器(QQChannel.ts:850,来自姊妹 PR fix(channel): track qqbot close reconnect timer #5416)不一致。在 token 端点永久不可用期间,如今变为无限的重试定时器会每 60s 让事件循环保持存活。对 daemon 而言这可能是期望行为(保持存活以自动恢复),且 disconnect() 会清除它、不存在泄漏 —— 但与重连定时器的这处不对称值得作一次有意识的取舍。

结论:LGTM —— 修复正确、有测试、范围收敛,并可复现地解决了 #5411。建议合并。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

qqbot: Token 刷新 2 次连续失败后永久停止

3 participants