Skip to content

Commit 2f880f9

Browse files
committed
Cut cold dial 10x for trusted peers: prewarm tunnels + lower RTO
A cold pilotctl send-message paid three sequential round trips before delivering the user's payload: 1 RTT registry resolve_node (~150ms) 1 RTT ECDH key exchange via relay (~170ms) 1 RTT SYN / SYN-ACK via relay (~170ms) ~500ms-1.5s on a working setup, depending on how the SYN retransmit RTO interacted with direct-then-relay fallback. Three changes that compound: * pkg/daemon/daemon.go: prewarmTrustedTunnels() — kicks off ~2s after daemon registration. For each peer in handshakes.TrustedPeers(), it calls ensureTunnel() (warms the resolve cache) and fires sendKeyExchangeToNode() (warms the ECDH session). 50ms gap between peers so a 50-trust-relationship daemon doesn't pile up at the beacon. Result: when the user runs `pilotctl send-message <known peer>` for the first time after restart, the dial only pays the SYN/SYN-ACK round trip — both the resolve and the encrypted tunnel are already in place. * pkg/daemon/daemon.go: DialInitialRTO 1s → 250ms. The dial's exponential-backoff retry loop was waiting a full second for the first SYN before retransmitting. Modern relay RTT is <200ms, so the generous 1s timeout meant cold dials felt like a stall whenever the first SYN was lost or processed slowly. Three retries now total ~1.75s before flipping to relay (was ~7s). * pkg/daemon/ipc.go handleResolveHostname: - Async prewarm of the resolve cache after a hostname lookup succeeds. Helps subsequent calls to the same agent within the same daemon session even without trust-list prewarm. - Async persist of the in-memory hostname cache to ~/.pilot/hostname_cache.json so a daemon restart starts warm. * pkg/daemon/daemon.go: loadHostnameCache() called from Start() before the IPC server comes up. Reads ~/.pilot/hostname_cache.json and populates the in-memory cache with non-stale entries. First resolve_hostname after restart hits the in-memory cache instead of pounding the registry. Measured (cold trace): Before: dial 1300ms, recv 1500ms total After (trusted peer): dial 114ms, recv 318ms total (~10x) After (new peer): dial 711ms, recv 818ms total (~2x) Warm: dial 60ms, recv 250ms total (unchanged)
1 parent ce4e02d commit 2f880f9

2 files changed

Lines changed: 180 additions & 1 deletion

File tree

pkg/daemon/daemon.go

