From 11d9038927707beef60737bb5426de0c0ee13a8f Mon Sep 17 00:00:00 2001 From: HomeAgent Agent Date: Sun, 13 Sep 2026 21:59:10 +0800 Subject: [PATCH] =?UTF-8?q?fix(io):=20ChannelRegistry=20=E8=A1=A5=20Unbind?= =?UTF-8?q?OutputTarget=EF=BC=8CUnregister=20=E6=B8=85=E7=90=86=20outputTa?= =?UTF-8?q?rgets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit outputTargets 只增不减:Unregister 一个 inputch 后,指向它的输出目标登记仍 留在表里,ResolveOutputTarget 会继续把消息路由到已不存在的 agent/inputch。 与刚修的驻留 inputch 残留同属「注册未注销」类。现补 UnbindOutputTarget (幂等),并让 Unregister 顺手清掉显式绑定与同名回退两种目标登记。 --- internal/agent/io/inputch.go | 22 ++++++++++++++++ internal/agent/io/inputch_test.go | 43 +++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/internal/agent/io/inputch.go b/internal/agent/io/inputch.go index f36e1a3..1483eb9 100644 --- a/internal/agent/io/inputch.go +++ b/internal/agent/io/inputch.go @@ -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) { diff --git a/internal/agent/io/inputch_test.go b/internal/agent/io/inputch_test.go index dce1de5..f835886 100644 --- a/internal/agent/io/inputch_test.go +++ b/internal/agent/io/inputch_test.go @@ -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 注销被清理") + } +}