diff --git a/backend/internal/handler/ws/handler.go b/backend/internal/handler/ws/handler.go index 5b548f4e..1e58252f 100644 --- a/backend/internal/handler/ws/handler.go +++ b/backend/internal/handler/ws/handler.go @@ -231,7 +231,7 @@ func (h *Handler) writePump(client *Client) { // keep the connection alive (staleThreshold = 6s by default). pingMsg, err := json.Marshal(PingFrame{ Type: ServerPing, - Message: time.Now().UTC().Format(time.RFC3339), + Message: time.Now().Unix(), }) if err != nil { logger.L().Errorf("ws: marshal ActionCable ping for user=%d: %v", client.UserID, err) @@ -370,7 +370,7 @@ func (h *Handler) handleUnsubscribe(client *Client, cmd CommandFrame) { func (h *Handler) handlePing(client *Client) { pingData, _ := json.Marshal(PingFrame{ Type: ServerPing, - Message: time.Now().UTC().Format(time.RFC3339), + Message: time.Now().Unix(), }) client.Send <- pingData } diff --git a/backend/internal/handler/ws/protocol.go b/backend/internal/handler/ws/protocol.go index 0f15c9f1..0b72fa06 100644 --- a/backend/internal/handler/ws/protocol.go +++ b/backend/internal/handler/ws/protocol.go @@ -12,7 +12,7 @@ package ws // {"type":"event","event":"message.created","payload":{...},"identifier":"{\"channel\":\"AccountChannel\",\"account_id\":1}"} // {"type":"confirm_subscribe","identifier":"..."} // {"type":"confirm_unsubscribe","identifier":"..."} -// {"type":"ping","message":"2026-05-23T10:00:00Z"} +// {"type":"ping","message":1779520800} // {"type":"reject_subscribe","identifier":"...","reason":"..."} // CommandType — client→server action types @@ -97,7 +97,7 @@ type RejectFrame struct { // PingFrame is a heartbeat pong response. type PingFrame struct { Type ServerMessageType `json:"type"` - Message string `json:"message"` // timestamp string + Message int64 `json:"message"` // Unix seconds, matching ActionCable's Time.now.to_i } // WelcomeFrame is sent upon successful WebSocket connection. @@ -160,6 +160,6 @@ const ( // --- Ping/pong Configuration --- const ( - // PingInterval is how often the server sends ping frames to detect dead connections. - PingInterval = 5 // seconds — must be < ActionCable staleThreshold (6s) to avoid reconnect loops + // PingInterval matches ActionCable::Server::Connections::BEAT_INTERVAL. + PingInterval = 3 // seconds — two missed beats reach ActionCable's 6s stale threshold ) diff --git a/backend/internal/handler/ws/ws_test.go b/backend/internal/handler/ws/ws_test.go index 1395b0d6..f653872f 100644 --- a/backend/internal/handler/ws/ws_test.go +++ b/backend/internal/handler/ws/ws_test.go @@ -141,18 +141,43 @@ func TestRejectFrame_JSONRoundTrip(t *testing.T) { assert.Equal(t, rf.Reason, decoded.Reason) } -func TestPingFrame_JSONRoundTrip(t *testing.T) { - pf := PingFrame{ - Type: ServerPing, - Message: "2026-01-01T00:00:00Z", - } - data, err := json.Marshal(pf) - require.NoError(t, err) +func TestWritePump_ActionCableHeartbeatCompatibility(t *testing.T) { + serverConn, clientConn := newTestWSConn(t) + defer clientConn.Close() - var decoded PingFrame - err = json.Unmarshal(data, &decoded) - require.NoError(t, err) - assert.Equal(t, pf.Message, decoded.Message) + client := NewClient(1, 10, serverConn, NewHubSimple()) + done := make(chan struct{}) + go func() { + (&Handler{}).writePump(client) + close(done) + }() + defer func() { + close(client.Send) + <-done + }() + + require.NoError(t, clientConn.SetReadDeadline(time.Now().Add(8*time.Second))) + receivedAt := make([]time.Time, 2) + for i := range receivedAt { + messageType, data, err := clientConn.ReadMessage() + receivedAt[i] = time.Now() + require.NoError(t, err) + require.Equal(t, websocket.TextMessage, messageType) + + var frame struct { + Type ServerMessageType `json:"type"` + Message json.RawMessage `json:"message"` + } + require.NoError(t, json.Unmarshal(data, &frame)) + require.Equal(t, ServerPing, frame.Type) + require.Regexp(t, `^[0-9]+$`, string(frame.Message), "message must be an unquoted Unix timestamp") + + var unixSeconds int64 + require.NoError(t, json.Unmarshal(frame.Message, &unixSeconds)) + assert.InDelta(t, receivedAt[i].Unix(), unixSeconds, 1) + } + + assert.WithinDuration(t, receivedAt[0].Add(3*time.Second), receivedAt[1], time.Second) } func TestWelcomeFrame_JSONRoundTrip(t *testing.T) { @@ -182,10 +207,6 @@ func TestDisconnectFrame_JSONRoundTrip(t *testing.T) { assert.Equal(t, df.Reconnect, decoded.Reconnect) } -func TestPingInterval_Constant(t *testing.T) { - assert.Equal(t, 5, PingInterval) -} - // --- Hub Tests --- func TestNewHubSimple(t *testing.T) {