HH-533: align ActionCable heartbeat protocol (#121)
* fix(ws): align ActionCable heartbeat protocol (HH-533) * test(ws): cover ActionCable heartbeat cadence (HH-533) --------- Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user