fix(io): ChannelRegistry 补 UnbindOutputTarget,Unregister 清理 outputTargets

outputTargets 只增不减:Unregister 一个 inputch 后,指向它的输出目标登记仍
留在表里,ResolveOutputTarget 会继续把消息路由到已不存在的 agent/inputch。
与刚修的驻留 inputch 残留同属「注册未注销」类。现补 UnbindOutputTarget
(幂等),并让 Unregister 顺手清掉显式绑定与同名回退两种目标登记。
This commit is contained in:
HomeAgent Agent
2026-09-13 21:59:10 +08:00
parent a15e7c2dc1
commit 11d9038927
2 changed files with 65 additions and 0 deletions

View File

@ -102,10 +102,20 @@ func (r *ChannelRegistry) Register(ch InputChannel) error {
}
// Unregister 注销一个 inputch。
//
// 同时清理指向它的输出目标登记outputTargets否则通道被注销后
// ResolveOutputTarget 仍会把它解析成一个已不存在的 inputch。
// 两种指向都要清:显式绑定到该 inputch 的,以及“回退到同名通道”的
// InputCh == "" 表示与输出通道同名)。
func (r *ChannelRegistry) Unregister(name string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.channels, name)
for out, t := range r.outputTargets {
if t.InputCh == name || (t.InputCh == "" && out == name) {
delete(r.outputTargets, out)
}
}
}
// Lookup 查询一个 inputch。
@ -175,6 +185,18 @@ func (r *ChannelRegistry) BindOutputTarget(output, agentID, inputCh string) erro
return nil
}
// UnbindOutputTarget 取消一个输出通道的目标登记。
//
// 与 BindOutputTarget 成对:输出通道可能是**随资源生灭**的(如远程设备
// 一台设备一个通道),设备掉线后必须解绑,否则 ResolveOutputTarget 会一直
// 把消息路由到一个已不存在的 agent/inputch 上——与 inputch 登记泄漏同一类问题。
// 未登记时是 no-op幂等方便清理路径无脑调用。
func (r *ChannelRegistry) UnbindOutputTarget(output string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.outputTargets, output)
}
// ResolveOutputTarget 解析一个输出通道的目标;未登记时 ok=false
// (意味着由传输层通道自行处理,如 qq/webui 这类 device 通道)。
func (r *ChannelRegistry) ResolveOutputTarget(output string) (OutputTarget, bool) {

View File

@ -132,3 +132,46 @@ func TestChannelRegistry_DefLookupCompat(t *testing.T) {
t.Fatal("未注册的 inputch 不应有策略")
}
}
// TestOutputTarget_UnbindAndUnregisterCleanup 守住 outputTargets 的清理:
// - UnbindOutputTarget 幂等解绑;
// - Unregister(inputch) 顺手清掉指向它的目标登记(显式绑定 + 同名回退两种)。
//
// 背景outputTargets 只增不减是漏项(审查发现)——通道随资源生灭时会留下
// 指向已不存在 agent/inputch 的路由。
func TestOutputTarget_UnbindAndUnregisterCleanup(t *testing.T) {
r := NewChannelRegistry()
if err := r.Register(InputChannel{Name: "sub/in"}); err != nil {
t.Fatal(err)
}
if err := r.Register(InputChannel{Name: "same"}); err != nil {
t.Fatal(err)
}
if err := r.BindOutputTarget("to-child", "child-1", "sub/in"); err != nil {
t.Fatal(err)
}
// InputCh=="" 表示"与输出通道同名"的回退。
if err := r.BindOutputTarget("same", "child-2", ""); err != nil {
t.Fatal(err)
}
if _, ok := r.ResolveOutputTarget("to-child"); !ok {
t.Fatal("绑定后应能解析")
}
r.UnbindOutputTarget("to-child")
if _, ok := r.ResolveOutputTarget("to-child"); ok {
t.Fatal("解绑后不应再解析")
}
r.UnbindOutputTarget("to-child") // 幂等:重复解绑不 panic
// 注销 inputch显式绑定到它的、以及同名回退的都要一起清。
r.Unregister("sub/in")
if _, ok := r.ResolveOutputTarget("to-child"); ok {
t.Fatal("显式绑定到已注销 inputch 的目标应被清理")
}
r.Unregister("same")
if _, ok := r.ResolveOutputTarget("same"); ok {
t.Fatal("同名回退目标应随该 inputch 注销被清理")
}
}