Files
AskaEth d81635672e
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(channel): 热重载把新适配器写回能力注册表
面板保存渠道配置后 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 交叉编译通过。
2026-10-13 19:21:49 +08:00

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)
}
}