Skip to content

Commit 26e3d09

Browse files
committed
Improve client reliability
1 parent c407377 commit 26e3d09

3 files changed

Lines changed: 97 additions & 19 deletions

File tree

pkg/client/client.go

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ const (
3232
// UI constants for logging.
3333
separatorLine = "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
3434
msgTypeField = "type"
35+
36+
// Server ping timing constants.
37+
expectedPingInterval = 54 * time.Second
38+
pingDelayThreshold = 10 * time.Second
3539
)
3640

3741
// Event represents a webhook event received from the server.
@@ -63,14 +67,16 @@ type Config struct {
6367

6468
// Client represents a WebSocket client with automatic reconnection.
6569
type Client struct {
66-
logger *slog.Logger
67-
ws *websocket.Conn
68-
stopCh chan struct{}
69-
stoppedCh chan struct{}
70-
config Config
71-
eventCount int
72-
retries int
73-
mu sync.RWMutex
70+
logger *slog.Logger
71+
ws *websocket.Conn
72+
stopCh chan struct{}
73+
stoppedCh chan struct{}
74+
config Config
75+
lastPingTime time.Time // Time of last ping received
76+
noActivitySince time.Time // Track when we last received anything
77+
mu sync.RWMutex
78+
eventCount int
79+
retries int
7480
}
7581

7682
// New creates a new robust WebSocket client.
@@ -454,26 +460,62 @@ func (c *Client) readEvents(ctx context.Context, ws *websocket.Conn) error {
454460

455461
// Handle ping messages
456462
if responseType == "ping" {
463+
now := time.Now()
464+
465+
// Check if pings are arriving on schedule
466+
c.mu.Lock()
467+
lastPing := c.lastPingTime
468+
c.lastPingTime = now
469+
c.noActivitySince = now
470+
c.mu.Unlock()
471+
472+
if !lastPing.IsZero() {
473+
timeSinceLastPing := now.Sub(lastPing)
474+
if timeSinceLastPing > expectedPingInterval+pingDelayThreshold {
475+
c.logger.Warn("[PING-PONG] Delayed ping from server",
476+
"expected_interval", expectedPingInterval,
477+
"actual_interval", timeSinceLastPing,
478+
"delay", timeSinceLastPing-expectedPingInterval)
479+
}
480+
}
481+
457482
c.logger.Debug("[PING-PONG] Received PING from server")
458483
pong := map[string]any{msgTypeField: "pong"}
459484
// Echo back any sequence number if present
460485
if seq, ok := response["seq"]; ok {
461486
pong["seq"] = seq
462487
}
488+
// Set write deadline to ensure timely pong response
489+
if err := ws.SetWriteDeadline(time.Now().Add(5 * time.Second)); err != nil {
490+
c.logger.Error("[PING-PONG] Failed to set write deadline", "error", err)
491+
return fmt.Errorf("error setting write deadline: %w", err)
492+
}
463493
if err := websocket.JSON.Send(ws, pong); err != nil {
464494
c.logger.Error("[PING-PONG] Failed to send PONG response", "error", err)
465495
return fmt.Errorf("error sending pong response: %w", err)
466496
}
497+
// Clear write deadline
498+
if err := ws.SetWriteDeadline(time.Time{}); err != nil {
499+
c.logger.Warn("[PING-PONG] Failed to clear write deadline", "error", err)
500+
}
467501
c.logger.Debug("[PING-PONG] Sent PONG response to server", "seq", pong["seq"])
468502
continue
469503
}
470504

471505
// Handle pong acknowledgments
472506
if responseType == "pong" {
507+
c.mu.Lock()
508+
c.noActivitySince = time.Now()
509+
c.mu.Unlock()
473510
c.logger.Debug("[PING-PONG] Received PONG acknowledgment from server")
474511
continue
475512
}
476513

514+
// Update activity timestamp for any event
515+
c.mu.Lock()
516+
c.noActivitySince = time.Now()
517+
c.mu.Unlock()
518+
477519
// Process the event inline
478520
event := Event{
479521
Type: responseType,

pkg/hub/client.go

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,19 @@ import (
1313

1414
// Client represents a connected WebSocket client with their subscription preferences.
1515
type Client struct {
16-
conn *websocket.Conn
17-
send chan Event
18-
hub *Hub
19-
done chan struct{}
20-
userOrgs map[string]bool
21-
ID string
22-
subscription Subscription
23-
closeOnce sync.Once
24-
pingSeq int64 // Track ping sequence numbers
16+
conn *websocket.Conn
17+
send chan Event
18+
hub *Hub
19+
done chan struct{}
20+
userOrgs map[string]bool
21+
ID string
22+
subscription Subscription
23+
lastPongTime time.Time // Time of last pong
24+
mu sync.RWMutex
25+
closeOnce sync.Once
26+
pingSeq int64 // Track ping sequence numbers
27+
lastPongSeq int64 // Last pong sequence received
28+
missedPongCount int // Count of consecutive missed pongs
2529
}
2630

2731
// NewClient creates a new client.
@@ -101,16 +105,35 @@ func (c *Client) Run(ctx context.Context, pingInterval, writeTimeout time.Durati
101105
log.Printf("✓ Event sent to client %s", c.ID)
102106

103107
case <-ticker.C:
108+
// Check if we're missing pongs before sending next ping
109+
c.mu.RLock()
110+
currentSeq := c.pingSeq
111+
lastPongSeq := c.lastPongSeq
112+
c.mu.RUnlock()
113+
114+
if currentSeq > 0 && lastPongSeq < currentSeq {
115+
c.mu.Lock()
116+
c.missedPongCount++
117+
missedCount := c.missedPongCount
118+
c.mu.Unlock()
119+
log.Printf("WARNING: client %s has not responded to ping #%d (missed %d consecutive pongs)",
120+
c.ID, currentSeq, missedCount)
121+
}
122+
104123
// Send ping to keep connection alive
124+
c.mu.Lock()
105125
c.pingSeq++
126+
currentSeq = c.pingSeq
127+
c.mu.Unlock()
128+
106129
if err := c.conn.SetWriteDeadline(time.Now().Add(writeTimeout)); err != nil {
107130
log.Printf("error setting ping deadline for client %s: %v", c.ID, err)
108131
return
109132
}
110133
ping := map[string]any{
111134
"type": "ping",
112135
"timestamp": time.Now().Format(time.RFC3339),
113-
"seq": c.pingSeq,
136+
"seq": currentSeq,
114137
}
115138
if err := websocket.JSON.Send(c.conn, ping); err != nil {
116139
log.Printf("error sending ping to client %s: %v", c.ID, err)
@@ -125,6 +148,16 @@ func (c *Client) Run(ctx context.Context, pingInterval, writeTimeout time.Durati
125148
}
126149
}
127150

151+
// RecordPong records receipt of a pong from the client.
152+
func (c *Client) RecordPong(seq int64) {
153+
c.mu.Lock()
154+
defer c.mu.Unlock()
155+
156+
c.lastPongSeq = seq
157+
c.lastPongTime = time.Now()
158+
c.missedPongCount = 0
159+
}
160+
128161
// Close gracefully closes the client.
129162
func (c *Client) Close() {
130163
c.closeOnce.Do(func() {

pkg/hub/websocket.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import (
2222
// Constants for WebSocket timeouts and limits.
2323
const (
2424
pingInterval = 54 * time.Second
25-
readTimeout = 60 * time.Second // Must be > pingInterval to avoid false timeouts
25+
readTimeout = 90 * time.Second // Must be > pingInterval + response time to avoid false timeouts
2626
writeTimeout = 10 * time.Second
2727
minTokenLength = 40 // Minimum GitHub token length
2828
maxTokenLength = 255 // Maximum GitHub token length
@@ -717,6 +717,9 @@ func (h *WebSocketHandler) Handle(ws *websocket.Conn) {
717717
switch msgType {
718718
case "pong":
719719
// Pong received - connection is alive
720+
if seq, ok := msgMap["seq"].(float64); ok {
721+
client.RecordPong(int64(seq))
722+
}
720723
continue
721724
case "ping":
722725
// Client sent us a ping, send pong back

0 commit comments

Comments
 (0)