Files
Cyrene_v2/internal/services/channelbridge/lane_test.go
T
AskaEth 1ae9158250 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 全绿。
2026-10-24 23:18:45 +08:00

879 lines
31 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package channelbridge
// 本文件是 **M22 切片 LANEQUEUE** 的断言集:每条不变量至少一条断言,见 lane.go 顶部的
// 四条不变量清单。
//
// - ① 顺序:同一会话内回复顺序 = 到达顺序(TestLaneSerializesOneSessionInArrivalOrder;
// 主人私聊溢出区另有一条 TestLaneOwnerPrivateSpillKeepsArrivalOrder);
// - ② 一致性:同一会话绝无两个回合同时跑,不同会话可并行
// (TestLaneSerializesOneSessionInArrivalOrder + TestLaneRunsDifferentSessionsInParallel
// + TestLaneKeyNeverCollidesAcrossSessions —— 后者钉住「车道键与会话键同源」);
// - ③ 完整性:被丢的只有「超龄」与「背压」两类,两者都留计数 + 审计 + 日志
// (TestLaneDropsStaleInboundAndTracesIt / TestLaneRejectsNewestWhenFull),
// 主人私聊与非聊天事件另有豁免(TestLaneStaleGateExemptions);
// - ④ 最新性:排队中的消息在**开跑之后**才装配上下文,因此看得到前一轮刚落的历史
// (TestLaneTurnSeesContextAssembledAtStart)。
//
// 另有 TestLaneDeliveryNeverBlocksTheReadLoop:把「读循环 + 回合」的旧形状与新形状放在
// 一起对照 —— 全局回合槽占满时投递仍然立即返回,读循环照常读下一帧(心跳不再被堵)。
import (
"context"
"fmt"
"log/slog"
"strings"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
// laneAdapter 是并发安全的最小适配器:车道让多个会话**并行**发回复,因此这里必须带锁
// (channelbridge_test.go 的 fakeAdapter 是单线程夹具,不能复用)。
type laneAdapter struct {
name string
mu sync.Mutex
sends []adapter.UnifiedMessage
handler adapter.MessageHandler
}
func (a *laneAdapter) Name() string { return a.name }
func (a *laneAdapter) Config() map[string]any { return map[string]any{"bot_token": "tok"} }
func (a *laneAdapter) Connect(context.Context) error { return nil }
func (a *laneAdapter) Disconnect(context.Context) error { return nil }
func (a *laneAdapter) IsConnected() bool { return true }
func (a *laneAdapter) HealthCheck(context.Context) error { return nil }
func (a *laneAdapter) ReadMessages(ctx context.Context, _ func(adapter.UnifiedMessage)) {
<-ctx.Done()
}
func (a *laneAdapter) SendMessage(_ context.Context, resp adapter.UnifiedResponse) error {
a.mu.Lock()
defer a.mu.Unlock()
a.sends = append(a.sends, resp.Messages...)
return nil
}
func (a *laneAdapter) SendProactive(_ context.Context, msg adapter.UnifiedMessage) error {
return a.SendMessage(context.Background(), adapter.UnifiedResponse{Messages: []adapter.UnifiedMessage{msg}})
}
func (a *laneAdapter) SetMessageHandler(fn adapter.MessageHandler) {
a.mu.Lock()
a.handler = fn
a.mu.Unlock()
}
func (a *laneAdapter) Capabilities() adapter.Capabilities { return adapter.DefaultCapabilities() }
func (a *laneAdapter) ListChannels(context.Context) ([]adapter.ChannelInfo, error) {
return nil, nil
}
// laneChatter 记录「谁在什么时候跑」并可在 Run 里阻塞,用来制造「回合还在跑」的窗口。
type laneChatter struct {
// onRun 在**记录之后、返回之前**调用;非 nil 时可用它阻塞或推进注入的时钟。
onRun func(user string)
mu sync.Mutex
runs []laneRun
inFlight int
maxInFlight int
perSession map[string]int
maxPerSess map[string]int
}
// laneRun 是一个已开跑的回合(当次用户文本 + 会话 + 模型可见消息)。
type laneRun struct {
user string
session string
messages []model.Message
}
func (c *laneChatter) Run(_ context.Context, sessionID string, msgs []model.Message) (*loop.Result, error) {
use := lastUserText(msgs)
c.mu.Lock()
if c.perSession == nil {
c.perSession = map[string]int{}
c.maxPerSess = map[string]int{}
}
c.runs = append(c.runs, laneRun{user: use, session: sessionID, messages: msgs})
c.inFlight++
if c.inFlight > c.maxInFlight {
c.maxInFlight = c.inFlight
}
c.perSession[sessionID]++
if c.perSession[sessionID] > c.maxPerSess[sessionID] {
c.maxPerSess[sessionID] = c.perSession[sessionID]
}
c.mu.Unlock()
if c.onRun != nil {
c.onRun(use)
}
c.mu.Lock()
c.inFlight--
c.perSession[sessionID]--
c.mu.Unlock()
return &loop.Result{Text: "reply:" + use}, nil
}
func (c *laneChatter) userTexts() []string {
c.mu.Lock()
defer c.mu.Unlock()
out := make([]string, 0, len(c.runs))
for _, r := range c.runs {
out = append(out, r.user)
}
return out
}
func (c *laneChatter) runCount() int {
c.mu.Lock()
defer c.mu.Unlock()
return len(c.runs)
}
func (c *laneChatter) maxConcurrent() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.maxInFlight
}
func (c *laneChatter) maxConcurrentIn(sessionID string) int {
c.mu.Lock()
defer c.mu.Unlock()
return c.maxPerSess[sessionID]
}
func (c *laneChatter) runAt(i int) laneRun {
c.mu.Lock()
defer c.mu.Unlock()
return c.runs[i]
}
// waitRuns 等到至少 n 个回合开跑(或超时失败)。
func (c *laneChatter) waitRuns(t *testing.T, n int, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if c.runCount() >= n {
return
}
time.Sleep(time.Millisecond)
}
t.Fatalf("only %d of %d turns started within %s (lane never drained?)", c.runCount(), n, timeout)
}
// laneSessions 按消息给出**稳定且互不相同**的隔离会话(同一发送者恒同一个会话)。
type laneSessions struct {
owner bool
}
func (s *laneSessions) GetOrCreate(_ context.Context, msg adapter.UnifiedMessage, _ *user.OwnerProfile) (session.Session, error) {
return session.Session{
Platform: msg.Platform,
ChannelID: msg.ChannelID,
SessionID: "sess-" + msg.ChannelID + "-" + msg.SenderID,
Owner: s.owner,
IsGroup: msg.IsGroup(),
MemWritable: s.owner,
}, nil
}
// laneClock 是可推进的注入时钟(陈旧度闸据此判定,测试因此不必真的等 30s)。
type laneClock struct {
mu sync.Mutex
t time.Time
}
func newLaneClock() *laneClock { return &laneClock{t: time.Unix(1_700_000_000, 0)} }
func (c *laneClock) Now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.t
}
func (c *laneClock) advance(d time.Duration) {
c.mu.Lock()
c.t = c.t.Add(d)
c.mu.Unlock()
}
// advanceTo 把时钟推到 t(只前进不后退)。
//
// 窗口夹具用它表达「时间走到了某个截止时刻」:推进量 = 真的过了多久,
// 与「按请求时长累加」相比不会把时钟推过头(见 coalesceTimers 的说明)。
func (c *laneClock) advanceTo(t time.Time) {
c.mu.Lock()
if t.After(c.t) {
c.t = t
}
c.mu.Unlock()
}
// laneRecorder 收集 onRejected 审计(并发安全)。
type laneRecorder struct {
mu sync.Mutex
rejected []string
}
func (r *laneRecorder) record(_ adapter.UnifiedMessage, reason string) {
r.mu.Lock()
r.rejected = append(r.rejected, reason)
r.mu.Unlock()
}
func (r *laneRecorder) reasons() []string {
r.mu.Lock()
defer r.mu.Unlock()
return append([]string(nil), r.rejected...)
}
func lastUserText(msgs []model.Message) string {
for i := len(msgs) - 1; i >= 0; i-- {
if msgs[i].Role != model.RoleUser {
continue
}
if t := lastText(msgs[i]); t != "" {
return t
}
}
return ""
}
func lastText(m model.Message) string {
for _, c := range m.Content {
if c.Type == model.ContentTypeText {
return c.Text
}
}
return ""
}
// strangerMsg 是一条**陌生人私聊**(非群、非主人):唯一闸门是流控,因此它必然进回合,
// 且不同发送者 = 不同会话。它是「车道并行」与「陈旧度闸」最干净的夹具。
func strangerMsg(sender, content string) adapter.UnifiedMessage {
return adapter.UnifiedMessage{
Platform: "telegram",
ChannelID: sender,
ChannelType: adapter.ChannelDirect,
SenderID: sender,
Sender: adapter.Actor{Role: adapter.RoleMember},
Content: content,
ContentType: adapter.ContentText,
}
}
// ownerMsg 是一条**主人私聊**:车道键恒为 main|web(inboundTargetFor 把它路由进主会话)。
func ownerMsg(content string) adapter.UnifiedMessage {
m := directMessage()
m.Content = content
return m
}
// newLaneBridge 构造一个**只跑 M22 车道语义**的桥接器:本文件(lane_test.go)钉的是
// 车道本身(顺序/串行/背压/陈旧度),因此默认把 M23 的静默窗口**关掉** ——
// 开着窗口时「2s 静默」会让这几条用例从毫秒级变成秒级,并且把「陈旧度何时判」的
// 时间口径搅在一起。窗口/合并/续跑自己的断言都在 coalesce_test.go。
//
// 需要窗口的用例显式传 Options.Coalesce(见 newCoalesceBridge)。
func newLaneBridge(t *testing.T, opts Options, ad *laneAdapter) *Bridge {
t.Helper()
if opts.Coalesce.For == nil {
opts.Coalesce.For = coalesceOffFor
}
b, err := New(opts)
if err != nil {
t.Fatalf("New: %v", err)
}
if ad != nil {
b.Add(ad)
}
return b
}
// waitLaneIdle 等到注册表里没有活跃车道(队列已排空)或超时。
func waitLaneIdle(t *testing.T, b *Bridge, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if b.LaneStats().ActiveLanes == 0 {
return
}
time.Sleep(time.Millisecond)
}
t.Fatalf("lanes never went idle: %+v", b.LaneStats())
}
// TestLaneSerializesOneSessionInArrivalOrder 断言不变量 ①(顺序)与 ②(一致性):
// 同一个会话(这里是主人私聊 ⇒ 同一条 main 车道)的五条消息**先来先跑、绝不同时跑**。
func TestLaneSerializesOneSessionInArrivalOrder(t *testing.T) {
t.Parallel()
chat := &laneChatter{}
b := newLaneBridge(t, Options{
Loop: chat,
Sessions: &laneSessions{owner: true},
Owner: testOwnerProfile("telegram", "100"),
}, nil)
ad := &laneAdapter{name: "telegram"}
const n = 5
for i := 0; i < n; i++ {
b.onMessage(context.Background(), ownerMsg(fmt.Sprintf("m%d", i)), ad)
}
chat.waitRuns(t, n, 5*time.Second)
got := chat.userTexts()
want := []string{"m0", "m1", "m2", "m3", "m4"}
if len(got) != len(want) {
t.Fatalf("ran %d turns, want %d: %v", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("turn order = %v, want %v", got, want)
}
}
// ②:同一会话绝不并发(主人私聊跨平台共用 main 车道,因此这里是会话级而非账号级)。
if m := chat.maxConcurrent(); m != 1 {
t.Errorf("max concurrent turns = %d, want 1 for one session", m)
}
if m := chat.maxConcurrentIn(history.MainSessionID); m != 1 {
t.Errorf("max concurrent turns in %s = %d, want 1", history.MainSessionID, m)
}
}
// TestLaneRunsDifferentSessionsInParallel 断言 ②的另一半:不同会话**互不排队** ——
// 两个陌生人各自私聊她,两个回合必须同时在跑(串行的话下面会以 maxInFlight=1 失败)。
func TestLaneRunsDifferentSessionsInParallel(t *testing.T) {
t.Parallel()
var mu sync.Mutex
entered := 0
both := make(chan struct{})
chat := &laneChatter{}
chat.onRun = func(string) {
mu.Lock()
entered++
if entered == 2 {
close(both)
}
mu.Unlock()
select {
case <-both:
case <-time.After(3 * time.Second):
// 串行了:返回让断言失败,而不是把测试挂死。
}
}
b := newLaneBridge(t, Options{Loop: chat, Sessions: &laneSessions{}, Owner: &user.OwnerProfile{}}, nil)
ad := &laneAdapter{name: "telegram"}
b.onMessage(context.Background(), strangerMsg("200", "a"), ad)
b.onMessage(context.Background(), strangerMsg("201", "b"), ad)
chat.waitRuns(t, 2, 5*time.Second)
if m := chat.maxConcurrent(); m != 2 {
t.Fatalf("max concurrent turns across two sessions = %d, want 2 (lanes must not share)", m)
}
}
// TestLaneDropsStaleInboundAndTracesIt 断言 ③ 的第一类丢弃:**轮到时已超龄 ⇒ 不补答**,
// 且丢得有声:计数 + onRejected 审计 + 带 source 的 WARN 日志。
func TestLaneDropsStaleInboundAndTracesIt(t *testing.T) {
t.Parallel()
logs := &safeBuffer{}
clock := newLaneClock()
rec := &laneRecorder{}
chat := &laneChatter{}
// 两条都投递完再让时钟越过阈值:第一轮**卡住**等这个信号,车道因此不会在两条之间
// 退休(退休后第二条会进一条新车,arrivedAt 取的是推进后的时钟 ⇒ 它永远不超龄,
// 用例假红)。这不是放宽断言 —— 「第二条超龄被丢」仍然由 stats/审计/日志三处断言。
bothQueued := make(chan struct{})
chat.onRun = func(user string) {
if user == "first" {
<-bothQueued
// 第一轮跑着的时候时间越过阈值:排在后面的那条等到自己开跑时已超龄。
clock.advance(defaultLaneStaleAfter + time.Second)
}
}
b := newLaneBridge(t, Options{
Loop: chat,
Sessions: &laneSessions{},
Owner: &user.OwnerProfile{},
Now: clock.Now,
OnRejected: rec.record,
Log: slog.New(slog.NewTextHandler(logs, &slog.HandlerOptions{Level: slog.LevelDebug})),
}, nil)
ad := &laneAdapter{name: "telegram"}
b.onMessage(context.Background(), strangerMsg("200", "first"), ad)
b.onMessage(context.Background(), strangerMsg("200", "stale by the time it runs"), ad)
close(bothQueued)
chat.waitRuns(t, 1, 5*time.Second)
waitLaneIdle(t, b, 2*time.Second)
if stats := b.LaneStats(); stats.DroppedStale != 1 {
t.Fatalf("dropped stale = %d, want 1 (stats=%+v)", stats.DroppedStale, stats)
}
if reasons := rec.reasons(); len(reasons) != 1 || reasons[0] != laneDropReasonStale {
t.Fatalf("audit reasons = %v, want [%s]", reasons, laneDropReasonStale)
}
if got := chat.userTexts(); len(got) != 1 || got[0] != "first" {
t.Fatalf("turns = %v, want only the fresh first one (the stale one must not run)", got)
}
if out := logs.String(); !strings.Contains(out, "inbound dropped by session lane") ||
!strings.Contains(out, laneDropReasonStale) {
t.Fatalf("drop left no trace in the log:\n%s", out)
}
}
// TestLaneRejectsNewestWhenFull 断言 ③ 的第二类丢弃:车道满时**在门前**拒收这一条
// (队列里已有的条目一条都不动 —— M23 的 takeAll 依赖队列完整),且拒收留痕
// (计数 + 审计)。深度 1 时:m1 在跑、m2 已排上,m3 被拒收。
// 注意语义是「拒收最新、队列不动」,**不是**「挤掉最旧」(复审 P2-2 的口径澄清)。
func TestLaneRejectsNewestWhenFull(t *testing.T) {
t.Parallel()
gate := make(chan struct{})
chat := &laneChatter{}
chat.onRun = func(user string) {
if user == "m1" {
<-gate // 第一个回合卡住,后面的条目只能在车道上等
}
}
rec := &laneRecorder{}
b := newLaneBridge(t, Options{
Loop: chat,
Sessions: &laneSessions{},
Owner: &user.OwnerProfile{},
LaneDepth: 1,
OnRejected: rec.record,
}, nil)
ad := &laneAdapter{name: "telegram"}
b.onMessage(context.Background(), strangerMsg("200", "m1"), ad)
chat.waitRuns(t, 1, 5*time.Second) // m1 已在跑(车道空出来)
b.onMessage(context.Background(), strangerMsg("200", "m2"), ad)
b.onMessage(context.Background(), strangerMsg("200", "m3"), ad) // 深度 1:m3 在门前被拒收
close(gate)
chat.waitRuns(t, 2, 5*time.Second)
waitLaneIdle(t, b, 2*time.Second)
if stats := b.LaneStats(); stats.DroppedOverflow != 1 {
t.Fatalf("dropped overflow = %d, want 1 (stats=%+v)", stats.DroppedOverflow, stats)
}
if reasons := rec.reasons(); len(reasons) != 1 || reasons[0] != laneDropReasonOverflow {
t.Fatalf("audit reasons = %v, want [%s]", reasons, laneDropReasonOverflow)
}
if got := chat.userTexts(); len(got) != 2 || got[0] != "m1" || got[1] != "m2" {
t.Fatalf("turns = %v, want [m1 m2] (m3 is the one rejected at the gate)", got)
}
}
// 🔴 TestLaneOwnerPrivateNeverDroppedOnOverflow 守住主人 2026-10-24 的裁定:
// **主人私聊永不因背压被吃掉**(「宁可晚回,不许不回」)。深度 1、第一轮卡住时连投四条,
// 四条必须一条不落地按到达顺序跑完 —— 溢出区只排队、绝不拒收。
// 变异检验:把主人私聊塞进群聊那条拒收路径 ⇒ 本用例立刻红(只剩两条回合)。
func TestLaneOwnerPrivateNeverDroppedOnOverflow(t *testing.T) {
t.Parallel()
gate := make(chan struct{})
chat := &laneChatter{}
chat.onRun = func(user string) {
if user == "m1" {
<-gate
}
}
rec := &laneRecorder{}
b := newLaneBridge(t, Options{
Loop: chat,
Sessions: &laneSessions{owner: true},
Owner: testOwnerProfile("telegram", "100"),
LaneDepth: 1,
OnRejected: rec.record,
}, nil)
ad := &laneAdapter{name: "telegram"}
b.onMessage(context.Background(), ownerMsg("m1"), ad)
chat.waitRuns(t, 1, 5*time.Second)
for _, m := range []string{"m2", "m3", "m4"} {
b.onMessage(context.Background(), ownerMsg(m), ad)
}
close(gate)
chat.waitRuns(t, 4, 5*time.Second)
waitLaneIdle(t, b, 2*time.Second)
if stats := b.LaneStats(); stats.DroppedOverflow != 0 || stats.DroppedStale != 0 {
t.Fatalf("owner private must never be dropped: %+v", stats)
}
if reasons := rec.reasons(); len(reasons) != 0 {
t.Fatalf("owner private must not reach the audit drop track: %v", reasons)
}
got := chat.userTexts()
want := []string{"m1", "m2", "m3", "m4"}
if len(got) != len(want) {
t.Fatalf("turns = %v, want all %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("turn order = %v, want %v", got, want)
}
}
}
// TestLaneStaleGateExemptions 钉住陈旧度闸的两类豁免:**主人私聊**(迟到的回答仍是回答)
// 与**非聊天事件**(留存与审计的唯一来源)。两者都不许被算进丢弃计数。
func TestLaneStaleGateExemptions(t *testing.T) {
t.Parallel()
clock := newLaneClock()
chat := &laneChatter{}
chat.onRun = func(user string) {
if user == "first" {
clock.advance(defaultLaneStaleAfter + time.Second)
}
}
b := newLaneBridge(t, Options{
Loop: chat,
Sessions: &laneSessions{owner: true},
Owner: testOwnerProfile("telegram", "100"),
Now: clock.Now,
}, nil)
ad := &laneAdapter{name: "telegram"}
b.onMessage(context.Background(), ownerMsg("first"), ad)
b.onMessage(context.Background(), ownerMsg("late owner private"), ad)
chat.waitRuns(t, 2, 5*time.Second)
// 非聊天事件(ContentAction)同样豁免:它不产生回复,但必须被登记/留存。
event := strangerMsg("200", "")
event.ContentType = adapter.ContentAction
b.onMessage(context.Background(), event, ad)
waitLaneIdle(t, b, 2*time.Second)
if stats := b.LaneStats(); stats.DroppedStale != 0 || stats.DroppedOverflow != 0 {
t.Fatalf("exempt messages must never be counted as dropped: %+v", stats)
}
if got := chat.userTexts(); len(got) < 2 || got[1] != "late owner private" {
t.Fatalf("owner private turns = %v, want the late one to run despite its age", got)
}
}
// TestLaneTurnSeesContextAssembledAtStart 断言 ④(最新性):一条在车道上排队的消息,
// 开跑时看到的是**当时**的历史 —— 前一轮刚落库的回复就在它的上下文里。
// 若上下文在入队时就被快照,第二条绝看不到 reply:first。
func TestLaneTurnSeesContextAssembledAtStart(t *testing.T) {
t.Parallel()
gate := make(chan struct{})
chat := &laneChatter{}
chat.onRun = func(user string) {
if user == "first" {
<-gate // 让第二条在车道上等,第一轮的落库全在它之后
}
}
b := newLaneBridge(t, Options{
Loop: chat,
Sessions: &laneSessions{owner: true},
Owner: testOwnerProfile("telegram", "100"),
History: newLaneHistory(),
}, nil)
ad := &laneAdapter{name: "telegram"}
b.onMessage(context.Background(), ownerMsg("first"), ad)
chat.waitRuns(t, 1, 5*time.Second)
b.onMessage(context.Background(), ownerMsg("second"), ad)
close(gate)
chat.waitRuns(t, 2, 5*time.Second)
var assistant []string
for _, m := range chat.runAt(1).messages {
if m.Role == model.RoleAssistant {
assistant = append(assistant, lastText(m))
}
}
// 转录会把历史行渲染成段落(`<p>…</p>`),因此按包含判定,不按字面相等。
joined := strings.Join(assistant, "\n")
if !strings.Contains(joined, "reply:first") {
t.Fatalf("second turn assistant transcript = %q, want it to contain reply:first", joined)
}
}
// TestLaneDeliveryNeverBlocksTheReadLoop 是**读与回水解耦**的回归断言。
//
// 旧形状(报障现场):适配器的读循环调用 onMessage,onMessage 同步抢 16 个全局回合槽,
// 槽满即**停止读 socket** —— 心跳不被回、后续帧不被解析,消息堆在连接缓冲里,跑完一轮
// 再按条重放(「后面才回复前面的话」)。
//
// 新形状:投递立刻返回(进车道排队),全局回合槽在车道 worker 里取。这里把 16 个槽全部
// 占满,然后走一遍「读循环 → onMessage(一帧) → 心跳帧」:心跳必须立刻被读到,
// 而那一帧的回合一个都不许开跑(槽确实还满着)。
func TestLaneDeliveryNeverBlocksTheReadLoop(t *testing.T) {
t.Parallel()
chat := &laneChatter{}
b := newLaneBridge(t, Options{
Loop: chat,
Sessions: &laneSessions{owner: true},
Owner: testOwnerProfile("telegram", "100"),
}, nil)
ad := &laneAdapter{name: "telegram"}
// 占满全局回合槽:此刻任何一个回合都无法开跑。
for i := 0; i < maxConcurrent; i++ {
b.sem <- struct{}{}
}
// 读循环的形状:串行处理每一帧;「心跳」是同一循环里的一帧(真实的 qq 适配器在
// readConn 里对 opPing 回 opPong,位置就在 dispatch 之前两行)。
type frame struct {
msg *adapter.UnifiedMessage
pong chan struct{}
}
frames := make(chan frame, 4)
loopCtx, stopReader := context.WithCancel(context.Background())
defer stopReader()
readerDone := make(chan struct{})
go func() {
defer close(readerDone)
for {
select {
case <-loopCtx.Done():
return
case f := <-frames:
if f.pong != nil {
close(f.pong)
continue
}
b.onMessage(loopCtx, *f.msg, ad) // ← 投递必须立即返回
}
}
}()
slow := ownerMsg("slow turn")
frames <- frame{msg: &slow}
pong := make(chan struct{})
frames <- frame{pong: pong}
select {
case <-pong:
case <-time.After(2 * time.Second):
t.Fatal("read loop never reached the heartbeat frame: delivery is blocking the reader")
}
// 槽还满着 ⇒ 回合一个都不该开跑;投递却已经返回并把消息排进了车道。
if n := chat.runCount(); n != 0 {
t.Fatalf("turns started = %d while every global slot was taken, want 0", n)
}
// 让位:释放槽,回合照旧跑完(排队不等于丢弃)。
for i := 0; i < maxConcurrent; i++ {
<-b.sem
}
chat.waitRuns(t, 1, 5*time.Second)
stopReader()
<-readerDone
}
// laneHistory 是最小的历史存储替身:只实现入站流水线真正用到的两条(AppendFull / Recent),
// 其余方法由内嵌的 nil 接口兜住(被调用即 panic —— 测试因此不会静默走错路)。
type laneHistory struct {
history.HistoryStore
mu sync.Mutex
rows map[string][]history.Message
}
func newLaneHistory() *laneHistory {
return &laneHistory{rows: map[string][]history.Message{}}
}
func (h *laneHistory) AppendFull(_ context.Context, msg history.Message) (history.Message, error) {
h.mu.Lock()
defer h.mu.Unlock()
h.rows[msg.SessionID] = append(h.rows[msg.SessionID], msg)
return msg, nil
}
func (h *laneHistory) Recent(_ context.Context, sessionID string, n int) ([]history.Message, error) {
h.mu.Lock()
defer h.mu.Unlock()
rows := h.rows[sessionID]
if len(rows) > n {
rows = rows[len(rows)-n:]
}
return append([]history.Message(nil), rows...), nil
}
// kookGroupMsg 是一条 kook 群消息的**生产形状**:`ChannelID` 是 text channel(频道),
// `GroupID` 是 guild。发送者是白名单里的主人且这条**指向她本人**(`AddressedToMe`,
// 结构化字段),因此它必定进回合 —— 与真人「主人在群里 @ 她」同一条路。
//
// 它会进回合而**不受**非主人群聊的最小打扰分级影响,这正是我们要的:本用例要观察的
// 只有「车道怎么分」,不是「要不要回答」。
func kookGroupMsg(channelID, groupID, content string) adapter.UnifiedMessage {
return adapter.UnifiedMessage{
Platform: "kook",
ChannelID: channelID,
ChannelType: adapter.ChannelGroup,
GroupID: groupID,
SenderID: "100",
Sender: adapter.Actor{Role: adapter.RoleOwner},
Content: content,
ContentType: adapter.ContentText,
AddressedToMe: true,
}
}
// TestLaneKeyNeverCollidesAcrossSessions 钉住复审 P1-1 的修法:**车道键与会话键同源**
// (`laneKey` 逐字就是 `session.Key`),因此「同一个 session」绝不可能有两条车道。
//
// 表里必须含复审给出的两条**真实反例**(它们在旧实现 `a.Name()+"|"+ConversationKey`
// 下会分成两条车道、两个回合并发):
// - kook:同一 guild 的两个 text channel(c1/c2)—— 会话层按 `group:<GroupID>` 判成
// 同一个群会话,旧键却按 `ChannelID` 拆开;
// - telegram:同一平台两个账号对同一对端(`telegram:conf0` / `telegram:conf1`)——
// 会话键只含 platform + channel,旧键却把账号名算进车道。
//
// 断言方式是**行为级**的(不只看键字符串):第一条回合卡住时,第二条必须还在排队;
// 而控制行(不同群)必须能真的并行,证明本修法没有把所有会话串成一条车道。
func TestLaneKeyNeverCollidesAcrossSessions(t *testing.T) {
t.Parallel()
cases := []struct {
name string
// 两条消息:同一 session 的必须共用一条车道;控制行则是两个不同 session。
first, second adapter.UnifiedMessage
// adapterNames 是两个账号的注册名(多账号场景不同,单账号/单频道场景相同)。
firstAdapter, secondAdapter string
// sessionOwner 是会话替身报告的身份车道:主人消息 true、陌生人私聊 false
// (与真实 SessionManager 由 actor 派生的口径一致;它决定最小打扰分级的分支)。
sessionOwner bool
// wantParallel 为 true 表示这两条属于**不同** session,必须能同时跑。
wantParallel bool
}{
{
name: "kook: 同 guild 的两个 text channel 是同一个群会话",
first: kookGroupMsg("c1", "g1", "a"),
second: kookGroupMsg("c2", "g1", "b"),
firstAdapter: "kook:conf0",
secondAdapter: "kook:conf0",
sessionOwner: true,
},
{
name: "telegram: 同平台两个账号对同一对端是同一个会话",
first: strangerMsg("200", "a"),
second: strangerMsg("200", "b"),
firstAdapter: "telegram:conf0",
secondAdapter: "telegram:conf1",
sessionOwner: false,
},
{
name: "控制组: 不同群仍是不同会话(必须能并行)",
first: kookGroupMsg("c1", "g1", "a"),
second: kookGroupMsg("c2", "g2", "b"),
firstAdapter: "kook:conf0",
secondAdapter: "kook:conf0",
sessionOwner: true,
wantParallel: true,
},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel()
gate := make(chan struct{})
chat := &laneChatter{}
chat.onRun = func(string) { <-gate } // 每个回合都卡住,便于观察「谁在跑」
b := newLaneBridge(t, Options{
Loop: chat,
Sessions: &laneSessions{owner: c.sessionOwner},
Owner: testOwnerProfile("kook", "100"),
}, nil)
first := &laneAdapter{name: c.firstAdapter}
second := &laneAdapter{name: c.secondAdapter}
b.onMessage(context.Background(), c.first, first)
chat.waitRuns(t, 1, 5*time.Second) // 第一条已在跑(并卡住)
b.onMessage(context.Background(), c.second, second)
if c.wantParallel {
chat.waitRuns(t, 2, 3*time.Second) // 不同会话:第二条立刻开跑
} else {
// 同一会话:第二条必须还在排队 —— 给它足够的时间「如果会跑就跑起来」。
assertRunCountStays(t, chat, 1, 200*time.Millisecond)
}
close(gate)
chat.waitRuns(t, 2, 5*time.Second)
})
}
}
// assertRunCountStays 在窗口期内反复确认回合数没有超过 want(用于断言「第二条没跑起来」)。
func assertRunCountStays(t *testing.T, c *laneChatter, want int, window time.Duration) {
t.Helper()
deadline := time.Now().Add(window)
for time.Now().Before(deadline) {
if n := c.runCount(); n > want {
t.Fatalf("turns started = %d, want at most %d: the second message must wait in the same lane", n, want)
}
time.Sleep(time.Millisecond)
}
}
// TestLaneOwnerPrivateSpillKeepsArrivalOrder 断言不变量 ①对**溢出区**同样成立:
// 主人私聊车道满了以后,新到的条目必须排在溢出区**末尾**,绝不能因为 `ch` 腾出一个位置
// 就超车到已在排队的条目前面。
//
// 复审实测的旧行为是 `[m1 m2 m3 m5 m4]`(到达顺序 m1..m5):m4 落进 spill 后,m1 结束让
// `ch` 空出一格,m5 于是先进 `ch` 而先跑。修法是「spill 非空后一律追加 spill」。
func TestLaneOwnerPrivateSpillKeepsArrivalOrder(t *testing.T) {
t.Parallel()
release := make(chan struct{}, 8)
chat := &laneChatter{}
chat.onRun = func(string) { <-release } // 每个回合都由测试逐个放行
b := newLaneBridge(t, Options{
Loop: chat,
Sessions: &laneSessions{owner: true},
Owner: testOwnerProfile("telegram", "100"),
LaneDepth: 2,
}, nil)
ad := &laneAdapter{name: "telegram"}
b.onMessage(context.Background(), ownerMsg("m1"), ad)
chat.waitRuns(t, 1, 5*time.Second) // m1 在跑(卡住)
b.onMessage(context.Background(), ownerMsg("m2"), ad)
b.onMessage(context.Background(), ownerMsg("m3"), ad) // 深度 2:ch 满
b.onMessage(context.Background(), ownerMsg("m4"), ad) // 落 spill
release <- struct{}{} // 放 m1 走
chat.waitRuns(t, 2, 5*time.Second) // m2 开跑 ⇒ ch 空出一格
b.onMessage(context.Background(), ownerMsg("m5"), ad) // 关键交错:此刻新条目到达
for i := 0; i < 4; i++ { // 逐条放行剩下的 m2..m5
release <- struct{}{}
}
chat.waitRuns(t, 5, 5*time.Second)
waitLaneIdle(t, b, 2*time.Second)
got := chat.userTexts()
want := []string{"m1", "m2", "m3", "m4", "m5"}
if len(got) != len(want) {
t.Fatalf("turns = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("turn order = %v, want %v (spill must never be overtaken)", got, want)
}
}
if stats := b.LaneStats(); stats.DroppedOverflow != 0 || stats.DroppedStale != 0 {
t.Fatalf("owner private must never be dropped: %+v", stats)
}
}