Lines changed: 158 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ const (
142142
const (
143143
DialDirectRetries = 3 // direct connection attempts before relay
144144
DialMaxRetries = 6 // total attempts (direct + relay)
145-
DialInitialRTO = 1 * time.Second // initial SYN retransmission timeout
145+
DialInitialRTO = 250 * time.Millisecond // initial SYN retransmission timeout. Lowered from 1s — modern relay RTT is <200ms; waiting a full second before assuming loss makes cold dials feel like a stall. Three direct retries with exponential backoff (250→500→1000) still cover up to 1.75s of jitter before flipping to relay; that's plenty for an unhealthy direct path while letting the common case (peer is reachable, single retry needed) feel snappy.
146146
DialMaxRTO = 8 * time.Second // max backoff for SYN retransmission
147147
DialCheckInterval = 10 * time.Millisecond // poll interval for state changes during dial
148148
RetxCheckInterval = 100 * time.Millisecond // retransmission check ticker
@@ -435,6 +435,11 @@ func (d *Daemon) reapPerSrcSYN() {
435435
}
436436

437437
func (d *Daemon) Start() error {
438+
// Warm the hostname cache from disk before the IPC server comes up,
439+
// so the first send-message after restart hits the cache instead of
440+
// pounding regConn for a fresh resolve_hostname.
441+
d.loadHostnameCache()
442+
438443
// 0. Resolve email: flag > owner (deprecated) > account file
439444
email := d.config.Email
440445
if email == "" && d.config.Owner != "" {
@@ -795,6 +800,13 @@ func (d *Daemon) Start() error {
795800
// hash-pick lands on a fresher beacon.
796801
go d.beaconRefreshLoop()
797802

803+
// 12b. Pre-warm encrypted tunnels for trusted peers so the first
804+
// `pilotctl send-message <peer>` after a daemon restart skips the
805+
// 2-RTT key-exchange + SYN/SYN-ACK and only pays the 1-RTT
806+
// SYN/SYN-ACK relay cost. Background; failures are harmless (the
807+
// dial path will redo whatever didn't stick).
808+
go d.prewarmTrustedTunnels()
809+
798810
// 13. Trusted-agents fetcher: hourly refresh of the auto-accept
799811
// list from GitHub. Embedded blob in the binary is the bootstrap.
800812
go func() {
@@ -3260,6 +3272,151 @@ func (d *Daemon) cacheResolve(nodeID uint32, resp map[string]interface{}) {
32603272
d.resolveCacheMu.Unlock()
32613273
}
32623274

3275+
// prewarmTrustedTunnels iterates trusted peers and fires a key_exchange
3276+
// at each one so the encrypted tunnel is established BEFORE the user's
3277+
// first send-message. Without this, the first dial pays:
3278+
//
3279+
// 1 RTT registry resolve_node (~150ms)
3280+
// 1 RTT ECDH key exchange (~170ms via relay)
3281+
// 1 RTT SYN / SYN-ACK (~170ms via relay)
3282+
//
3283+
// = ~500ms cold. With prewarm, only the SYN/SYN-ACK round trip remains
3284+
// (~170ms), because both the resolve cache and the encrypted tunnel
3285+
// are already warm.
3286+
//
3287+
// Spaced out (50ms between peers) so that 50 trusted peers don't all
3288+
// fire key_exchange at the same beacon at once. The beacon's per-peer
3289+
// reflection queue can absorb bursts but smoothing the registration
3290+
// reduces tail latency for everyone.
3291+
func (d *Daemon) prewarmTrustedTunnels() {
3292+
if d.handshakes == nil {
3293+
return
3294+
}
3295+
// Brief delay so registration + beacon-refresh have settled before
3296+
// we start hammering the registry with resolves.
3297+
select {
3298+
case <-time.After(2 * time.Second):
3299+
case <-d.stopCh:
3300+
return
3301+
}
3302+
peers := d.handshakes.TrustedPeers()
3303+
if len(peers) == 0 {
3304+
return
3305+
}
3306+
slog.Info("prewarming encrypted tunnels for trusted peers", "count", len(peers))
3307+
warmed := 0
3308+
for _, rec := range peers {
3309+
if d.stopping() {
3310+
return
3311+
}
3312+
// ensureTunnel fetches the peer's UDP endpoint (real_addr / relay)
3313+
// and registers it with the tunnel manager. Cheap if cached.
3314+
if err := d.ensureTunnel(rec.NodeID); err != nil {
3315+
slog.Debug("prewarm: ensureTunnel failed", "peer", rec.NodeID, "err", err)
3316+
continue
3317+
}
3318+
// Fire a key_exchange; the peer's reply derives the session key
3319+
// on both sides. After this, the peer is in tm.encKeys and the
3320+
// next call goes encrypted without re-keying.
3321+
d.tunnels.sendKeyExchangeToNode(rec.NodeID)
3322+
warmed++
3323+
select {
3324+
case <-time.After(50 * time.Millisecond):
3325+
case <-d.stopCh:
3326+
return
3327+
}
3328+
}
3329+
slog.Info("prewarm fired", "peers", warmed)
3330+
}
3331+
3332+
// hostnameCachePath returns the on-disk location of the persisted
3333+
// hostname cache, derived from the identity path. Empty if the daemon
3334+
// is in-memory only (no identity persistence configured).
3335+
func (d *Daemon) hostnameCachePath() string {
3336+
if d.config.IdentityPath == "" {
3337+
return ""
3338+
}
3339+
return filepath.Join(filepath.Dir(d.config.IdentityPath), "hostname_cache.json")
3340+
}
3341+
3342+
// hostnameCacheDisk is the on-disk format for the hostname cache.
3343+
type hostnameCacheDisk struct {
3344+
SavedAt time.Time `json:"saved_at"`
3345+
Hostnames map[string]hostnameCacheDiskEntry `json:"hostnames"`
3346+
}
3347+
3348+
type hostnameCacheDiskEntry struct {
3349+
Resp map[string]interface{} `json:"resp"`
3350+
CachedAt time.Time `json:"cached_at"`
3351+
}
3352+
3353+
// persistHostnameCache writes the in-memory hostname cache to disk so
3354+
// a daemon restart starts warm. Best-effort — errors are logged at
3355+
// debug level; a missing/corrupt file just means a slower first call.
3356+
// Held lock is read-only and brief; the JSON marshal happens after
3357+
// release.
3358+
func (d *Daemon) persistHostnameCache() {
3359+
path := d.hostnameCachePath()
3360+
if path == "" {
3361+
return
3362+
}
3363+
d.hostnameCacheMu.RLock()
3364+
snap := hostnameCacheDisk{
3365+
SavedAt: time.Now(),
3366+
Hostnames: make(map[string]hostnameCacheDiskEntry, len(d.hostnameCache)),
3367+
}
3368+
for k, v := range d.hostnameCache {
3369+
snap.Hostnames[k] = hostnameCacheDiskEntry{Resp: v.resp, CachedAt: v.cachedAt}
3370+
}
3371+
d.hostnameCacheMu.RUnlock()
3372+
data, err := json.Marshal(snap)
3373+
if err != nil {
3374+
slog.Debug("persist hostname cache marshal failed", "err", err)
3375+
return
3376+
}
3377+
if err := fsutil.AtomicWrite(path, data); err != nil {
3378+
slog.Debug("persist hostname cache write failed", "err", err)
3379+
}
3380+
}
3381+
3382+
// loadHostnameCache reads the persisted hostname cache from disk into
3383+
// memory at daemon startup. Stale entries (older than hostnameCacheTTL)
3384+
// are dropped — they'd be filtered by the read path anyway, but
3385+
// dropping them on load keeps memory tight.
3386+
func (d *Daemon) loadHostnameCache() {
3387+
path := d.hostnameCachePath()
3388+
if path == "" {
3389+
return
3390+
}
3391+
data, err := os.ReadFile(path)
3392+
if err != nil {
3393+
// First-run / missing file is normal — do not log.
3394+
if !os.IsNotExist(err) {
3395+
slog.Debug("hostname cache read failed", "err", err)
3396+
}
3397+
return
3398+
}
3399+
var snap hostnameCacheDisk
3400+
if err := json.Unmarshal(data, &snap); err != nil {
3401+
slog.Debug("hostname cache parse failed", "err", err)
3402+
return
3403+
}
3404+
now := time.Now()
3405+
loaded := 0
3406+
d.hostnameCacheMu.Lock()
3407+
for k, v := range snap.Hostnames {
3408+
if now.Sub(v.CachedAt) >= hostnameCacheTTL {
3409+
continue
3410+
}
3411+
d.hostnameCache[k] = &hostnameCacheEntry{resp: v.Resp, cachedAt: v.CachedAt}
3412+
loaded++
3413+
}
3414+
d.hostnameCacheMu.Unlock()
3415+
if loaded > 0 {
3416+
slog.Info("hostname cache loaded from disk", "entries", loaded)
3417+
}
3418+
}
3419+
32633420
// forgetPeerResolution invalidates the registry resolve cache and the
32643421
// fallback endpoint cache for a peer. Called when there is strong
32653422
// evidence the cached endpoint is wrong (dial timeout reached, ICMP

pkg/daemon/ipc.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -859,6 +859,28 @@ func (s *IPCServer) handleResolveHostname(conn *ipcConn, payload []byte) {
859859
s.daemon.hostnameCache[hostname] = &hostnameCacheEntry{resp: result, cachedAt: now}
860860
s.daemon.hostnameCacheMu.Unlock()
861861

862+
// Pre-warm the node-resolve cache so a follow-up DialAddr call from the
863+
// same pilotctl invocation skips the second registry round-trip in
864+
// ensureTunnel. Ran in a goroutine so the IPC reply isn't gated on it
865+
// — the caller has already gotten what they asked for; this is just
866+
// a latency-hiding bonus. Lock contention is rare since regConn.Send
867+
// is the same mutex; the policy_runner backoff keeps it free.
868+
if nodeIDVal, ok := result["node_id"].(float64); ok {
869+
nodeID := uint32(nodeIDVal)
870+
go func() {
871+
resolveResp, err := s.daemon.regConn.Resolve(nodeID, s.daemon.NodeID())
872+
if err != nil {
873+
slog.Debug("hostname resolve prewarm failed", "node_id", nodeID, "err", err)
874+
return
875+
}
876+
s.daemon.cacheResolve(nodeID, resolveResp)
877+
}()
878+
}
879+
880+
// Also persist the hostname cache to disk so a daemon restart starts
881+
// warm. Async — don't gate the IPC reply on disk I/O.
882+
go s.daemon.persistHostnameCache()
883+
862884
data, err := json.Marshal(result)
863885
if err != nil {
864886
s.sendError(conn, fmt.Sprintf("resolve_hostname marshal: %v", err))

0 commit comments

Comments
 (0)