Skip to content

Commit 7fed60b

Browse files
authored
Merge pull request #27 from codeGROOVE-dev/star-org
Remove out-of-band ping/pong that caused race conditions
2 parents cc6acce + ab3d713 commit 7fed60b

14 files changed

Lines changed: 269 additions & 272 deletions

cmd/server/main.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ import (
1717
"golang.org/x/crypto/acme/autocert"
1818
"golang.org/x/net/websocket"
1919

20-
"github.com/codeGROOVE-dev/sprinkler/pkg/hub"
2120
"github.com/codeGROOVE-dev/sprinkler/pkg/security"
21+
"github.com/codeGROOVE-dev/sprinkler/pkg/srv"
2222
"github.com/codeGROOVE-dev/sprinkler/pkg/webhook"
2323
)
2424

@@ -86,7 +86,7 @@ func main() {
8686

8787
// CORS support removed - WebSocket clients should handle auth via Authorization header
8888

89-
h := hub.NewHub()
89+
h := srv.NewHub()
9090
go h.Run(ctx)
9191

9292
// Create security components
@@ -146,7 +146,7 @@ func main() {
146146
log.Println("Registered webhook handler at /webhook")
147147

148148
// WebSocket handler - exact match
149-
wsHandler := hub.NewWebSocketHandler(h, connLimiter, allowedEventTypes)
149+
wsHandler := srv.NewWebSocketHandler(h, connLimiter, allowedEventTypes)
150150
mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
151151
startTime := time.Now()
152152
ip := security.ClientIP(r)
@@ -272,7 +272,7 @@ func main() {
272272
signal.Notify(sigint, os.Interrupt)
273273
<-sigint
274274

275-
log.Println("shutting down server...")
275+
log.Println("shutting down srv...")
276276

277277
// Cancel the context to stop all components
278278
cancel()

pkg/client/client.go

Lines changed: 101 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,12 @@ const (
3434
separatorLine = "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
3535
msgTypeField = "type"
3636

37-
// Server ping timing constants.
38-
expectedPingInterval = 54 * time.Second
39-
pingDelayThreshold = 10 * time.Second
40-
4137
// Read timeout for WebSocket operations.
4238
// Set to 90s to be longer than server ping interval (54s) to avoid false timeouts.
4339
readTimeout = 90 * time.Second
40+
41+
// Write channel buffer size.
42+
writeChannelBuffer = 10
4443
)
4544

4645
// Event represents a webhook event received from the server.
@@ -71,17 +70,22 @@ type Config struct {
7170
}
7271

7372
// Client represents a WebSocket client with automatic reconnection.
73+
// Connection management:
74+
// - Read loop (readEvents) receives all messages from server
75+
// - Write channel (writeCh) serializes all writes through one goroutine
76+
// - Server sends pings; client responds with pongs
77+
// - Client also sends pings; server responds with pongs
78+
// - Both sides use read timeouts to detect dead connections
7479
type Client struct {
75-
logger *slog.Logger
76-
ws *websocket.Conn
77-
stopCh chan struct{}
78-
stoppedCh chan struct{}
79-
config Config
80-
lastPingTime time.Time // Time of last ping received
81-
noActivitySince time.Time // Track when we last received anything
82-
mu sync.RWMutex
83-
eventCount int
84-
retries int
80+
logger *slog.Logger
81+
ws *websocket.Conn
82+
stopCh chan struct{}
83+
stoppedCh chan struct{}
84+
config Config
85+
writeCh chan any // Channel for serializing all writes
86+
mu sync.RWMutex
87+
eventCount int
88+
retries int
8589
}
8690

8791
// New creates a new robust WebSocket client.
@@ -102,7 +106,7 @@ func New(config Config) (*Client, error) {
102106
config.PingInterval = 30 * time.Second
103107
}
104108
if config.MaxBackoff == 0 {
105-
config.MaxBackoff = 30 * time.Second
109+
config.MaxBackoff = 2 * time.Minute // Use exponential backoff up to 2 minutes
106110
}
107111

108112
// Set default logger if not provided
@@ -394,21 +398,70 @@ func (c *Client) connect(ctx context.Context) error {
394398
c.retries = 0
395399
c.mu.Unlock()
396400

397-
// Start ping sender
401+
// Create write channel for serializing all writes
402+
c.writeCh = make(chan any, writeChannelBuffer)
403+
404+
// Start write pump - this is the ONLY goroutine that writes to the websocket
405+
writeCtx, cancelWrite := context.WithCancel(ctx)
406+
defer cancelWrite()
407+
writeDone := make(chan error, 1)
408+
go func() {
409+
writeDone <- c.writePump(writeCtx, ws)
410+
}()
411+
412+
// Start ping sender (sends to write channel, not directly to websocket)
398413
pingCtx, cancelPing := context.WithCancel(ctx)
399414
defer cancelPing()
400-
go c.sendPings(pingCtx, ws)
415+
go c.sendPings(pingCtx)
416+
417+
// Read events - when this returns, cancel everything
418+
readErr := c.readEvents(ctx, ws)
401419

402-
// Don't process subscription_confirmed as an event
403-
// We've already handled it above
420+
// Stop write pump and ping sender
421+
cancelWrite()
422+
cancelPing()
404423

405-
// Read remaining events
406-
return c.readEvents(ctx, ws)
424+
// Wait for write pump to finish
425+
writeErr := <-writeDone
426+
427+
// Return the first error that occurred
428+
if readErr != nil {
429+
return readErr
430+
}
431+
return writeErr
432+
}
433+
434+
// writePump is the ONLY goroutine that writes to the websocket.
435+
// All writes must go through the writeCh channel to prevent concurrent writes.
436+
func (c *Client) writePump(ctx context.Context, ws *websocket.Conn) error {
437+
const writeTimeout = 10 * time.Second
438+
439+
for {
440+
select {
441+
case <-ctx.Done():
442+
return ctx.Err()
443+
444+
case msg, ok := <-c.writeCh:
445+
if !ok {
446+
return errors.New("write channel closed")
447+
}
448+
449+
// Set write deadline
450+
if err := ws.SetWriteDeadline(time.Now().Add(writeTimeout)); err != nil {
451+
return fmt.Errorf("set write deadline: %w", err)
452+
}
453+
454+
// Send message
455+
if err := websocket.JSON.Send(ws, msg); err != nil {
456+
return fmt.Errorf("write: %w", err)
457+
}
458+
}
459+
}
407460
}
408461

409462
// sendPings sends periodic ping messages to keep the connection alive.
410-
// The server will respond with pings too, and we respond with pongs in readEvents.
411-
func (c *Client) sendPings(ctx context.Context, ws *websocket.Conn) {
463+
// Pings are sent to the write channel, not directly to the websocket.
464+
func (c *Client) sendPings(ctx context.Context) {
412465
ticker := time.NewTicker(c.config.PingInterval)
413466
defer ticker.Stop()
414467

@@ -417,14 +470,18 @@ func (c *Client) sendPings(ctx context.Context, ws *websocket.Conn) {
417470
case <-ctx.Done():
418471
return
419472
case <-ticker.C:
420-
ping := map[string]string{msgTypeField: "ping", "timestamp": time.Now().Format(time.RFC3339)}
421-
c.logger.Debug("[KEEP-ALIVE] Sending periodic ping to server")
422-
if err := websocket.JSON.Send(ws, ping); err != nil {
423-
c.logger.Error("Failed to send keep-alive ping", "error", err)
424-
c.logger.Warn("Connection may be broken!")
473+
ping := map[string]string{msgTypeField: "ping"}
474+
c.logger.Debug("[PING] Sending periodic ping to server")
475+
476+
// Send to write channel (non-blocking)
477+
select {
478+
case c.writeCh <- ping:
479+
c.logger.Debug("[PING] ✓ Ping queued")
480+
case <-ctx.Done():
425481
return
482+
default:
483+
c.logger.Warn("[PING] Write channel full, skipping ping")
426484
}
427-
c.logger.Debug("[KEEP-ALIVE] ✓ Ping sent successfully")
428485
}
429486
}
430487
}
@@ -476,64 +533,35 @@ func (c *Client) readEvents(ctx context.Context, ws *websocket.Conn) error {
476533
// Check message type
477534
responseType, _ := response[msgTypeField].(string) //nolint:errcheck // type assertion, not error
478535

479-
// Handle ping messages
536+
// Handle ping messages from server
480537
if responseType == "ping" {
481-
now := time.Now()
482-
483-
// Check if pings are arriving on schedule
484-
c.mu.Lock()
485-
lastPing := c.lastPingTime
486-
c.lastPingTime = now
487-
c.noActivitySince = now
488-
c.mu.Unlock()
489-
490-
if !lastPing.IsZero() {
491-
timeSinceLastPing := now.Sub(lastPing)
492-
if timeSinceLastPing > expectedPingInterval+pingDelayThreshold {
493-
c.logger.Warn("[PING-PONG] Delayed ping from server",
494-
"expected_interval", expectedPingInterval,
495-
"actual_interval", timeSinceLastPing,
496-
"delay", timeSinceLastPing-expectedPingInterval)
497-
}
498-
}
538+
c.logger.Debug("[PONG] Received PING from server")
499539

500-
c.logger.Debug("[PING-PONG] Received PING from server")
540+
// Build pong response
501541
pong := map[string]any{msgTypeField: "pong"}
502-
// Echo back any sequence number if present
503542
if seq, ok := response["seq"]; ok {
504543
pong["seq"] = seq
505544
}
506-
// Set write deadline to ensure timely pong response
507-
if err := ws.SetWriteDeadline(time.Now().Add(5 * time.Second)); err != nil {
508-
c.logger.Error("[PING-PONG] Failed to set write deadline", "error", err)
509-
return fmt.Errorf("error setting write deadline: %w", err)
510-
}
511-
if err := websocket.JSON.Send(ws, pong); err != nil {
512-
c.logger.Error("[PING-PONG] Failed to send PONG response", "error", err)
513-
return fmt.Errorf("error sending pong response: %w", err)
514-
}
515-
// Clear write deadline
516-
if err := ws.SetWriteDeadline(time.Time{}); err != nil {
517-
c.logger.Warn("[PING-PONG] Failed to clear write deadline", "error", err)
545+
546+
// Send pong via write channel (non-blocking with timeout)
547+
select {
548+
case c.writeCh <- pong:
549+
c.logger.Debug("[PONG] Sent PONG response to server", "seq", pong["seq"])
550+
case <-ctx.Done():
551+
return ctx.Err()
552+
case <-time.After(1 * time.Second):
553+
c.logger.Error("[PONG] Failed to queue pong - write channel blocked")
554+
return errors.New("pong send blocked")
518555
}
519-
c.logger.Debug("[PING-PONG] Sent PONG response to server", "seq", pong["seq"])
520556
continue
521557
}
522558

523-
// Handle pong acknowledgments
559+
// Handle pong acknowledgments from server
524560
if responseType == "pong" {
525-
c.mu.Lock()
526-
c.noActivitySince = time.Now()
527-
c.mu.Unlock()
528-
c.logger.Debug("[PING-PONG] Received PONG acknowledgment from server")
561+
c.logger.Debug("[PONG] Received PONG acknowledgment from server")
529562
continue
530563
}
531564

532-
// Update activity timestamp for any event
533-
c.mu.Lock()
534-
c.noActivitySince = time.Now()
535-
c.mu.Unlock()
536-
537565
// Process the event inline
538566
event := Event{
539567
Type: responseType,

0 commit comments

Comments
 (0)