Skip to content

fix(channel): track qqbot close reconnect timer#5416

Merged
wenshao merged 1 commit into
QwenLM:mainfrom
tt-a1i:fix/qqbot-close-reconnect-timer
Jun 19, 2026
Merged

fix(channel): track qqbot close reconnect timer#5416
wenshao merged 1 commit into
QwenLM:mainfrom
tt-a1i:fix/qqbot-close-reconnect-timer

Conversation

@tt-a1i

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

Copy link
Copy Markdown
Contributor

What this PR does

This stores the QQ bot close-handler reconnect timeout in this.reconnectTimer, calls .unref() on that timeout, and keeps disconnect() responsible for clearing it. It also adds a regression test that triggers the real close handler and verifies disconnect clears the timer.

Why it's needed

Issue #5413 showed that the close-handler reconnect backoff could keep the daemon process alive because the timeout was not tracked or unrefed. Tracking the timer makes cleanup reliable, and unrefing it prevents a pending reconnect delay from being the only reason the Node process stays open.

Reviewer Test Plan

How to verify

Check that the close handler stores its reconnect timer, that the timer is unrefed, and that disconnect() clears the pending timer. The regression test exercises the actual close handler instead of only mocking the final state.

Evidence (Before & After)

Before: a close-handler reconnect timeout could remain referenced and keep the daemon alive during backoff. After: the timeout is tracked, unrefed, and cleared on disconnect.

Tested on

OS Status
macOS tested
Windows CI
Linux CI

Environment (optional)

Node 22 via npx -p node@22.

Commands run locally:

  • 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 prettier --check packages/channels/qqbot/src/QQChannel.ts packages/channels/qqbot/src/send.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
  • git diff --check

Risk & Scope

  • Main risk or tradeoff: reconnect timer cleanup changes can affect close/reconnect timing, but the same reconnect path is still used and the timer is now visible to disconnect cleanup.
  • Not validated / out of scope: no live QQ bot connection was run.
  • Breaking changes / migration notes: none.

Linked Issues

Fixes #5413

中文说明

What this PR does

这个 PR 把 QQ bot close handler 里的 reconnect timeout 存到 this.reconnectTimer,对这个 timeout 调用 .unref(),并继续由 disconnect() 负责清理它。同时增加了一个回归测试:触发真实 close handler,并验证 disconnect 会清掉 timer。

Why it's needed

#5413 暴露出 close-handler reconnect backoff 可能让 daemon 进程一直不退出,因为这个 timeout 没有被跟踪,也没有 unref。跟踪 timer 可以让 cleanup 更可靠,unref 则避免一个等待重连的延迟成为 Node 进程保持打开的唯一原因。

Reviewer Test Plan

How to verify

确认 close handler 会保存 reconnect timer,timer 会被 unref,并且 disconnect() 会清理待执行 timer。回归测试执行真实 close handler,而不是只 mock 最终状态。

Evidence (Before & After)

Before:close-handler reconnect timeout 可能保持 referenced,在 backoff 期间让 daemon 进程不退出。After:timeout 被跟踪、unref,并会在 disconnect 时清理。

Tested on

OS Status
macOS tested
Windows CI
Linux CI

Environment (optional)

通过 npx -p node@22 使用 Node 22。

本地运行过:

  • 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 prettier --check packages/channels/qqbot/src/QQChannel.ts packages/channels/qqbot/src/send.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
  • git diff --check

Risk & Scope

  • Main risk or tradeoff:reconnect timer cleanup 的变化可能影响 close/reconnect 时序,但仍然使用同一条 reconnect path,并且 timer 现在会被 disconnect cleanup 看见。
  • Not validated / out of scope:没有运行真实 QQ bot 连接。
  • Breaking changes / migration notes:无。

Linked Issues

Fixes #5413

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 15:08
@wenshao

wenshao commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@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.

Hey @tt-a1i, thanks for the contribution! The fix itself looks spot-on — storing the timer reference and calling .unref() to prevent the process from hanging on exit is exactly the right approach for this issue.

