面板保存渠道配置后 Bridge.Reload 只重建了桥接器自己的适配器表,能力注册表里的条目仍指向保存前的旧实例;而管理面的渠道列表、每账号连接状态、open/close 的目标解析全部读注册表 —— 于是卡片永远「未连接」,点「连接」驱动的是旧实例(按旧 URL 拨号)。 生产日志实证:保存后 bridge 侧新实例连上 ws://localhost:3001 且心跳正常,而点连接时被驱动的适配器拨的是旧地址 ws://localhost:10301。 改法:注册表新增 upsert 语义的 ReplaceChannel(覆盖 Value、保留运行期 Policy;Register 遇重名会 ErrDuplicate,热重载必然同名);桥接器经窄端口在适配器上线后同步,账号被移除时删除条目;组合根注入 a.caps 包装。同步失败只记日志、不中断 Reload;未接线时保持旧行为并只 WARN 一次。 验收:go build ./... + go vet 全绿;registry/channelbridge/gateway 三包 test 全绿;主会话亲自变异(摘掉 Policy 沿用 ⇒ TestReplaceChannelSwapsValueAndKeepsPolicy 红)。linux/darwin 交叉编译通过。
246 lines
9.2 KiB
Go
246 lines
9.2 KiB
Go
package channelbridge
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync"
|
|
"testing"
|
|
|
|
"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/loop"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
|
)
|
|
|
|
// 本文件钉住渠道热重载的**注册表同步**(design/04 §3.1):能力注册表里的渠道条目只在
|
|
// 启动时写过一次,而热重载重建出来的是同名**新实例**。不同步的话,管理面板(渠道卡片、
|
|
// 账号在线状态、open/close 目标解析)读到的仍是保存前的旧实例——卡片永远「未连接」,
|
|
// 点「连接」驱动的是旧配置里的那把(拨旧 URL)。
|
|
|
|
// errRegistryDown 表示注册表同步失败(被测代码必须吞掉它,只记日志)。
|
|
var errRegistryDown = errors.New("registry unavailable")
|
|
|
|
// errConnectDown 表示新适配器拨号失败。
|
|
var errConnectDown = errors.New("dial refused")
|
|
|
|
// recordingChannelRegistry 是 ChannelRegistry 的记录型实现:按注册名保存**接口值**,
|
|
// 使断言能按指针同一性判定新旧实例(新旧名字相同,比名字必然假绿)。
|
|
type recordingChannelRegistry struct {
|
|
mu sync.Mutex
|
|
entries map[string]sdk.Channel
|
|
removed []string
|
|
replaceErr error
|
|
removeErr error
|
|
}
|
|
|
|
func newRecordingRegistry(preset ...sdk.Channel) *recordingChannelRegistry {
|
|
reg := &recordingChannelRegistry{entries: make(map[string]sdk.Channel, len(preset))}
|
|
for _, ch := range preset {
|
|
reg.entries[ch.Name()] = ch
|
|
}
|
|
return reg
|
|
}
|
|
|
|
func (r *recordingChannelRegistry) ReplaceChannel(ch sdk.Channel) error {
|
|
if r.replaceErr != nil {
|
|
return r.replaceErr
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.entries[ch.Name()] = ch
|
|
return nil
|
|
}
|
|
|
|
func (r *recordingChannelRegistry) RemoveChannel(name string) error {
|
|
if r.removeErr != nil {
|
|
return r.removeErr
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.removed = append(r.removed, name)
|
|
delete(r.entries, name)
|
|
return nil
|
|
}
|
|
|
|
// entry 返回当前条目(不存在则 nil),模拟注册表的读侧。
|
|
func (r *recordingChannelRegistry) entry(name string) sdk.Channel {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return r.entries[name]
|
|
}
|
|
|
|
// removedNames 返回所有被要求删除的名字。
|
|
func (r *recordingChannelRegistry) removedNames() []string {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return append([]string(nil), r.removed...)
|
|
}
|
|
|
|
var _ ChannelRegistry = (*recordingChannelRegistry)(nil)
|
|
|
|
// downAdapter 是 Connect 必失败的适配器,用来钉住「新实例没上线就不动注册表」。
|
|
type downAdapter struct {
|
|
*fakeAdapter
|
|
err error
|
|
}
|
|
|
|
func (d *downAdapter) Connect(context.Context) error { return d.err }
|
|
|
|
// newReloadBridgeWithCaps 与 newReloadBridge 同形,额外注入注册表窄端口。
|
|
func newReloadBridgeWithCaps(t *testing.T, factory PlatformFactory, caps ChannelRegistry) *Bridge {
|
|
t.Helper()
|
|
b, err := New(Options{
|
|
Loop: &stubChatter{res: &loop.Result{Text: "hi"}},
|
|
Sessions: &stubSessions{sess: session.Session{SessionID: "s", Owner: true, MemWritable: true}},
|
|
Owner: &user.OwnerProfile{},
|
|
Factory: factory,
|
|
Channels: caps,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
t.Cleanup(func() { b.Stop(context.Background()) })
|
|
return b
|
|
}
|
|
|
|
// TestReloadPublishesNewAdapterInstanceToRegistry 是本次修复的主判据:重载后注册表里
|
|
// 同名条目必须指向**新实例**,否则面板显示「未连接」、点「连接」驱动旧实例。
|
|
func TestReloadPublishesNewAdapterInstanceToRegistry(t *testing.T) {
|
|
t.Parallel()
|
|
old := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "old"}}
|
|
factory := &stubFactory{}
|
|
// 启动注册(wire_channels 的职责)写进注册表的就是这个旧实例,重载要覆盖的正是它。
|
|
reg := newRecordingRegistry(sdk.Channel(old))
|
|
b := newReloadBridgeWithCaps(t, factory, reg)
|
|
b.Add(old)
|
|
b.Start()
|
|
|
|
next := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "new"}}
|
|
factory.adpts = []adapter.PlatformAdapter{next}
|
|
if err := b.Reload("kook"); err != nil {
|
|
t.Fatalf("Reload: %v", err)
|
|
}
|
|
|
|
got := reg.entry("kook:conf0")
|
|
if got == nil {
|
|
t.Fatal("重载后注册表里没有 kook:conf0:面板会列出「账号已配置但没有已注册的适配器」")
|
|
}
|
|
// 指针同一性判据:新旧实例的 Name() 相同,比名字必然假绿。
|
|
if got != sdk.Channel(next) {
|
|
t.Fatalf("注册表条目 = %#v, want 新实例", got)
|
|
}
|
|
if got == sdk.Channel(old) {
|
|
t.Fatal("注册表仍指向旧实例:卡片会永远显示未连接,点「连接」驱动的是旧 URL 的那把")
|
|
}
|
|
if !got.IsConnected() {
|
|
t.Fatal("注册表里的条目未连接:面板会显示未连接")
|
|
}
|
|
if !b.IsConnected("kook:conf0") || old.IsConnected() {
|
|
t.Fatalf("桥接器应当只让新实例在线:new=%v old=%v", b.IsConnected("kook:conf0"), old.IsConnected())
|
|
}
|
|
}
|
|
|
|
// TestReloadRemovesDroppedAccountFromRegistry:账号被删除(或更名)后,它的注册表条目
|
|
// 必须一并消失,否则面板会继续列出一条已经不存在的账号。同时钉住「同名重建的账号绝不
|
|
// 被按名字删除」——否则会误删刚写进去的新实例。
|
|
func TestReloadRemovesDroppedAccountFromRegistry(t *testing.T) {
|
|
t.Parallel()
|
|
dropped := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "t0"}}
|
|
kept := &fakeAdapter{name: "kook:conf1", cfg: map[string]any{"bot_token": "t1"}}
|
|
factory := &stubFactory{}
|
|
reg := newRecordingRegistry(sdk.Channel(dropped), sdk.Channel(kept))
|
|
b := newReloadBridgeWithCaps(t, factory, reg)
|
|
b.Add(dropped)
|
|
b.Add(kept)
|
|
b.Start()
|
|
|
|
nextKept := &fakeAdapter{name: "kook:conf1", cfg: map[string]any{"bot_token": "t1b"}}
|
|
factory.adpts = []adapter.PlatformAdapter{nextKept}
|
|
if err := b.Reload("kook"); err != nil {
|
|
t.Fatalf("Reload: %v", err)
|
|
}
|
|
|
|
if reg.entry("kook:conf0") != nil {
|
|
t.Fatal("被移除账号的注册表条目还在:面板会列出一条不存在的账号")
|
|
}
|
|
if got := reg.entry("kook:conf1"); got != sdk.Channel(nextKept) {
|
|
t.Fatalf("保留账号的条目 = %#v, want 新实例", got)
|
|
}
|
|
for _, name := range reg.removedNames() {
|
|
if name == "kook:conf1" {
|
|
t.Fatal("同名重建的账号被按名字删掉了:会误删刚写回的新实例")
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestReloadWithoutRegistryPortKeepsAdapterRunning:未注入端口时保持旧行为 —— 热重载
|
|
// 照常成功、新实例照常上线,只是不同步注册表(不得因为缺端口而 panic 或中断重载)。
|
|
func TestReloadWithoutRegistryPortKeepsAdapterRunning(t *testing.T) {
|
|
t.Parallel()
|
|
old := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "old"}}
|
|
factory := &stubFactory{}
|
|
b := newReloadBridgeWithCaps(t, factory, nil)
|
|
b.Add(old)
|
|
b.Start()
|
|
|
|
next := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "new"}}
|
|
factory.adpts = []adapter.PlatformAdapter{next}
|
|
if err := b.Reload("kook"); err != nil {
|
|
t.Fatalf("未接线注册表不该让 Reload 失败:%v", err)
|
|
}
|
|
if !next.IsConnected() || !b.IsConnected("kook:conf0") {
|
|
t.Fatal("未接线注册表时新适配器仍应照常上线")
|
|
}
|
|
}
|
|
|
|
// TestReloadSurvivesRegistryFailures:注册表同步失败只记日志,绝不中断重载、绝不返回
|
|
// 错误——面板保存不能被注册表拖垮。
|
|
func TestReloadSurvivesRegistryFailures(t *testing.T) {
|
|
t.Parallel()
|
|
dropped := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "t0"}}
|
|
kept := &fakeAdapter{name: "kook:conf1", cfg: map[string]any{"bot_token": "t1"}}
|
|
factory := &stubFactory{}
|
|
reg := newRecordingRegistry(sdk.Channel(dropped), sdk.Channel(kept))
|
|
reg.replaceErr = errRegistryDown
|
|
reg.removeErr = errRegistryDown
|
|
b := newReloadBridgeWithCaps(t, factory, reg)
|
|
b.Add(dropped)
|
|
b.Add(kept)
|
|
b.Start()
|
|
|
|
nextKept := &fakeAdapter{name: "kook:conf1", cfg: map[string]any{"bot_token": "t1b"}}
|
|
factory.adpts = []adapter.PlatformAdapter{nextKept}
|
|
if err := b.Reload("kook"); err != nil {
|
|
t.Fatalf("注册表同步失败不该让 Reload 失败:%v", err)
|
|
}
|
|
if !b.IsConnected("kook:conf1") || !nextKept.IsConnected() {
|
|
t.Fatal("同步失败时新适配器仍应照常上线")
|
|
}
|
|
}
|
|
|
|
// TestReloadLeavesRegistryAloneWhenNewAccountFailsToConnect:新实例没上线(拨号失败)
|
|
// 就不写注册表——换成连不上的实例只会让面板的在线状态更失真;无关账号的条目不受影响。
|
|
func TestReloadLeavesRegistryAloneWhenNewAccountFailsToConnect(t *testing.T) {
|
|
t.Parallel()
|
|
old := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "old"}}
|
|
down := &downAdapter{fakeAdapter: &fakeAdapter{name: "kook:conf1", cfg: map[string]any{"bot_token": "new"}}, err: errConnectDown}
|
|
factory := &stubFactory{}
|
|
reg := newRecordingRegistry(sdk.Channel(old))
|
|
b := newReloadBridgeWithCaps(t, factory, reg)
|
|
b.Add(old)
|
|
b.Start()
|
|
|
|
nextOld := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "old2"}}
|
|
factory.adpts = []adapter.PlatformAdapter{nextOld, down}
|
|
if err := b.Reload("kook"); err != nil {
|
|
t.Fatalf("单个适配器连不上不该让 Reload 失败:%v", err)
|
|
}
|
|
if reg.entry("kook:conf1") != nil {
|
|
t.Fatal("没连上的新实例不该写进注册表")
|
|
}
|
|
if got := reg.entry("kook:conf0"); got != sdk.Channel(nextOld) {
|
|
t.Fatalf("已上线账号的条目 = %#v, want 新实例", got)
|
|
}
|
|
}
|