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 全绿。
879 lines
31 KiB
Go
879 lines
31 KiB
Go
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)
|
||
}
|
||
}
|