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:
Rogee
2026-08-23 17:46:44 +08:00
committed by GitHub
co-authored by rogee
parent 2c5e6e595f
commit f719a18ba8
3 changed files with 42 additions and 21 deletions
+2 -2
View File
@@ -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
}
+4 -4
View File
@@ -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
)
+36 -15
View File
@@ -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) {