Skip to content

Commit 977f386

Browse files
TeoSlayerteovl
andauthored
daemon: cooldown the dial-driven auto-handshake spawn (#216)
DialConnection unconditionally spawned `go HandshakeSendRequest(peer, "")` on every call to a peer in the trustedagents allowlist that wasn't yet trusted. The per-peer in-flight dedup collapsed CONCURRENT callers to one underlying SendRequest, but did NOT collapse SEQUENTIAL ones: once sendMessage returned fast (ErrEphemeralExhausted, or the registry-relay fallback), the slot was released within microseconds and the next dial re-fired the goroutine — re-emitting "direct handshake failed" and re-allocating an ephemeral port. In steady state against an unreachable trusted-agent (blockchain-ticker, node 19418, 2026-06-06) this produced ~4000 log lines per second. daemon.log grew to 1 GB in under four hours and the port pool saturated; pilotctl info/peers/handshake all wedged because the IPC server couldn't get a port through the bitmap. Add shouldAutoHandshake: a per-peer time-keyed gate (autoHandshakeCooldown = 30 s) checked BEFORE the goroutine spawn. Concurrent racers atomic-CAS for the slot; sequential callers within the cooldown short-circuit without ever reaching HandshakeSendRequest. Explicit `pilotctl handshake` IPC calls bypass the gate — user intent is never throttled. Regression-pinned by six TestShouldAutoHandshake* cases, including a 50k-call tight loop asserting exactly one spawn. Co-authored-by: Teodor Calin <teodor@vulturelabs.io>
1 parent f61b35e commit 977f386

3 files changed

Lines changed: 272 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,41 @@ project uses [Semantic Versioning](https://semver.org/).
77
Detailed per-release notes are on the
88
[GitHub Releases page](https://github.com/TeoSlayer/pilotprotocol/releases).
99

10+
## [Unreleased]
11+
12+
### Fixed
13+
- **Auto-handshake dial-storm against unreachable trusted agents no longer
14+
saturates the ephemeral-port pool.** When `DialConnection` targeted a
15+
peer in the `trustedagents` allowlist that wasn't yet trusted, it
16+
unconditionally spawned `go HandshakeSendRequest(peer, "")` on every
17+
call. The existing per-peer in-flight dedup collapsed CONCURRENT
18+
callers to a single underlying `SendRequest`, but did NOT collapse
19+
SEQUENTIAL ones: when `sendMessage` returned fast (e.g. hit
20+
`ErrEphemeralExhausted` in microseconds, or the registry-relay
21+
fallback completed), the in-flight slot was released immediately and
22+
the next dial re-fired the goroutine — re-entering `SendRequest`,
23+
re-emitting `"direct handshake failed, relaying via registry"`, and
24+
re-allocating an ephemeral port.
25+
26+
In steady state against a reachable-but-key-exchange-stuck peer
27+
(`blockchain-ticker`, node 19418, on 2026-06-06) this produced
28+
~4000 log lines per second — 1 GB of `daemon.log` written in under
29+
four hours — while every outbound dial to that peer and every
30+
concurrent tenant of the port pool failed with `"ephemeral ports
31+
exhausted"`. `pilotctl info`, `pilotctl peers`, and new specialist
32+
handshakes all wedged because the IPC server couldn't get a port
33+
through the saturated bitmap.
34+
35+
Fix: `Daemon.shouldAutoHandshake` adds a per-peer time-keyed gate
36+
(`autoHandshakeCooldown` = 30 s) that runs BEFORE the goroutine
37+
spawn in `DialConnection`. Concurrent racers atomic-CAS for the
38+
slot; sequential callers within the cooldown window short-circuit
39+
silently and never reach `HandshakeSendRequest`. Explicit
40+
`pilotctl handshake <peer>` IPC calls bypass the gate so user
41+
intent is never throttled. Regression-pinned by
42+
`TestShouldAutoHandshake*` (six cases including a 50k-call tight
43+
loop that asserts exactly one spawn).
44+
1045
## [1.10.5] - 2026-05-20
1146

1247
### Fixed

pkg/daemon/daemon.go

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,28 @@ type Daemon struct {
270270
// dial regardless of caller count.
271271
handshakeInFlight sync.Map
272272

273+
// autoHandshakeLastAttempt records the last time DialConnection's
274+
// inline trusted-agents auto-handshake (daemon.go ~3139) fired for
275+
// a peer (nodeID → time.Time). Used by shouldAutoHandshake to
276+
// throttle the dial-driven fanout: handshakeInFlight only collapses
277+
// CONCURRENT callers, not SEQUENTIAL ones — when SendRequest
278+
// returns fast (e.g. sendMessage hits ErrEphemeralExhausted in
279+
// microseconds, or the registry-relay fallback completes), the
280+
// in-flight slot is released immediately and the next DialConnection
281+
// caller re-fires the goroutine, re-enters SendRequest, and emits
282+
// another "direct handshake failed" log line. Without a cooldown,
283+
// this produced a 4k-log-line-per-second storm against
284+
// blockchain-ticker (node 19418) on 2026-06-06 — daemon log grew
285+
// to 1 GB in under four hours while every outbound dial to that
286+
// peer (and any concurrent tenant of the port pool) failed with
287+
// "ephemeral ports exhausted".
288+
//
289+
// Distinct from handshakeInFlight (concurrent-burst dedup): this is
290+
// a separate time-keyed gate that runs only on the DialConnection
291+
// auto-spawn path, so explicit user-initiated `pilotctl handshake`
292+
// IPC calls are NOT throttled.
293+
autoHandshakeLastAttempt sync.Map
294+
273295
// network.* bus subscriber for daemon-internal reactions (managed
274296
// engines + member-tag cache). Wired by subscribeNetworkInternalToBus
275297
// during Start; torn down before tunnels in doStop. (T4.3.)
@@ -1869,6 +1891,48 @@ func (d *Daemon) HandshakeRevokeTrust(nodeID uint32) error {
18691891
// pilotctl users get a clean signal rather than a generic error.
18701892
var ErrHandshakeInFlight = errors.New("handshake already in flight to this peer")
18711893

1894+
// autoHandshakeCooldown is the per-peer minimum gap between
1895+
// DialConnection-driven auto-handshake spawns. Sized to match the dial
1896+
// budget (~25-78s of direct-retry + relay) so a peer that's unreachable
1897+
// can't drive log/dial fanout faster than the dial itself takes. The
1898+
// next caller still gets an auto-handshake — just not 4000 of them per
1899+
// second from the same hot dial path.
1900+
const autoHandshakeCooldown = 30 * time.Second
1901+
1902+
// shouldAutoHandshake reports whether DialConnection's inline
1903+
// trusted-agents auto-handshake should fire for peer. Returns true the
1904+
// first time it sees the peer and after autoHandshakeCooldown has
1905+
// elapsed since the last spawn. Records the spawn time atomically so
1906+
// concurrent racers see one winner; subsequent callers within the
1907+
// cooldown window observe a recent timestamp and return false without
1908+
// spawning a goroutine.
1909+
//
1910+
// Gates only the dial-driven auto-spawn path: explicit
1911+
// `pilotctl handshake <peer>` IPC calls hit HandshakeSendRequest
1912+
// directly and are unaffected (so a user explicitly retrying is never
1913+
// silently dropped).
1914+
func (d *Daemon) shouldAutoHandshake(peer uint32) bool {
1915+
now := time.Now()
1916+
prev, loaded := d.autoHandshakeLastAttempt.LoadOrStore(peer, now)
1917+
if !loaded {
1918+
return true
1919+
}
1920+
prevTime, ok := prev.(time.Time)
1921+
if !ok {
1922+
// Defensive — value type mismatch should be impossible, but if
1923+
// it happens, overwrite and proceed rather than wedge.
1924+
d.autoHandshakeLastAttempt.Store(peer, now)
1925+
return true
1926+
}
1927+
if now.Sub(prevTime) < autoHandshakeCooldown {
1928+
return false
1929+
}
1930+
// Cooldown expired — race to claim the slot. CompareAndSwap means at
1931+
// most one of N concurrent racers wins; the rest skip this round and
1932+
// will try again on the next dial after cooldown.
1933+
return d.autoHandshakeLastAttempt.CompareAndSwap(peer, prev, now)
1934+
}
1935+
18721936
// HandshakeSendRequest issues an outbound trust handshake to peerNodeID
18731937
// via the registered handshake plugin, deduped per-peer.
18741938
//
@@ -3194,10 +3258,26 @@ func (d *Daemon) dialConnectionLocked(ctx context.Context, dstAddr protocol.Addr
31943258
// peer, all from the recursive go-fired chain. The wrapper's
31953259
// LoadOrStore caps it at one in-flight call per peer.
31963260
//
3261+
// The in-flight dedup catches CONCURRENT racers. It does NOT
3262+
// catch the SEQUENTIAL retry storm that emerges when the
3263+
// underlying SendRequest returns fast (e.g. sendMessage hits
3264+
// ErrEphemeralExhausted): the slot is released within
3265+
// microseconds and the next DialConnection re-fires the
3266+
// goroutine immediately, producing 4k+ "direct handshake
3267+
// failed" log lines per second (1 GB of daemon log in <4 h
3268+
// observed against blockchain-ticker, node 19418, on
3269+
// 2026-06-06). shouldAutoHandshake adds a per-peer time
3270+
// cooldown so the auto-spawn fires at most once per
3271+
// autoHandshakeCooldown, regardless of caller volume.
3272+
// Explicit `pilotctl handshake` IPC calls still bypass this
3273+
// gate.
3274+
//
31973275
// Returns are intentionally discarded — the goroutine is best
31983276
// effort, just like before. ErrHandshakeInFlight short-circuit
31993277
// is the dedup hit, which is the success case.
3200-
go func() { _ = d.HandshakeSendRequest(dstAddr.Node, "") }()
3278+
if d.shouldAutoHandshake(dstAddr.Node) {
3279+
go func() { _ = d.HandshakeSendRequest(dstAddr.Node, "") }()
3280+
}
32013281
}
32023282
}
32033283

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+
package daemon
4+
5+
// Pins the per-peer auto-handshake cooldown that gates the inline
6+
// DialConnection spawn of HandshakeSendRequest for peers in the
7+
// trustedagents allowlist.
8+
//
9+
// Motivation: handshakeInFlight collapses CONCURRENT auto-handshake
10+
// callers to a single underlying SendRequest. It does not collapse
11+
// SEQUENTIAL callers — when SendRequest returns fast (e.g. sendMessage
12+
// hits ErrEphemeralExhausted in microseconds, or the registry-relay
13+
// fallback completes), the in-flight slot is released immediately and
14+
// the next DialConnection caller re-fires the goroutine, re-enters
15+
// SendRequest, and emits another "direct handshake failed" log line.
16+
// Without a cooldown this produces a 4k-log-line-per-second storm
17+
// against an unreachable trusted-agent (observed: 1 GB of daemon log
18+
// in under 4 h against blockchain-ticker / node 19418, 2026-06-06).
19+
//
20+
// shouldAutoHandshake records the spawn time per peer and rejects
21+
// subsequent calls within autoHandshakeCooldown. These tests verify
22+
// that contract end-to-end without spinning up the full plugin stack.
23+
24+
import (
25+
"sync"
26+
"sync/atomic"
27+
"testing"
28+
"time"
29+
)
30+
31+
// TestShouldAutoHandshakeFirstCallFires verifies the first call for an
32+
// unseen peer always returns true (no cooldown to wait through).
33+
func TestShouldAutoHandshakeFirstCallFires(t *testing.T) {
34+
t.Parallel()
35+
d := &Daemon{}
36+
if !d.shouldAutoHandshake(7777) {
37+
t.Fatalf("first call for an unseen peer must return true")
38+
}
39+
}
40+
41+
// TestShouldAutoHandshakeRepeatWithinCooldownSuppressed verifies that
42+
// subsequent calls within autoHandshakeCooldown return false — the
43+
// regression that motivated the gate (4k-per-sec dial-driven re-entry).
44+
func TestShouldAutoHandshakeRepeatWithinCooldownSuppressed(t *testing.T) {
45+
t.Parallel()
46+
d := &Daemon{}
47+
const peer = uint32(8888)
48+
49+
if !d.shouldAutoHandshake(peer) {
50+
t.Fatalf("first call must fire")
51+
}
52+
for i := 0; i < 1000; i++ {
53+
if d.shouldAutoHandshake(peer) {
54+
t.Fatalf("repeat call %d within cooldown returned true; cooldown gate failed", i)
55+
}
56+
}
57+
}
58+
59+
// TestShouldAutoHandshakeDifferentPeersIndependent verifies the cooldown
60+
// is keyed strictly by peer ID — a hot dial path against peer A must
61+
// not silence auto-handshakes to unrelated peer B.
62+
func TestShouldAutoHandshakeDifferentPeersIndependent(t *testing.T) {
63+
t.Parallel()
64+
d := &Daemon{}
65+
66+
if !d.shouldAutoHandshake(1001) {
67+
t.Fatalf("peer 1001 first call must fire")
68+
}
69+
if d.shouldAutoHandshake(1001) {
70+
t.Fatalf("peer 1001 second call within cooldown must skip")
71+
}
72+
if !d.shouldAutoHandshake(1002) {
73+
t.Fatalf("peer 1002 first call must fire (independent of 1001)")
74+
}
75+
if !d.shouldAutoHandshake(1003) {
76+
t.Fatalf("peer 1003 first call must fire (independent of 1001/1002)")
77+
}
78+
}
79+
80+
// TestShouldAutoHandshakeConcurrentRacersOneWinner verifies the
81+
// CompareAndSwap path: N goroutines racing to fire for the same peer
82+
// must yield exactly one true result, the rest false.
83+
func TestShouldAutoHandshakeConcurrentRacersOneWinner(t *testing.T) {
84+
t.Parallel()
85+
d := &Daemon{}
86+
const peer = uint32(2024)
87+
const N = 64
88+
89+
var trueCount atomic.Int32
90+
var wg sync.WaitGroup
91+
start := make(chan struct{})
92+
93+
wg.Add(N)
94+
for i := 0; i < N; i++ {
95+
go func() {
96+
defer wg.Done()
97+
<-start
98+
if d.shouldAutoHandshake(peer) {
99+
trueCount.Add(1)
100+
}
101+
}()
102+
}
103+
close(start)
104+
wg.Wait()
105+
106+
if got := trueCount.Load(); got != 1 {
107+
t.Fatalf("%d goroutines saw shouldAutoHandshake=true, want exactly 1 (CompareAndSwap should pick one winner)", got)
108+
}
109+
}
110+
111+
// TestShouldAutoHandshakeFiresAgainAfterCooldown verifies the gate
112+
// releases when the cooldown elapses — a peer that's persistently
113+
// unreachable still gets a fresh attempt periodically (so trust can
114+
// converge once the peer comes back).
115+
//
116+
// Rather than sleep for autoHandshakeCooldown (30s), we directly seed
117+
// the map with an old timestamp to simulate cooldown expiry. This
118+
// pins the behaviour without making the test sleep through the real
119+
// cooldown.
120+
func TestShouldAutoHandshakeFiresAgainAfterCooldown(t *testing.T) {
121+
t.Parallel()
122+
d := &Daemon{}
123+
const peer = uint32(3030)
124+
125+
// Seed an attempt that's well past the cooldown window.
126+
d.autoHandshakeLastAttempt.Store(peer, time.Now().Add(-2*autoHandshakeCooldown))
127+
128+
if !d.shouldAutoHandshake(peer) {
129+
t.Fatalf("call after cooldown elapsed must fire")
130+
}
131+
if d.shouldAutoHandshake(peer) {
132+
t.Fatalf("immediately-following call within new cooldown must be suppressed")
133+
}
134+
}
135+
136+
// TestShouldAutoHandshakeStormSuppression simulates the regression
137+
// directly: a dial-driven hot path slams shouldAutoHandshake N times
138+
// at a single peer in a tight loop, and we assert the gate caps the
139+
// fanout at exactly 1 (so the goroutine spawn rate is bounded, not
140+
// the 4000/sec storm observed in the wild).
141+
func TestShouldAutoHandshakeStormSuppression(t *testing.T) {
142+
t.Parallel()
143+
d := &Daemon{}
144+
const peer = uint32(19418) // the real-world repro peer (blockchain-ticker)
145+
const N = 50_000
146+
147+
fires := 0
148+
for i := 0; i < N; i++ {
149+
if d.shouldAutoHandshake(peer) {
150+
fires++
151+
}
152+
}
153+
if fires != 1 {
154+
t.Fatalf("auto-handshake fired %d times in a %d-call tight loop, want exactly 1 — storm suppression failed", fires, N)
155+
}
156+
}

0 commit comments

Comments
 (0)