However, the PR body doesn't follow our PR template. The template requires these sections:

  • ## What this PR does
  • ## Why it's needed
  • ## Reviewer Test Plan (with ### How to verify, ### Evidence (Before & After), ### Tested on)
  • ## Risk & Scope
  • ## Linked Issues
  • <details><summary>中文说明</summary> (Chinese translation)

Your current headings (## Summary, ## Test Plan) don't match. Could you reformat the PR body to follow the template? The content is all there — it just needs to be reorganized under the expected headings so reviewers can find what they need.

Looking forward to reviewing the code once the template is updated! 🙏

中文说明

@tt-a1i,感谢贡献!修复本身是正确的——存储 timer 引用并调用 .unref() 防止进程退出时挂起,这正是解决该问题的正确方法。

但是 PR 描述没有按照我们的 PR 模板 填写。模板要求以下章节:

  • ## What this PR does(本 PR 做了什么)
  • ## Why it's needed(为什么需要这个改动)
  • ## Reviewer Test Plan(审查者测试计划,含 ### How to verify### Evidence (Before & After)### Tested on
  • ## Risk & Scope(风险与范围)
  • ## Linked Issues(关联 Issue)
  • <details><summary>中文说明</summary>(中文翻译)

当前使用的标题(## Summary## Test Plan)与模板不匹配。能否按照模板重新组织 PR 描述?内容都已经有了,只需要按照预期的标题重新排列即可。

更新模板后会继续审查代码!🙏

Qwen Code · qwen3.7-max

@tt-a1i

tt-a1i commented Jun 19, 2026

Copy link
Copy Markdown
Contributor Author

updated the PR description to match the template. code is unchanged.

@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.

No issues found. LGTM! ✅

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

1 similar comment
@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, @tt-a1i!

Template looks good ✓

On direction: this is a clear-cut bug fix — the close-handler setTimeout at L636 was the only reconnect timer in QQChannel that wasn't being tracked or unref'd, which could keep the daemon alive up to 30s during backoff. Issue #5413 identified it precisely, and this PR addresses it directly. Aligned with the project's stability goals.

On approach: the scope is minimal and correct — 4 lines of production code that match the existing store+unref pattern already used at L845-846. No unrelated changes. The test additions are justified: the MockWebSocket infrastructure (~40 lines) is necessary to exercise the real close handler path rather than mocking internals. Feels right-sized.

Moving on to code review. 🔍

中文说明

感谢 @tt-a1i 的贡献!

模板完整 ✓

方向:这是一个明确的 bug 修复——close handler 里的 setTimeout(L636)是 QQChannel 中唯一没有被跟踪和 unref 的重连定时器,可能导致 daemon 在 backoff 期间保持存活最多 30 秒。Issue #5413 精准定位了问题,PR 直接修复。符合项目的稳定性目标。

方案:范围极小且正确——4 行生产代码,与 L845-846 已有的存储+unref 模式一致。没有无关改动。测试新增部分合理:MockWebSocket 基础设施(约 40 行)是触发真实 close handler 路径所必需的,比 mock 内部状态更好。范围恰当。

进入代码审查 🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal: to fix "close-handler setTimeout not tracked/unref'd," I would (1) store the setTimeout() result in the existing this.reconnectTimer field, (2) call .unref() on it, (3) confirm disconnect() already clears it, and (4) add a test that triggers the real close handler. This is exactly what the PR does.

The production change is 4 lines in QQChannel.ts — stores the timer and unrefs it at the close handler, matching the identical pattern already at L845-846. No correctness bugs, no security concerns, no regressions. The field declaration (L129) and disconnect() cleanup (L314-316) already exist, so the fix slots in cleanly.

The test adds a MockWebSocket class to exercise the real dialGateway → close handler → reconnect timer path. It verifies: timer is stored, timer is unref'd (hasRef() === false), disconnect() calls clearTimeout and nulls the field. Solid regression coverage — this test would fail without the fix.

Test Results

$ cd packages/channels/qqbot && npx vitest run src/send.test.ts

 ✓ src/send.test.ts (40 tests) 16ms

 Test Files  1 passed (1)
      Tests  40 passed (40)
   Duration  418ms
$ npx tsc --noEmit --project packages/channels/qqbot/tsconfig.json
(clean — no errors)
$ npx prettier --check packages/channels/qqbot/src/QQChannel.ts packages/channels/qqbot/src/send.test.ts
All matched files use Prettier code style!

Real-Scenario Testing

N/A — this fixes internal reconnection timer logic in the QQ bot channel. Triggering it requires a live QQ bot gateway connection, which is not available in CI. The unit test exercises the actual close handler path end-to-end and is the appropriate verification for this change.

中文说明

代码审查

独立方案:修复"close handler setTimeout 未跟踪/未 unref",我会 (1) 把 setTimeout() 结果存入已有的 this.reconnectTimer 字段,(2) 调用 .unref(),(3) 确认 disconnect() 已清理它,(4) 添加触发真实 close handler 的测试。PR 的做法与此完全一致。

生产代码改动仅 4 行——在 close handler 处存储并 unref 定时器,与 L845-846 已有的模式一致。无正确性 bug、无安全问题、无回归。字段声明(L129)和 disconnect() 清理(L314-316)已存在,修复自然嵌入。

测试新增 MockWebSocket 类来走真实的 dialGateway → close handler → reconnect timer 路径。验证:timer 已存储、timer 已 unref(hasRef() === false)、disconnect() 调用 clearTimeout 并置空。扎实的回归覆盖——没有这个修复的话测试会失败。

测试结果

40/40 测试通过,TypeScript 类型检查通过,Prettier 格式检查通过。

真实场景测试

不适用——此修复针对 QQ Bot 内部重连定时器逻辑,触发需要真实的 QQ Bot 网关连接(CI 环境不可用)。单元测试走完了真实 close handler 的端到端路径,是对此改动的恰当验证。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

This is a clean, well-scoped bug fix. The problem is real (unref'd timer keeping daemon alive during backoff), the fix is the minimum viable change (4 production lines matching an existing pattern), and the test covers the actual code path rather than mocking around it. My independent proposal was identical to what the PR does — that's a good sign.

The test infrastructure additions (~40 lines for MockWebSocket) might look like a lot relative to the 4-line fix, but they're testing the real close-handler → reconnect-timer path, which is strictly better than mocking setTimeout and asserting it was called. This is the kind of test that catches real regressions.

No concerns. Ships the fix, adds coverage, gets out of the way.

Approving. ✅

中文说明

这是一个干净、范围恰当的 bug 修复。问题真实存在(未 unref 的定时器在 backoff 期间让 daemon 保持存活),修复是最小可行改动(4 行生产代码,与已有模式一致),测试覆盖了真实代码路径而非在周围 mock。我的独立方案与 PR 完全相同——这是个好信号。

测试基础设施新增约 40 行(MockWebSocket),相对于 4 行修复看起来不少,但它们测试的是真实的 close handler → reconnect timer 路径,比 mock setTimeout 然后断言它被调用要好得多。这种测试才能真正捕获回归。

没有顾虑。修复到位、覆盖到位、不多不少。

批准 ✅

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 — minimal fix for the unref'd reconnect timer, matches existing pattern, solid regression test. Ready to ship. ✅

@wenshao

wenshao commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator

✅ Maintainer verification — real local + tmux testing

Built the PR head (05f4deb4, Node v22.22.2) in an isolated worktree and reproduced #5413 with a real process-liveness A/B. TL;DR: a tiny, correct, CI-green timer-leak fix — the close-handler reconnect timer is now tracked + .unref()'d and disconnect() clears it; mutation-confirmed, and the live A/B shows the pending reconnect timer used to keep the process alive (~2s) and now lets it exit (+7ms). Recommend merge.

What it fixes

Issue #5413: the QQ bot close handler scheduled its reconnect backoff with a bare setTimeout(() => this.reconnectWithRetry(), delay)untracked and not .unref()'d. So (a) disconnect() had no reference to clear, and (b) the pending reconnect delay was enough to keep the Node/daemon process alive on exit. The fix (QQChannel.ts:636-640):

this.reconnectTimer = setTimeout(() => this.reconnectWithRetry(), delay);
this.reconnectTimer.unref();

disconnect() already clears this.reconnectTimer (clearTimeout + null, lines 314-317), and the other reconnect path (reconnectWithRetry, line 849) was already tracked + unref'd — so this just makes the close-handler path consistent with it.

Real process-liveness A/B (tmux) — reproduces #5413

Scheduled a reconnect timer the way the close handler does (attempt-1 delay = min(1000·2¹, 30000) = 2000ms), then let the process try to exit:

PRE-FIX (ref) FIXED (unref)
process lifetime hangs ~2000msreconnect timer FIRED at +2005ms (kept alive the whole time) → exit at +2006ms exits at +7ms

The un-unref'd timer keeps the process alive for the full reconnect delay — and in production that delay grows to 30s and every fire schedules another, so the daemon never exits. .unref() lets it exit immediately. (This A/B demonstrates the setTimeout + unref mechanism the channel now applies; the regression test below proves the channel actually applies it — hasRef() === false on the real close-handler-scheduled timer.)

Regression test + mutation

The PR's test (tracks and unrefs reconnect timers scheduled by close handler) drives the real close handler (dialGateway → mock wsemit('close', 4001)), then asserts reconnectTimer !== null, timer.hasRef() === false (unref'd), and that disconnect() clearTimeouts it and nulls it.

  • FIXED → ✅ pass.
  • Mutation (revert the hunk back to a bare setTimeout) → ❌ FAIL (expected null not to be null): the untracked timer leaves reconnectTimer null, so disconnect() can't clear it and it isn't unref'd → the test genuinely guards both the tracking and the unref.

CI & the bot's review

  • CI is green on the current head (05f4deb4): Lint ✅ · Test (macos / ubuntu / windows · Node 22) ✅ · CodeQL ✅.
  • The qwen-code-ci-bot CHANGES_REQUESTED is template-only — it explicitly says "the fix itself looks spot-on … exactly the right approach" and only asked to reformat the PR body. The current body already uses ## What this PR does / ## Why it's needed / ## Reviewer Test Plan, so that request is satisfied and the CR is stale — it can be dismissed.

Evidence

Check Result
Build ✅ exit 0, 0 TS errors
Regression test ✅ pass (tracking + hasRef()===false + disconnect clears)
Mutation (revert) ✅ test fails (reconnectTimer stays null)
Real process-liveness (tmux) ✅ ref hangs ~2000ms · unref exits +7ms (#5413)
CI (current head) ✅ Lint + Test ×3 + CodeQL green

Verdict

Correct, minimal, CI-green fix for a real process-liveness leak (#5413): tracking + .unref() + disconnect()-clear are all verified, the regression test is non-vacuous (mutation-proven), and the tmux A/B reproduces the symptom and its fix. Recommend merge. (Backs my approval; the CI bot's template-only CHANGES_REQUESTED is satisfied by the current body and can be dismissed.)

🇨🇳 中文版(点击展开)

✅ 维护者验证 —— 本地真实 + tmux 测试

在隔离 worktree 构建 PR head(05f4deb4,Node v22.22.2),用真实的进程存活性 A/B 复现了 #5413结论:一个小而正确、CI 全绿的定时器泄漏修复 —— close 处理器的 reconnect 定时器现在被追踪 + .unref(),且由 disconnect() 清除;变异测试确认,实时 A/B 显示这个挂起的 reconnect 定时器以前会撑住进程(约 2s)、现在让它立即退出(+7ms)。建议合并。

修了什么

Issue #5413:QQ bot 的 close 处理器用一个裸的 setTimeout(() => this.reconnectWithRetry(), delay) 调度 reconnect 退避 —— 既没追踪也没 .unref()。于是(a)disconnect() 没有引用可清除,(b)这个挂起的 reconnect 延迟足以在退出时把 Node/daemon 进程撑住。修复(QQChannel.ts:636-640):

this.reconnectTimer = setTimeout(() => this.reconnectWithRetry(), delay);
this.reconnectTimer.unref();

disconnect() 本就清除 this.reconnectTimerclearTimeout + 置 null,314-317 行),而另一条 reconnect 路径(reconnectWithRetry,849 行)本就已追踪 + unref —— 所以这次只是让 close 处理器这条路径与它一致。

真实进程存活性 A/B(tmux)—— 复现 #5413

按 close 处理器的方式调度一个 reconnect 定时器(第 1 次重试延迟 = min(1000·2¹, 30000) = 2000ms),然后让进程尝试退出:

修复前(ref 修复后(unref
进程存活时长 挂起约 2000ms —— reconnect timer FIRED at +2005ms(全程被撑住) → +2006ms 退出 +7ms 退出

unref 的定时器在整个 reconnect 延迟期间撑住进程 —— 而生产中这个延迟会增长到 30s、且每次触发又排一个新的,所以 daemon 永远退不出去。.unref() 让它立即退出。(这个 A/B 演示的是 channel 现在应用的 setTimeout + unref 机制;下面的回归测试证明 channel 确实应用了它 —— 对真实 close 处理器调度的定时器断言 hasRef() === false。)

回归测试 + 变异测试

PR 的测试(tracks and unrefs reconnect timers scheduled by close handler)驱动真实的 close 处理器(dialGateway → mock wsemit('close', 4001)),然后断言 reconnectTimer !== nulltimer.hasRef() === false(已 unref),以及 disconnect()clearTimeout 并置 null。

  • 修复后 → ✅ 通过。
  • 变异(把 hunk 还原成裸 setTimeout)→ ❌ 失败expected null not to be null):未追踪的定时器让 reconnectTimer 保持 null,于是 disconnect() 清不掉、也没 unref → 测试确实守护了「追踪」与「unref」两点。

CI 与机器人的 review

  • 当前 head(05f4deb4)CI 全绿Lint ✅ · Test(macos / ubuntu / windows · Node 22)✅ · CodeQL ✅。
  • qwen-code-ci-botCHANGES_REQUESTED 只关于模板 —— 它明确说*"修复本身正确……正是正确做法"*,只要求把 PR 描述按模板重排。当前 body 已经用了 ## What this PR does / ## Why it's needed / ## Reviewer Test Plan,所以该要求已满足、这条 CR 已陈旧,可以 dismiss。

证据

检查项 结果
构建 ✅ exit 0,0 TS 错误
回归测试 ✅ 通过(追踪 + hasRef()===false + disconnect 清除)
变异(还原) ✅ 测试失败(reconnectTimer 保持 null)
真实进程存活性(tmux) ✅ ref 挂约 2000ms · unref +7ms 退出(#5413
CI(当前 head) ✅ Lint + Test ×3 + CodeQL 全绿

结论

针对一个真实的进程存活性泄漏(#5413)的正确、精简、CI 全绿的修复:追踪 + .unref() + disconnect() 清除三点都已验证,回归测试非空过场(变异坐实),tmux A/B 复现了症状及其修复。建议合并。(呼应我的 approve;CI bot 那条仅关于模板的 CHANGES_REQUESTED 已被当前 body 满足,可 dismiss。)

Method: isolated worktree build of 05f4deb4 (exit 0) · regression test (tracks and unrefs … close handler: hasRef()===false + disconnect clears) · source-mutation (revert the close-handler hunk → test fails expected null not to be null) · real process-liveness A/B (ref hangs ~2000ms / unref exits +7ms = #5413) · CI check-runs green (Lint + Test ×3 + CodeQL).

@wenshao
wenshao merged commit 30bfe63 into QwenLM:main Jun 19, 2026
38 checks passed
@wenshao

wenshao commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator

✅ Local verification — built & real-tested

I checked this out on fix/qqbot-close-reconnect-timer, built @qwen-code/channel-qqbot from source, and verified it with the full static suite plus a real end-to-end test (real ws, real timers, the actual compiled code — no mocks). Verdict: correct, minimal, and safe to merge.

Environment: Node v22.22.2, Linux · built with tsc --build.

Static checks

Check Result
vitest run send.test.ts api.test.ts 53 passed (incl. the new regression test)
tsc --noEmit -p packages/channels/qqbot ✅ 0 errors
eslint QQChannel.ts send.test.ts ✅ clean
prettier --check ✅ clean
git diff --check ✅ clean

Real integration test (the part that matters)

The unit test mocks ws. To independently confirm the claim from #5413a pending reconnect must not keep the daemon alive — I drove the real private dialGateway() of the compiled QQChannel against a real local WebSocketServer that accepts the socket and immediately closes it with code 4001 (the exact path that schedules a reconnect). Ran twice: once with the fix reverted (BEFORE), once applied (AFTER).

Test A — timer is tracked, unref'd, and cleared on disconnect()

tracked on this.reconnectTimer hasRef() (false = won't hold loop) disconnect() clears it leaked after disconnect
BEFORE ❌ false true (referenced) ❌ no ⚠️ yes — leak
AFTER ✅ true false (unref'd) ✅ yes ✅ no

Test B — does the pending reconnect keep the Node process alive?
(reconnect backoff preset to 16 s; a 2.5 s unref'd watchdog decides the verdict)

verdict exit code wall time active handles after server close
BEFORE STILL_ALIVE 2 2.63 s [PipeWrap, Timeout] ← referenced timer holds the loop
AFTER PROCESS_EXITS_CLEANLY 0 0.13 s [TCPServerWrap, PipeWrap] ← no referenced Timeout

So with the fix a daemon that's mid-backoff exits cleanly; without it the referenced reconnect timer pins the event loop open — precisely the #5413 symptom.

Raw harness output
============ TEST A — tracking + unref + disconnect clears (real ws, real timers) ============
---- BEFORE (fix reverted) ----
{"label":"BEFORE","marker":"SCHEDULED","attempts":1,"expectedDelayMs":2000,"realTimerScheduled":true,"trackedInField":false,"fieldIsCaptured":false,"hasRef":true}
{"label":"BEFORE","marker":"AFTER_DISCONNECT","clearTimeoutCalledWithTimer":false,"reconnectTimerNull":true,"timerStillRefdAfterDisconnect":true}
---- AFTER  (PR applied)  ----
{"label":"AFTER","marker":"SCHEDULED","attempts":1,"expectedDelayMs":2000,"realTimerScheduled":true,"trackedInField":true,"fieldIsCaptured":true,"hasRef":false}
{"label":"AFTER","marker":"AFTER_DISCONNECT","clearTimeoutCalledWithTimer":true,"reconnectTimerNull":true,"timerStillRefdAfterDisconnect":false}

============ TEST B — does a pending reconnect keep the daemon process alive? ============
---- BEFORE (fix reverted): expect STILL_ALIVE, exitcode 2 ----
{"label":"BEFORE","marker":"SCHEDULED","attempts":4,"expectedDelayMs":16000,"realTimerScheduled":true,"trackedInField":false,"fieldIsCaptured":false,"hasRef":true}
{"label":"BEFORE","marker":"LOOP_RELEASED","activeResources":["TCPServerWrap","PipeWrap","Timeout"]}
{"label":"BEFORE","marker":"VERDICT","result":"STILL_ALIVE","meaning":"a referenced reconnect timer kept the daemon process alive","activeResources":["PipeWrap","Timeout"]}
==> exitcode=2 wall=2.63s
---- AFTER  (PR applied): expect PROCESS_EXITS_CLEANLY, exitcode 0 ----
{"label":"AFTER","marker":"SCHEDULED","attempts":4,"expectedDelayMs":16000,"realTimerScheduled":true,"trackedInField":true,"fieldIsCaptured":true,"hasRef":false}
{"label":"AFTER","marker":"LOOP_RELEASED","activeResources":["TCPServerWrap","PipeWrap"]}
{"label":"AFTER","marker":"VERDICT","result":"PROCESS_EXITS_CLEANLY","meaning":"reconnect timer did NOT keep the daemon process alive"}
==> exitcode=0 wall=0.13s

Code notes

  • The fix makes the close-handler path consistent with the existing reconnectWithRetry() fallback, which already did this.reconnectTimer = setTimeout(...); .unref().
  • disconnect() already cleared this.reconnectTimer — the close-handler timer just wasn't being stored, so this supplies the missing half. No new state, minimal surface, no change to reconnect timing.
  • Live QQ gateway not exercised (out of scope, as noted) — but the real-ws harness covers the exact changed branch deterministically.

LGTM 👍

中文版(点击展开)

✅ 本地验证 —— 已构建并真实测试

我在 fix/qqbot-close-reconnect-timer 分支上 checkout 并从源码构建了 @qwen-code/channel-qqbot,跑了完整的静态检查,并且做了一个真实的端到端测试(真实 ws、真实 timer、真实编译产物,无 mock)。结论:修复正确、改动最小,可以合并。

环境: Node v22.22.2,Linux,使用 tsc --build 构建。

静态检查

检查项 结果
vitest run send.test.ts api.test.ts 53 通过(含新增回归测试)
tsc --noEmit -p packages/channels/qqbot ✅ 0 错误
eslint QQChannel.ts send.test.ts ✅ 通过
prettier --check ✅ 通过
git diff --check ✅ 通过

真实集成测试(关键部分)

单测里 ws 是被 mock 的。为了独立验证 #5413 的核心诉求 —— 一个待执行的重连不应让 daemon 进程保持存活 —— 我用真实的本地 WebSocketServer(接受连接后立即以 4001 关闭,正是触发重连调度的路径)去驱动编译后 QQChannel真实私有方法 dialGateway()。跑了两遍:一遍把修复还原(BEFORE),一遍应用修复(AFTER)。

测试 A —— timer 被跟踪、被 unref、并在 disconnect() 时被清理

存到 this.reconnectTimer hasRef()(false = 不会拖住事件循环) disconnect() 是否清理 disconnect 后是否泄漏
BEFORE ❌ false true(被引用) ❌ 否 ⚠️ 是 —— 泄漏
AFTER ✅ true false(已 unref) ✅ 是 ✅ 否

测试 B —— 待执行的重连会不会让 Node 进程一直不退出?
(重连退避预设为 16 秒;用一个 2.5 秒的 unref 看门狗来判定)

判定 退出码 墙钟耗时 关闭 server 后仍存活的 handle
BEFORE STILL_ALIVE 2 2.63 秒 [PipeWrap, Timeout] ← 被引用的 timer 拖住事件循环
AFTER PROCESS_EXITS_CLEANLY 0 0.13 秒 [TCPServerWrap, PipeWrap] ← 没有被引用的 Timeout

也就是说,应用修复后,处于退避中的 daemon 能干净退出;不修复时被引用的重连 timer 会把事件循环钉住 —— 正是 #5413 的现象。

代码层面

  • 该修复让 close handler 这条路径与已有的 reconnectWithRetry() 兜底逻辑保持一致(后者本就已经 this.reconnectTimer = setTimeout(...); .unref())。
  • disconnect() 早已会清理 this.reconnectTimer —— 只是 close handler 的 timer 之前没被存下来,这个 PR 补上了缺失的另一半。没有新增状态,改动面很小,也不改变重连时序。
  • 未连真实 QQ 网关(如 PR 所述,超出范围)—— 但真实 ws 的 harness 已经确定性地覆盖了改动的那条分支。

LGTM 👍


Verified locally with Claude Code (Opus 4.8).

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: Close handler setTimeout 未存储引用/未 unref

3 participants