| 28 | } |
| 29 | |
| 30 | func heartbeatCloseWith(ctx context.Context, logger slog.Logger, exit func(), conn *websocket.Conn, clk quartz.Clock, interval time.Duration) { |
| 31 | ticker := clk.NewTicker(interval, "HeartbeatClose") |
| 32 | defer ticker.Stop() |
| 33 | |
| 34 | for { |
| 35 | select { |
| 36 | case <-ctx.Done(): |
| 37 | return |
| 38 | case <-ticker.C: |
| 39 | } |
| 40 | err := pingWithTimeout(ctx, conn, interval) |
| 41 | if err != nil { |
| 42 | // These errors are all expected during normal connection |
| 43 | // teardown and should not be logged at error level: |
| 44 | // - context.DeadlineExceeded: client disconnected |
| 45 | // without sending a close frame. |
| 46 | // - context.Canceled: request context was canceled. |
| 47 | // - net.ErrClosed: connection was already closed by |
| 48 | // another goroutine (e.g. handler returned). |
| 49 | // - websocket.CloseError: a close frame was |
| 50 | // received or sent. |
| 51 | if errors.Is(err, context.DeadlineExceeded) || |
| 52 | errors.Is(err, context.Canceled) || |
| 53 | errors.Is(err, net.ErrClosed) || |
| 54 | websocket.CloseStatus(err) != -1 { |
| 55 | logger.Debug(ctx, "heartbeat ping stopped", slog.Error(err)) |
| 56 | } else { |
| 57 | logger.Error(ctx, "failed to heartbeat ping", slog.Error(err)) |
| 58 | } |
| 59 | _ = conn.Close(websocket.StatusGoingAway, "Ping failed") |
| 60 | exit() |
| 61 | return |
| 62 | } |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | func pingWithTimeout(ctx context.Context, conn *websocket.Conn, timeout time.Duration) error { |
| 67 | ctx, cancel := context.WithTimeout(ctx, timeout) |