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

@@ -60,12 +60,13 @@ func (f *fakeAdapter) ListChannels(context.Context) ([]adapter.ChannelInfo, erro
// stubChatter 记录传给 agent 循环的会话 id 与消息。
//
// 🔴 字段一律经 mu 访问:Run 跑在**车道 worker** 上,测试 goroutine 在另一侧读记录 ——
// 「等一会儿再看循环有没有被驱动」这类负向断言之间**没有** happens-before。无锁时,
// 只要那一轮真被驱动了(正是负向断言要抓的失败形态),-race 就会把它报成 DATA RACE,
// 栈落在替身字段上、看起来像被测代码的问题;加锁不改并发度,只把记录的读写变成有序的。
// 不加锁是**有意的**:用到它的 21 个用例全部走同步便捷路径
// (`b.process → processFrom → runBatch → Loop.Run`,见 inbound.go 的 processFrom 注释),
// 写与读发生在同一个 goroutine 上,没有跨 goroutine 的 happens-before 缺口
// (M33 实测:`-race` 数十次跑、含把锁删掉的变异,DATA RACE 恒为 0)。
// 🔴 若将来有用例把它放上车道(laneWorker)再读这些字段,**必须**加锁(sync.Mutex)
// 并补一条 -race 用例 —— 届时的竞态是真的,不能只靠运气。
type stubChatter struct {
mu sync.Mutex
sessionID string
messages []model.Message
runCtx context.Context
@@ -74,60 +75,17 @@ type stubChatter struct {
}
func (s *stubChatter) Run(ctx context.Context, sessionID string, msgs []model.Message) (*loop.Result, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.sessionID = sessionID
s.messages = msgs
s.runCtx = ctx
return s.res, s.err
}
// SessionID 返回最近一轮的会话 id(空串 = 循环从未被驱动)。
func (s *stubChatter) SessionID() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.sessionID
}
// Messages 返回最近一轮的消息副本:调用方拿到的切片不与替身共享,
// 锁外的读写因此不会互相污染。
func (s *stubChatter) Messages() []model.Message {
s.mu.Lock()
defer s.mu.Unlock()
if s.messages == nil {
return nil
}
return append([]model.Message(nil), s.messages...)
}
// RunCtx 返回最近一轮的 ctx(turn id 接线用例靠它取回合标识)。
func (s *stubChatter) RunCtx() context.Context {
s.mu.Lock()
defer s.mu.Unlock()
return s.runCtx
}
// setResult 换掉本轮返回值:Run 之外改它同样要过锁。
func (s *stubChatter) setResult(res *loop.Result) {
s.mu.Lock()
defer s.mu.Unlock()
s.res = res
}
// clearSessionID 只清「循环被驱动过」这个记录,与旧用例里的 `chat.sessionID = ""`
// 逐字等价(消息与 ctx 照旧保留):让接下来的负向断言重新生效。
func (s *stubChatter) clearSessionID() {
s.mu.Lock()
defer s.mu.Unlock()
s.sessionID = ""
}
// stubSessions 记录入站消息并返回固定会话。
//
// 与 stubChatter 同理:GetOrCreate 在车道 worker 上被调,测试 goroutine 读记录,
// 两侧没有 happens-before —— 记录必须过锁。
// 与 stubChatter 同理:只在同步路径上被调用,因此不加锁;若将来从车道 worker 触碰,
// 必须补锁 + 补一条 -race 用例。
type stubSessions struct {
mu sync.Mutex
msg adapter.UnifiedMessage
owner *user.OwnerProfile
sess session.Session
@@ -135,36 +93,12 @@ type stubSessions struct {
}
func (s *stubSessions) GetOrCreate(_ context.Context, msg adapter.UnifiedMessage, owner *user.OwnerProfile) (session.Session, error) {
s.mu.Lock()
s.msg = msg
s.owner = owner
err, sess := s.err, s.sess
s.mu.Unlock()
if err != nil {
return session.Session{}, err
if s.err != nil {
return session.Session{}, s.err
}
return sess, nil
}
// Msg 返回最近一次解析的入站消息。
func (s *stubSessions) Msg() adapter.UnifiedMessage {
s.mu.Lock()
defer s.mu.Unlock()
return s.msg
}
// Owner 返回最近一次解析的主人档案(未进白名单时为 nil)。
func (s *stubSessions) Owner() *user.OwnerProfile {
s.mu.Lock()
defer s.mu.Unlock()
return s.owner
}
// Sess 返回替身固定给出的会话。
func (s *stubSessions) Sess() session.Session {
s.mu.Lock()
defer s.mu.Unlock()
return s.sess
return s.sess, nil
}
// directMessage 是一条主人私聊消息:platform telegram / 发送者 100。它是否是主人由**白名单**
@@ -208,18 +142,18 @@ func TestProcessRunsPipelineAndReplies(t *testing.T) {
b.process(ctx, directMessage())
if sess.Msg().SenderID != "100" {
t.Errorf("resolved sender = %q, want 100", sess.Msg().SenderID)
if sess.msg.SenderID != "100" {
t.Errorf("resolved sender = %q, want 100", sess.msg.SenderID)
}
// 主人私聊被路由进唯一的 Web 主会话(design/16 §4.4)。
if chat.SessionID() != history.MainSessionID {
t.Errorf("loop session id = %q, want %q", chat.SessionID(), history.MainSessionID)
if chat.sessionID != history.MainSessionID {
t.Errorf("loop session id = %q, want %q", chat.sessionID, history.MainSessionID)
}
// IM 平台上的主人私聊会镜像到主会话。来源由
// 最前面的一行系统上下文承载,用户文本保持不加装饰
// (design/16 §4.6.1):元数据绝不进入用户的自由文本通道。
var sys, userText string
for _, m := range chat.Messages() {
for _, m := range chat.messages {
if m.Role == model.RoleSystem {
sys = m.Content[0].Text
} else if m.Role == model.RoleUser {
@@ -253,8 +187,8 @@ func TestProcessSkipsEmptyText(t *testing.T) {
b.process(context.Background(), msg)
if chat.SessionID() != "" {
t.Errorf("loop ran for empty content, session = %q", chat.SessionID())
if chat.sessionID != "" {
t.Errorf("loop ran for empty content, session = %q", chat.sessionID)
}
if len(ad.sends) != 0 {
t.Errorf("send count = %d, want 0 for empty content", len(ad.sends))
@@ -279,7 +213,7 @@ func TestProcessGroupMessageKeepsGroupTarget(t *testing.T) {
Content: "hello all",
ContentType: adapter.ContentText,
}
chat.setResult(&loop.Result{Text: "group ack"})
chat.res = &loop.Result{Text: "group ack"}
b.process(context.Background(), msg)
@@ -399,8 +333,8 @@ func TestOwnerPrivatePersistsToMainSessionWithPlatform(t *testing.T) {
t.Fatalf("persisted notifications = %d, want 2", len(persisted))
}
// 且循环针对主会话运行,而不是隔离的 sess-1。
if chat.SessionID() != history.MainSessionID {
t.Fatalf("loop session id = %q, want %q", chat.SessionID(), history.MainSessionID)
if chat.sessionID != history.MainSessionID {
t.Fatalf("loop session id = %q, want %q", chat.sessionID, history.MainSessionID)
}
}
@@ -489,8 +423,8 @@ func TestGroupMessageNotPersistedToMainSession(t *testing.T) {
t.Fatalf("record[%d] Mode = %q, want chat", i, rec.Mode)
}
}
if chat.SessionID() != "sess-g" {
t.Fatalf("group loop session id = %q, want sess-g", chat.SessionID())
if chat.sessionID != "sess-g" {
t.Fatalf("group loop session id = %q, want sess-g", chat.sessionID)
}
// 每次隔离写入也会交给广播钩子,使 gateway 能为 IM
// 适配器日志页发出 `channel_persisted`——但消息本身
@@ -539,8 +473,8 @@ func TestBlacklistGroupMessageDroppedBeforeLoop(t *testing.T) {
// 被拉黑的群消息绝不进入 agent 循环,也绝不发送
// 回复;拒绝原因暴露给审计轨迹。
if chat.SessionID() != "" || len(chat.Messages()) != 0 {
t.Fatalf("loop ran for blacklisted message: session=%q messages=%+v", chat.SessionID(), chat.Messages())
if chat.sessionID != "" || len(chat.messages) != 0 {
t.Fatalf("loop ran for blacklisted message: session=%q messages=%+v", chat.sessionID, chat.messages)
}
if len(ad.sends) != 0 {
t.Fatalf("blacklisted message sent %d replies, want 0", len(ad.sends))
@@ -574,8 +508,8 @@ func TestBlacklistUserMessageDroppedBeforeLoop(t *testing.T) {
// 来自不在允许白名单中的用户的私聊消息被丢弃;主人
// 绝不受影响(这里是陌生人)。
if chat.SessionID() != "" || len(chat.Messages()) != 0 {
t.Fatalf("loop ran for non-whitelisted user: session=%q messages=%+v", chat.SessionID(), chat.Messages())
if chat.sessionID != "" || len(chat.messages) != 0 {
t.Fatalf("loop ran for non-whitelisted user: session=%q messages=%+v", chat.sessionID, chat.messages)
}
if len(ad.sends) != 0 {
t.Fatalf("non-whitelisted user sent %d replies, want 0", len(ad.sends))
@@ -606,13 +540,13 @@ func TestGroupMessageThirdPartySenderNameAndBoundary(t *testing.T) {
// 循环看到最前面有一条第三方边界系统消息,加上已带
// [第三方 · 张三] 前缀的用户消息(历史中保留原始
// 内容,因此不丢失来源数据)。
if len(chat.Messages()) != 2 {
t.Fatalf("loop messages = %d, want 2 (boundary + user)", len(chat.Messages()))
if len(chat.messages) != 2 {
t.Fatalf("loop messages = %d, want 2 (boundary + user)", len(chat.messages))
}
if chat.Messages()[0].Role != model.RoleSystem || !strings.Contains(chat.Messages()[0].Content[0].Text, "非管理员账户") {
t.Fatalf("expected third-party boundary system message, got %+v", chat.Messages()[0])
if chat.messages[0].Role != model.RoleSystem || !strings.Contains(chat.messages[0].Content[0].Text, "非管理员账户") {
t.Fatalf("expected third-party boundary system message, got %+v", chat.messages[0])
}
user := chat.Messages()[1]
user := chat.messages[1]
// 第三方说话者由结构化的 SenderName 字段承载,用户文本保持
// 不加装饰(design/16 §4.6.1);来源/边界放在系统消息里。
if user.SenderName != "张三" || user.Content[0].Text != "hello all" {
@@ -651,18 +585,18 @@ func TestOwnerInGroupIsIsolatedButNotLabeledThirdParty(t *testing.T) {
b.process(context.Background(), msg)
// 受限上下文 → 仍会注入第三方边界系统消息。
if len(chat.Messages()) != 2 || chat.Messages()[0].Role != model.RoleSystem {
t.Fatalf("loop messages = %d, want 2 (boundary + user)", len(chat.Messages()))
if len(chat.messages) != 2 || chat.messages[0].Role != model.RoleSystem {
t.Fatalf("loop messages = %d, want 2 (boundary + user)", len(chat.messages))
}
// 主人的消息不会被标为第三方,也不携带 SenderName;用户
// 文本保持不加装饰,群的来源放在系统消息里
// (design/16 §4.6.1)。
user := chat.Messages()[1]
user := chat.messages[1]
if user.SenderName != "" || user.Content[0].Text != "我在群里" {
t.Fatalf("owner group message = %+v, want undecorated text, no SenderName, no [第三方] prefix", user)
}
if !strings.Contains(chat.Messages()[0].Content[0].Text, "群#-20") {
t.Fatalf("origin/boundary system = %q, want group#-20", chat.Messages()[0].Content[0].Text)
if !strings.Contains(chat.messages[0].Content[0].Text, "群#-20") {
t.Fatalf("origin/boundary system = %q, want group#-20", chat.messages[0].Content[0].Text)
}
// 主人在群里的发言仍落在隔离会话(不是 Web 主会话)。
recs := hist.records()
@@ -679,8 +613,8 @@ func TestNoHistoryStoreKeepsBridgeUnchanged(t *testing.T) {
if len(ad.sends) != 1 {
t.Fatalf("send count = %d, want 1", len(ad.sends))
}
if chat.SessionID() != history.MainSessionID {
t.Fatalf("loop session id = %q, want %q", chat.SessionID(), history.MainSessionID)
if chat.sessionID != history.MainSessionID {
t.Fatalf("loop session id = %q, want %q", chat.sessionID, history.MainSessionID)
}
}