package channelbridge import ( "context" "sync" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" ) // deadSessionAdapter mimics a channel whose session dies while the bridge is // running — today the iLink session rejection (errcode=-14): Connect // reports success, the read loop then returns on its own (nothing cancelled it) // and IsConnected reports false from that point on. type deadSessionAdapter struct { name string mu sync.Mutex up bool } func (a *deadSessionAdapter) Name() string { return a.name } func (a *deadSessionAdapter) Config() map[string]any { return map[string]any{"bot_token": "tok"} } func (a *deadSessionAdapter) Connect(context.Context) error { a.mu.Lock() a.up = true a.mu.Unlock() return nil } func (a *deadSessionAdapter) Disconnect(context.Context) error { a.mu.Lock() a.up = false a.mu.Unlock() return nil } func (a *deadSessionAdapter) IsConnected() bool { a.mu.Lock() defer a.mu.Unlock() return a.up } func (a *deadSessionAdapter) HealthCheck(context.Context) error { return adapter.ErrNotConnected } func (a *deadSessionAdapter) ReadMessages(context.Context, func(adapter.UnifiedMessage)) { // The session died: the read loop ends by itself, no cancellation involved. a.mu.Lock() a.up = false a.mu.Unlock() } func (a *deadSessionAdapter) SendMessage(context.Context, adapter.UnifiedResponse) error { return adapter.ErrNotConnected } func (a *deadSessionAdapter) SendProactive(context.Context, adapter.UnifiedMessage) error { return adapter.ErrNotConnected } func (a *deadSessionAdapter) SetMessageHandler(adapter.MessageHandler) {} func (a *deadSessionAdapter) Capabilities() adapter.Capabilities { return adapter.DefaultCapabilities() } func (a *deadSessionAdapter) ListChannels(context.Context) ([]adapter.ChannelInfo, error) { return nil, nil } // signaledAdapter is a fakeAdapter whose read loop reports when it has returned, // so a test can wait for a replaced adapter's exit deterministically. type signaledAdapter struct { *fakeAdapter exited chan struct{} } func (a *signaledAdapter) ReadMessages(ctx context.Context, _ func(adapter.UnifiedMessage)) { <-ctx.Done() close(a.exited) } // TestReadLoopExitClearsConnectedFlag is the regression for "the adapter goes mute // while the bridge still claims it is connected": the read loop ends with the // session down and nothing cancelled it, so IsConnected/ConnectedCount must stop // reporting a live channel. func TestReadLoopExitClearsConnectedFlag(t *testing.T) { t.Parallel() b := newReloadBridge(t, nil) ad := &deadSessionAdapter{name: "weixin-ilink"} b.Add(ad) b.Start() waitDeadline := time.Now().Add(2 * time.Second) for time.Now().Before(waitDeadline) { if !b.IsConnected("weixin-ilink") { if n := b.ConnectedCount(); n != 0 { t.Fatalf("ConnectedCount() = %d, want 0 after the only read loop died", n) } return } time.Sleep(time.Millisecond) } t.Fatal("bridge still reports weixin-ilink as connected after its read loop ended") } // TestReplacedAdapterExitKeepsReplacementConnected guards the other direction: the // read-loop exit accounting must not undo a replacement installed by a hot reload. // The replaced adapter's loop is cancelled by Reload (a deliberate stop), so its // exit says nothing about the adapter that now owns the name. func TestReplacedAdapterExitKeepsReplacementConnected(t *testing.T) { t.Parallel() old := &signaledAdapter{ fakeAdapter: &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "old"}}, exited: make(chan struct{}), } factory := &stubFactory{adpts: []adapter.PlatformAdapter{ &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "new"}}, }} b := newReloadBridge(t, factory) b.Add(old) b.Start() if err := b.Reload("kook"); err != nil { t.Fatalf("Reload: %v", err) } <-old.exited // The exit accounting runs immediately after the read path returns; a short // settle window catches it without relying on a specific interleaving. settle := time.Now().Add(50 * time.Millisecond) for time.Now().Before(settle) { if !b.IsConnected("kook:conf0") { t.Fatal("the replaced adapter's read-loop exit cleared the replacement's connected flag") } time.Sleep(time.Millisecond) } }