Files
felhom-controller/controller/internal/web/agent_client_reuse_test.go
T
admin 2a5b88fbf9 v0.74.0: fix controller->agent connection leak (reuse one agentapi client)
agentClient() built a new agentapi.Client (new bare http.Transport, IdleConnTimeout:0)
per call and discarded it without closing idle conns -> one leaked idle ESTABLISHED
socket per call to the agent :8443, exhausting the ephemeral port range after ~5 days
(EADDRNOTAVAIL). Memoize one shared client via sync.Once; harden Transport
(MaxIdleConns/PerHost + IdleConnTimeout 90s). Agent/firewall untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:19:24 +02:00

48 lines
1.7 KiB
Go

package web
import (
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
)
// T1: agentClient() must return the SAME *agentapi.Client across calls — one Client ⇒ one pooled
// http.Transport ⇒ the per-call Transport leak (idle ESTABLISHED sockets to the agent's :8443 that
// exhausted the ephemeral port range after ~5 days) is structurally impossible. cfg.LocalAPI is
// valid-but-non-network (agentapi.New builds the struct without dialing), so this is hermetic.
//
// Companion red-proof: reverting agentClient() to `return agentapi.New(...)` makes the two pointers
// differ → this test fails (demonstrated, then reverted, during implementation).
func TestAgentClient_ReusesSameInstance(t *testing.T) {
s := &Server{cfg: &config.Config{LocalAPI: config.LocalAPIConfig{
Endpoint: "127.0.0.1:8443",
Token: "tok",
Fingerprint: strings.Repeat("a", 64), // syntactically valid 64-hex; no network touched
}}}
c1, err := s.agentClient()
if err != nil {
t.Fatalf("agentClient() #1: %v", err)
}
c2, err := s.agentClient()
if err != nil {
t.Fatalf("agentClient() #2: %v", err)
}
if c1 == nil || c2 == nil {
t.Fatal("agentClient() returned a nil client")
}
if c1 != c2 {
t.Fatalf("agentClient() must return the SAME instance (leak fix); got %p then %p", c1, c2)
}
}
// T1b: the "not configured" semantics are preserved — an empty endpoint still errors, OUTSIDE the
// memoizing Once (so an unprovisioned guest never caches a client).
func TestAgentClient_UnconfiguredErrors(t *testing.T) {
s := &Server{cfg: &config.Config{}} // LocalAPI.Endpoint == ""
if _, err := s.agentClient(); err == nil {
t.Fatal("expected an 'agent not configured' error when Endpoint is empty")
}
}