From 07e4958e44387cd0a43990a5b3da9daa8cdbaa40 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 31 Oct 2026 19:16:58 +0800 Subject: [PATCH] =?UTF-8?q?test(core):=20=E4=BF=AE=E5=81=B6=E5=8F=91?= =?UTF-8?q?=E7=BA=A2=E7=9A=84=20rtchild=20=E7=94=A8=E4=BE=8B=EF=BC=8C?= =?UTF-8?q?=E7=99=BB=E8=AE=B0=E5=8F=A6=E4=B8=80=E5=A4=84=E6=9C=AA=E4=BF=AE?= =?UTF-8?q?=E7=9A=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 背景 CI 的 `-race` 门两次变红(提交 `merge: pr31` 与 `style(webui)`), 都是 `TestRTChild_InterruptTruncatesOutput`: --- FAIL: TestRTChild_InterruptTruncatesOutput (5.00s) rtchild_r3_test.go:271: 等待超时:开始输出 本地 `-race` 单测 6/6、整包连跑 4 轮全过,复现不出来。 ## 查到的真相 CI 日志里 `[rtchild] t started` 之后再无任何 round 行 —— **一轮都没跑到 provider**。所以不是输出丢包,是轮次根本没启动起来(CI 的 2 核 runner + race 插桩下启动更慢)。 原写法把两件事塞进同一个固定 5 秒窗口: waitFor(t, "开始输出", func() bool { return len(dev.snapshot()) >= 2 }) 「输出已开始」是**前置条件**,「Stop 后输出冻结」才是被测行为。混在一起后, 超时只能报"没等到输出",看不出到底是「轮次没启动」还是「输出没外发」。 ## 改法(同 2026-10-24 TestStress_Nesting... 的先例:等状态,不等时间) waitForWithin(t, "轮次进入 ChatStream", 30s, p.callCount() >= 1) waitForWithin(t, "开始输出(chunk 到设备)", 30s, len(dev.snapshot()) >= 2) 两段分开等、各自 30 秒:超时能直接指认卡在哪一步。被测断言一字未改。 顺带把原来丢弃的 `PushRTInput` 返回值改成检查(同文件第 297 行本来就这么写)。 ## 另一处已定位但**没修** `TestResidualDropNotifiesSyncCaller`:本地整包 `-race` 8 轮偶发 1 次 (`应处置 1 条,实际 0`),CI 从未出现。 根因是 `waitSchedulerStopped()` 只等 `running == nil`,**不等调度循环退出** —— cancel 之后循环仍有窗口把测试刚入队的任务取走。要修得给生产代码加 「循环已退出」信号,不是改测试能解决的。已记入 `docs/zh/future-plan.md` §5.0。 ## 验证 go vet ./internal/agent/core/ ✓ go test ./internal/agent/core/ -count=1 ✓ go test -race ./internal/agent/core/ -count=1 ✓(连跑 3 轮) 单测 -race -count=5 ✓ --- docs/zh/future-plan.md | 29 ++++++++++++++++++++++++++ internal/agent/core/rtchild_r3_test.go | 28 +++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/docs/zh/future-plan.md b/docs/zh/future-plan.md index 1e5ab960..c08c8dc7 100644 --- a/docs/zh/future-plan.md +++ b/docs/zh/future-plan.md @@ -327,6 +327,35 @@ retention 修好之后这些会被逐步清掉,但不会立刻(要等跨月 ## 五、文档与工程卫生 +### 5.0 🟡 两个测试用例存在「固定时间假设」导致偶发红 + +**2026-10-31 记录。** CI 的 `-race` 门两次因偶发失败变红(提交 `merge: pr31`、 +`style(webui)`),本地却复现不出来。 + +#### 已修:`TestRTChild_InterruptTruncatesOutput` + +失败信息 `等待超时:开始输出`(正好 5.00s)。查 CI 日志:`[rtchild] t started` +之后**一轮都没跑到 provider** —— 不是输出丢了,是轮次没启动。 + +原写法把「前置条件(输出已开始)」和「被测行为(Stop 后输出冻结)」塞进同一个 +固定 5 秒的 `waitFor` 里,超时时看不出卡在哪一步。已改成两段**等状态**: +先等 `p.callCount() >= 1`(轮次真进入 ChatStream),再等 chunk 到设备。 +超时直接指认卡在哪步。同 2026-10-24 `TestStress_Nesting...` 的修法。 + +#### 未修:`TestResidualDropNotifiesSyncCaller` + +本地整包 `-race` 8 轮里偶发 1 次(`应处置 1 条,实际 0`),CI 从未出现。 + +根因已定位:`scheduler.waitSchedulerStopped()` 只等 **`running == nil`** +(没有任务在跑),**不等调度循环退出**。于是 `child.cancel()` 之后仍有一个窗口: +循环可能还停在下一轮顶部、或正走到 `pumpInbox`/`nextRef`,把测试刚入队的任务 +取走执行 ⇒ 残余任务为 0 ⇒ 断言失效。 + +**要修得给生产代码加一个「循环已退出」的信号**(`schedulerLoop` 退出时 close +一个 channel,`waitSchedulerStopped` 改为等它),不是改测试能解决的。 +因此先登记、不改:它在 CI 里从未出现过,而改调度器退出信号属于生产路径改动, +收益与风险不匹配。 + ### 5.1 🟡 README / ARCHITECTURE 的工具名陈旧 实际工具名是 `stablemem_*` / `coldcontext_*`,但对外文档仍写 `memory_recall` / diff --git a/internal/agent/core/rtchild_r3_test.go b/internal/agent/core/rtchild_r3_test.go index 20bc8d9e..7a08b35a 100644 --- a/internal/agent/core/rtchild_r3_test.go +++ b/internal/agent/core/rtchild_r3_test.go @@ -262,13 +262,37 @@ func TestRTChild_OutputIsStreamed(t *testing.T) { // ── R5:打断即截断,且不重试 ── // TestRTChild_InterruptTruncatesOutput 钉住契约 2 的后半:被打断时就地截断。 +// +// ## 前置条件为什么不用固定的 5 秒(2026-10-31 修偶发红) +// +// 本用例曾在 CI 的 -race 门里偶发失败,报「等待超时:开始输出」(5.00s), +// 而本地 -race 连跑 6 轮全过。查 CI 日志:`[rtchild] t started` 之后 +// **一轮都没跑到 provider**,所以不是输出丢了,是轮次没启动。 +// +// 它把「前置条件」和「被测行为」混在一个固定超时里了:被测的是 +// 「Stop 之后输出冻结」,而「输出已开始」只是 setup。setup 等不到时, +// 报错看不出卡在哪一步(provider 没被调?还是输出没到设备?)。 +// +// 改法同 2026-10-24 的 TestStress_Nesting...:**等状态**而不是等时间, +// 并把两个阶段分开等——超时时能直接指认出卡在哪一步。 func TestRTChild_InterruptTruncatesOutput(t *testing.T) { p := &rtBlockingProvider{blockForever: true} host, dev := newRTHost(t, p) id := rtSpawn(t, host, "t") - host.PushRTInput(id, "一直说") - waitFor(t, "开始输出", func() bool { return len(dev.snapshot()) >= 2 }) + if _, err := host.PushRTInput(id, "一直说"); err != nil { + t.Fatalf("投递输入: %v", err) + } + + // 阶段 1:轮次真的进入了 LLM 调用。 + // 这是其他一切的前提;过不去就说明轮次没启动(而不是输出没外发)。 + waitForWithin(t, "轮次进入 ChatStream", 30*time.Second, func() bool { + return p.callCount() >= 1 + }) + // 阶段 2:provider 的 chunk 真的经 outputch 到了设备。 + waitForWithin(t, "开始输出(chunk 到设备)", 30*time.Second, func() bool { + return len(dev.snapshot()) >= 2 + }) if err := host.StopRTChild(id); err != nil { t.Fatalf("停止: %v", err)