test(channelbridge): 窗口夹具按截止时刻推钟 + 去掉无法证实的替身锁(M33 r1 复审)

P1-1(channelbridge 的 -race 门仍红,红在用例不确定性而不是竞态):
- coalesceTimers 改成**截止时刻**语义:每次 after(d) 记 at = 注入时钟 + d,
  推钟只把时钟推进到「当前最早的截止时刻」再触发它。旧写法按 FIFO 逐个触发并
  按请求时长累加,实测 12 条消息内攒到 5 个废弃定时器把时钟推 +10s(窗口 2s、
  绝对上限 6s)⇒ 循环头走「到点直接发车、不 takeAll」的分支,队列尾部落到下一批。
- 推钟前强制「车道排空」这条不变量(pump/advanceAndFire/waitIdle):
  时钟只表示时间过去,它一转就可能跳过 takeAll,因此先等 worker 把货收进本批。
- sendSettled:投递后按**序号**等「这次投递之后新挂的定时器」(旧的都已被 worker
  放弃、再没人监听),供「投递与推钟交替」的用例(每 500ms 一条的绝对上限用例)。
- fireLatest/advanceAndFire 带 done 谓词重试,避免「登记与触发之间续臂」导致
  推了钟却没人醒;waitIdle 一边等一边推窗口(worker 停在窗口里时永远走不到退休)。
- 用例侧只补相位与等待,不动断言:TwelveBurst 读历史前等 assistant 行落库;
  准入用例断言「最新一次续臂」(原来按下标取 d[1],隐含臂数假设);
  stale 用例两条都投完再推时钟(车道不会在两条之间退休)。

P2-1(替身锁无法用变异证实)选 A:删掉 stubChatter/stubSessions 的锁与访问器,
退回原字段访问,并写清理由 —— 21 个用例全走同步便捷路径
(b.process → processFrom → runBatch → Loop.Run),DEV HEAD 上 -race 数十次跑
(含把锁删掉的变异)DATA RACE 恒为 0;若将来从车道 worker 触碰这些字段必须加锁
并补 -race 用例。

证据:go test -race -count=3 ./internal/services/channelbridge/ 连跑 6 轮
18/18 全绿、DATA RACE=0;coalesce 子集 -count=80 全绿;整包 -race -count=20 全绿;
全树 go build/vet/test -count=1 全绿。
This commit is contained in:
AskaEth committed 2026-10-24 23:18:45 +08:00
1 parent 7d9b2132bb
commit 1ae9158250
20 files changed
+584 -371

No files matched your search

@@ -36,7 +36,7 @@ func TestCoalesceWaitsForQuietThenFlushes(t *testing.T) {
}
// 窗口内的第二条:计时必须重置(新定时器 = 从 m2 起再等满一个窗口)。
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "m2"))
f.timers.advanceAndFire(t, f.clock)
f.advanceAndFireUntil(t)
f.waitRuns(t, 1)
if n := chat.runCount(); n != 1 {
@@ -65,7 +65,7 @@ func TestCoalesceFlushesOnWindowExpiry(t *testing.T) {
if n := chat.runCount(); n != 0 {
t.Fatalf("turns started = %d before the window expired, want 0", n)
}
f.timers.advanceAndFire(t, f.clock)
f.advanceAndFireUntil(t)
f.waitRuns(t, 1)
if n := chat.runCount(); n != 1 {
@@ -99,11 +99,15 @@ func TestCoalesceTwelveBurstOneTurn(t *testing.T) {
f.pump(t, func() bool { return chat.runCount() >= 1 })
if got := chat.runCount(); got != 1 {
t.Fatalf("turns started = %d, want exactly 1 for a 12-message burst", got)
t.Fatalf("turns started = %d, want exactly 1 for a 12-message burst(各批=%v stats=%+v)",
got, runTexts(chat), f.b.CoalesceStats())
}
if got := userTextsIn(chat.runAt(0).messages); len(got) != n {
t.Fatalf("batch user messages = %d, want %d: %v", len(got), n, got)
}
// 回合是异步跑完再落库的:先等 assistant 行出现,否则读到的是半成品
// (实测「12 user + 0 assistant」)。
hist.waitRows(t, "sess--20", "assistant", 1, 5*time.Second)
rows := hist.snapshot("sess--20")
userRows, assistantRows := 0, 0
turnIDs := map[string]struct{}{}
@@ -285,14 +289,16 @@ func TestCoalesceMaxWaitForcesFlush(t *testing.T) {
Loop: chat,
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "m1"))
// 每条投递后都等 worker 收走再推进时钟:时钟只表示「时间过去了」,它一转,
// 循环头就可能直接按到点发车(那条分支不 takeAll),没被收走的尾部会漏到下一批。
f.sendSettled(t, coalesceGroupMsg("telegram", "-20", "9", "张三", "m1"))
for i := 1; i < 12; i++ {
f.clock.advance(500 * time.Millisecond)
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", fmt.Sprintf("m%d", i+1)))
f.sendSettled(t, coalesceGroupMsg("telegram", "-20", "9", "张三", fmt.Sprintf("m%d", i+1)))
}
// 最后那个定时器请求的正是「距离 6s 绝对上限还差多少」——按它推完最后 500ms,
// 循环头因此看见 maxLeft<=0 并按 max_wait 发车(滑动窗口被一条条重置也压不住它)。
f.timers.advanceAndFire(t, f.clock)
f.advanceAndFireUntil(t)
f.waitRuns(t, 1)
if n := chat.runCount(); n != 1 {
t.Fatalf("turns started = %d, want 1 (max wait forces one flush)", n)