@@ -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
7479type 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