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>
This commit is contained in:
2026-06-22 17:19:24 +02:00
parent 0bfb481c2a
commit 2a5b88fbf9
6 changed files with 141 additions and 4 deletions
+16 -2
View File
@@ -84,12 +84,26 @@ func New(endpoint, token, fingerprintHex string) (*Client, error) {
baseURL: "https://" + endpoint,
token: token,
hc: &http.Client{
Timeout: 15 * time.Second,
Transport: &http.Transport{TLSClientConfig: tlsCfg},
Timeout: 15 * time.Second,
// Bound + expire the idle-conn pool. With the controller reusing one Client (so the pool
// stays ~2), IdleConnTimeout also lets idle conns to a RESTARTED agent drain instead of
// lingering as stale ESTABLISHED entries, and caps any future per-call misuse. (The earlier
// bare Transport had IdleConnTimeout:0 = idle keep-alives never expire → the leak.)
Transport: &http.Transport{
TLSClientConfig: tlsCfg,
MaxIdleConns: 4,
MaxIdleConnsPerHost: 2,
IdleConnTimeout: 90 * time.Second,
},
},
}, nil
}
// Close releases the client's idle keep-alive connections. Optional hygiene for any caller that builds
// a short-lived client; the controller reuses one long-lived client, so it relies on the bounded,
// expiring idle pool (above) rather than calling this.
func (c *Client) Close() { c.hc.CloseIdleConnections() }
// Storage calls GET /storage and returns this guest's mounts (connectivity + placement view).
func (c *Client) Storage(ctx context.Context) (StorageResponse, error) {
var out StorageResponse
@@ -0,0 +1,30 @@
package agentapi
import (
"net/http"
"strings"
"testing"
)
// T2: New must build a Transport with a BOUNDED, EXPIRING idle-conn pool. The earlier bare
// &http.Transport{TLSClientConfig:…} had IdleConnTimeout==0 (idle keep-alives never expire) — the
// leak. White-box (package agentapi) so we can read the unexported hc.
//
// Companion red-proof: the bare Transport gives IdleConnTimeout==0 → this test fails (demonstrated,
// then reverted, during implementation).
func TestNew_TransportIdlePoolBounded(t *testing.T) {
c, err := New("127.0.0.1:8443", "tok", strings.Repeat("a", 64))
if err != nil {
t.Fatalf("New: %v", err)
}
tr, ok := c.hc.Transport.(*http.Transport)
if !ok {
t.Fatalf("unexpected transport type %T", c.hc.Transport)
}
if tr.IdleConnTimeout <= 0 {
t.Fatalf("IdleConnTimeout must be > 0 (idle keep-alives must expire); got %v", tr.IdleConnTimeout)
}
if tr.MaxIdleConnsPerHost <= 0 {
t.Fatalf("MaxIdleConnsPerHost must be > 0 (bounded pool); got %d", tr.MaxIdleConnsPerHost)
}
}