fix(esp_http_server): let the WS control handler own the protocol reply

This commit is contained in:
Ashish Sharma
2026-08-27 18:37:15 +08:00
parent 731ef25e37
commit 8a01f77647
9 changed files with 116 additions and 69 deletions

View File

@@ -493,18 +493,29 @@ typedef struct httpd_uri {
* Optional dedicated handler for WebSocket control frames (PING, PONG, CLOSE).
*
* Only takes effect when handle_ws_control_frames is true. When set, control
* frames are delivered to this handler instead of the data handler. The server
* has already received the frame (passed via the read-only frame argument), and
* after this handler returns the server performs the protocol reply itself
* (PONG for PING, CLOSE for CLOSE). The frame and its payload are owned by the
* server and are only valid for the duration of the call; the handler must not
* free or retain them. If left NULL, control frames continue to be delivered to
* the data handler (unchanged behavior).
* frames are delivered to this handler instead of the data handler, so the data
* handler only ever sees data frames (CONTINUE, TEXT, BINARY).
*
* The server has already received the frame body for you - no allocation or
* httpd_ws_recv_frame() call is needed - but it does *not* reply. Consistent with
* handle_ws_control_frames being true, this handler owns the protocol reply:
* answer a PING with a PONG echoing the payload and a CLOSE with a CLOSE
* (RFC 6455 section 5.5); a PONG needs no reply. The frame may be reused for the
* reply by overwriting frame->type and passing it to httpd_ws_send_frame().
*
* The frame and its payload are owned by the server and are only valid for the
* duration of the call: the handler must not free or retain them, and must not
* grow frame->len beyond the received length, as the payload buffer is sized for
* a control frame only. Returning an error closes the connection.
*
* If left NULL, control frames continue to be delivered to the data handler,
* which remains responsible for receiving and replying to them (unchanged
* behavior).
*
* Placed at the end of the struct to keep positional initialization of existing
* fields backward compatible.
*/
esp_err_t (*ws_control_handler)(httpd_req_t *req, const httpd_ws_frame_t *frame);
esp_err_t (*ws_control_handler)(httpd_req_t *req, httpd_ws_frame_t *frame);
#endif /* CONFIG_HTTPD_WS_SUPPORT */
} httpd_uri_t;

View File

@@ -90,7 +90,7 @@ struct sock_db {
bool ws_close_sent; /*!< Set to true once this endpoint has sent its own WS Close frame */
esp_err_t (*ws_handler)(httpd_req_t *r); /*!< WebSocket handler, leave to null if it's not WebSocket */
bool ws_control_frames; /*!< WebSocket flag indicating that control frames should be passed to user handlers */
esp_err_t (*ws_control_handler)(httpd_req_t *r, const httpd_ws_frame_t *frame); /*!< Dedicated WebSocket control-frame handler, NULL if not used */
esp_err_t (*ws_control_handler)(httpd_req_t *r, httpd_ws_frame_t *frame); /*!< Dedicated WebSocket control-frame handler, NULL if not used */
void *ws_user_ctx; /*!< Pointer to user context data which will be available to handler for websocket*/
#endif
};
@@ -582,8 +582,10 @@ esp_err_t httpd_ws_recv_control_frame(httpd_req_t *req, httpd_ws_frame_t *frame,
/**
* @brief Send the protocol reply for a received WebSocket control frame.
*
* @note PING is answered with a PONG echoing the payload; CLOSE is answered with
* an empty CLOSE; all other control frames (e.g. PONG) require no reply.
* @note PING is answered with a PONG echoing the payload. CLOSE is answered with
* a CLOSE that echoes the received status code and reason when
* CONFIG_HTTPD_WS_STRICTER_RFC6455 is set, and with an empty CLOSE
* otherwise. All other control frames (for example PONG) require no reply.
*
* @param[in] req WebSocket request
* @param[in] frame Control frame previously received (modified in place)
@@ -597,15 +599,18 @@ esp_err_t httpd_ws_reply_to_control_frame(httpd_req_t *req, httpd_ws_frame_t *fr
* @brief Handle an incoming WebSocket control frame via the dedicated control handler.
*
* @note Used only when a ws_control_handler is registered (handle_ws_control_frames
* must be true). The server receives the frame body, passes a read-only view
* to the control handler, then performs the protocol reply itself. If the
* control handler returns an error, the reply is still sent and the error is
* propagated so the caller closes the socket.
* must be true). The server receives the frame body and hands it to the
* control handler, which owns the protocol reply. The server does not reply
* on its own, so a handler that answers a PING cannot produce a duplicate
* PONG on the wire. A handler error is propagated so the caller closes the
* socket.
*
* @param[in] req WebSocket request
* @return
* - ESP_OK : Control frame handled and replied
* - others : Control handler error, or frame could not be received/replied
* - ESP_OK : Control frame received and handled
* - ESP_ERR_INVALID_ARG : Argument is invalid (null request aux or session)
* - ESP_ERR_INVALID_STATE : No control handler registered for this session
* - others : Control handler error, or frame could not be received
*/
esp_err_t httpd_ws_handle_control_frame(httpd_req_t *req);
#endif /* CONFIG_HTTPD_WS_SUPPORT */

View File

@@ -853,7 +853,7 @@ esp_err_t httpd_req_new(struct httpd_data *hd, struct sock_db *sd)
/* Dispatch the frame:
* - Control frames (CLOSE/PING/PONG) go to the dedicated control handler
* when one is registered; the server then sends the protocol reply.
* when one is registered; that handler owns the protocol reply.
* - Otherwise dispatch to the data handler for non-control frames, PONG
* frames, or when the handler opted in to receiving control frames.
* PONG must be dispatched so that user heartbeat code can track it and

View File

@@ -878,6 +878,9 @@ esp_err_t httpd_ws_handle_control_frame(httpd_req_t *req)
return ESP_ERR_INVALID_ARG;
}
struct sock_db *sd = aux->sd;
if (sd->ws_control_handler == NULL) {
return ESP_ERR_INVALID_STATE;
}
/* The server receives the control-frame body itself. Oversized or malformed
* frames are rejected by the max_len cap (zero-trust on client input). */
@@ -888,17 +891,9 @@ esp_err_t httpd_ws_handle_control_frame(httpd_req_t *req)
return ret;
}
/* Notify the user's control handler with a read-only view of the frame. */
esp_err_t handler_ret = ESP_OK;
if (sd->ws_control_handler != NULL) {
handler_ret = sd->ws_control_handler(req, &frame);
}
/* The server always performs the protocol reply (PONG for PING, CLOSE for
* CLOSE). If the handler failed, still reply, then propagate the error so the
* caller closes the socket. */
esp_err_t reply_ret = httpd_ws_reply_to_control_frame(req, &frame);
return (handler_ret != ESP_OK) ? handler_ret : reply_ret;
/* Hand the frame over. The server deliberately does not reply: with
* handle_ws_control_frames true the application owns the protocol reply */
return sd->ws_control_handler(req, &frame);
}
esp_err_t httpd_ws_get_frame_type(httpd_req_t *req)

View File

@@ -623,13 +623,29 @@ static esp_err_t ws_data_handler_spy(httpd_req_t *req)
return ESP_OK;
}
static esp_err_t ws_control_handler_spy(httpd_req_t *req, const httpd_ws_frame_t *frame)
/* Mirrors what an application does: the control handler owns the protocol reply.
* On a handler failure nothing is sent, because no other code replies for it. */
static esp_err_t ws_control_handler_spy(httpd_req_t *req, httpd_ws_frame_t *frame)
{
(void)req;
s_ws_control_handler_calls++;
s_ws_control_seen_type = frame->type;
s_ws_control_seen_len = frame->len;
return s_ws_control_ret;
if (s_ws_control_ret != ESP_OK) {
return s_ws_control_ret;
}
switch (frame->type) {
case HTTPD_WS_TYPE_PING:
frame->type = HTTPD_WS_TYPE_PONG;
return httpd_ws_send_frame(req, frame);
case HTTPD_WS_TYPE_CLOSE:
frame->len = 0;
frame->payload = NULL;
return httpd_ws_send_frame(req, frame);
default:
/* A PONG needs no reply */
return ESP_OK;
}
}
/* Wire a fake session/server and reset all fixtures around a single frame. */
@@ -1335,7 +1351,7 @@ TEST_CASE("WS send uses 16-bit length encoding for exactly 65535-byte payload",
TEST_ASSERT_EQUAL_UINT8_ARRAY(expected_header, ws_send_capture_ctx.data, sizeof(expected_header));
}
TEST_CASE("WS control handler receives PING and server replies PONG", "[HTTP SERVER][websocket]")
TEST_CASE("WS control handler owns the PONG reply for PING", "[HTTP SERVER][websocket]")
{
/* Masked (zero-key) PING carrying a 2-byte payload "Hi". */
static const uint8_t ping_frame[] = { 0x89, 0x82, 0x00, 0x00, 0x00, 0x00, 'H', 'i' };
@@ -1353,13 +1369,13 @@ TEST_CASE("WS control handler receives PING and server replies PONG", "[HTTP SER
TEST_ASSERT_EQUAL(2, s_ws_control_seen_len);
TEST_ASSERT_EQUAL(0, s_ws_data_handler_calls); /* control frame must not reach data handler */
TEST_ASSERT_GREATER_THAN(0, s_ws_sent_len);
TEST_ASSERT_EQUAL_HEX8(0x8A, s_ws_sent[0]); /* FIN | PONG */
TEST_ASSERT_EQUAL_HEX8(0x8A, s_ws_sent[0]); /* FIN | PONG, sent by the handler */
TEST_ASSERT_FALSE(session.ws_close);
free(hd.hd_req_aux.resp_hdrs);
}
TEST_CASE("WS control handler receives CLOSE and server replies CLOSE", "[HTTP SERVER][websocket]")
TEST_CASE("WS control handler owns the CLOSE reply for CLOSE", "[HTTP SERVER][websocket]")
{
static const uint8_t close_frame[] = { 0x88, 0x80, 0x00, 0x00, 0x00, 0x00 };
struct httpd_data hd = {0};
@@ -1375,7 +1391,7 @@ TEST_CASE("WS control handler receives CLOSE and server replies CLOSE", "[HTTP S
TEST_ASSERT_EQUAL(HTTPD_WS_TYPE_CLOSE, s_ws_control_seen_type);
TEST_ASSERT_EQUAL(0, s_ws_data_handler_calls);
TEST_ASSERT_GREATER_THAN(0, s_ws_sent_len);
TEST_ASSERT_EQUAL_HEX8(0x88, s_ws_sent[0]); /* FIN | CLOSE */
TEST_ASSERT_EQUAL_HEX8(0x88, s_ws_sent[0]); /* FIN | CLOSE, sent by the handler */
TEST_ASSERT_TRUE(session.ws_close); /* server marked the session for close */
free(hd.hd_req_aux.resp_hdrs);
@@ -1424,11 +1440,12 @@ TEST_CASE("WS without control handler auto-replies PING (backward compatible)",
free(hd.hd_req_aux.resp_hdrs);
}
TEST_CASE("WS control handler error still replies then closes socket", "[HTTP SERVER][websocket]")
TEST_CASE("WS control handler error sends no reply and closes socket", "[HTTP SERVER][websocket]")
{
/* A PING is used so ws_close stays false and cleanup does not touch the fake
* control socket. The handler fails, but the server must still send the PONG,
* and httpd_req_new() must propagate the error so the caller closes the socket. */
* control socket. The handler owns the reply, so a handler failure puts nothing
* on the wire, and httpd_req_new() propagates the error so the caller closes
* the socket. */
static const uint8_t ping_frame[] = { 0x89, 0x80, 0x00, 0x00, 0x00, 0x00 };
struct httpd_data hd = {0};
struct sock_db session = {0};
@@ -1441,8 +1458,7 @@ TEST_CASE("WS control handler error still replies then closes socket", "[HTTP SE
TEST_ASSERT_EQUAL(ESP_FAIL, ret); /* error propagated to caller */
TEST_ASSERT_EQUAL(1, s_ws_control_handler_calls);
TEST_ASSERT_GREATER_THAN(0, s_ws_sent_len);
TEST_ASSERT_EQUAL_HEX8(0x8A, s_ws_sent[0]); /* reply sent despite handler error */
TEST_ASSERT_EQUAL(0, s_ws_sent_len); /* no reply: the handler owns it */
free(hd.hd_req_aux.resp_hdrs);
}

View File

@@ -156,19 +156,31 @@ On transmit, the server does not fragment a message automatically. To send fragm
WebSocket Control Frame Handler
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
By default, the server replies to WebSocket control frames automatically — a PING frame is answered with a PONG and a CLOSE frame is answered with a CLOSE — without involving the application. Setting ``handle_ws_control_frames`` to true in :cpp:type:`httpd_uri_t` disables this behavior and delivers control frames to the data handler, which then becomes responsible for receiving the frame and sending the protocol replies itself.
By default, the server replies to WebSocket control frames automatically. A PING frame gets a PONG, and a CLOSE frame gets a CLOSE. The application is not involved.
The ``ws_control_handler`` callback provides a middle ground: when it is set (and ``handle_ws_control_frames`` is true), control frames (PING, PONG, CLOSE) are delivered to this dedicated handler instead of the data handler, while the server still receives the frame body and performs the protocol replies itself after the handler returns. This is useful for observing heartbeats (tracking PONG responses) or logging the close reason without re-implementing the reply logic.
Set ``handle_ws_control_frames`` to true in :cpp:type:`httpd_uri_t` to turn off the automatic reply. Control frames then go to the data handler. That handler must receive each frame and send the protocol reply itself.
The frame passed to the handler is read-only and owned by the server; it is only valid for the duration of the call, so the handler must not free or retain it. If the handler returns an error, the server still sends the protocol reply and then closes the connection.
The ``ws_control_handler`` callback keeps control frames out of the data handler. Set it together with ``handle_ws_control_frames``. Control frames (PING, PONG, CLOSE) then go to this dedicated handler, and the data handler only sees data frames.
The server receives the frame body for the handler, so no allocation and no :cpp:func:`httpd_ws_recv_frame` call is needed. The server does not reply. The handler owns the protocol reply. Answer a PING with a PONG that echoes the payload. Answer a CLOSE with a CLOSE. A PONG needs no reply. To send the reply, overwrite ``frame->type`` and pass the frame to :cpp:func:`httpd_ws_send_frame`.
The server owns the frame and its payload. Both are valid only for the duration of the call. The handler must not free them and must not retain them. The handler must not increase ``frame->len`` above the received length, because the payload buffer holds a control frame only. If the handler returns an error, the server sends no reply and closes the connection.
.. code-block:: c
static esp_err_t ws_control_frame_handler(httpd_req_t *req, const httpd_ws_frame_t *frame)
static esp_err_t ws_control_frame_handler(httpd_req_t *req, httpd_ws_frame_t *frame)
{
// Observe PING/PONG/CLOSE here (e.g. heartbeat tracking, logging).
// The server sends the protocol reply itself after this returns.
return ESP_OK;
switch (frame->type) {
case HTTPD_WS_TYPE_PING:
frame->type = HTTPD_WS_TYPE_PONG; // reuse the frame for the reply
return httpd_ws_send_frame(req, frame);
case HTTPD_WS_TYPE_CLOSE:
frame->len = 0; // an empty CLOSE is a valid reply
frame->payload = NULL;
return httpd_ws_send_frame(req, frame);
default:
return ESP_OK; // a PONG needs no reply
}
}
// Registering a WebSocket URI handler with a dedicated control-frame handler

View File

@@ -96,10 +96,10 @@ This example registers a dedicated control-frame handler on the `/ws` endpoint (
```c
.handle_ws_control_frames = true,
.ws_control_handler = ws_control_frame_handler, // observes PING/PONG/CLOSE
.ws_control_handler = ws_control_frame_handler, // handles PING/PONG/CLOSE
```
The handler only observes the frames (this example logs them); the server still sends the protocol replies (PONG for PING, CLOSE for CLOSE) itself. Send the text message `Ping` to the server to watch the full heartbeat round trip: the server sends a PING and the client's PONG response is logged by the control-frame handler.
Control frames then go to `ws_control_frame_handler()` instead of the data handler, so `echo_handler()` only deals with TEXT/BINARY frames. The server receives the control frame body for the handler but does not reply, so the handler sends the protocol reply itself (a PONG echoing the payload for a PING, an empty CLOSE for a CLOSE). Send the text message `Ping` to the server to watch the full heartbeat round trip: the server sends a PING and the client's PONG response is logged by the control-frame handler.
### Hardware Required

View File

@@ -25,9 +25,9 @@ menu "Example Configuration"
default y
help
Enable this option to register a dedicated handler for WebSocket
control frames (PING, PONG, CLOSE) on the /ws endpoint. The handler
only observes the frames (e.g. for heartbeat tracking or logging);
the server still sends the protocol replies (PONG for PING, CLOSE
for CLOSE) itself.
control frames (PING, PONG, CLOSE) on the /ws endpoint, keeping them
out of the data handler. The server receives the frame body for the
handler, which then sends the protocol replies itself (PONG for
PING, CLOSE for CLOSE).
endmenu

View File

@@ -141,31 +141,39 @@ static esp_err_t ws_post_handshake_cb(httpd_req_t *req)
#ifdef CONFIG_EXAMPLE_ENABLE_WS_CONTROL_FRAME_HANDLER
/*
* Dedicated control-frame handler: observes PING/PONG/CLOSE frames without
* receiving them in the data handler. The frame is read-only and owned by the
* server, which sends the protocol reply (PONG for PING, CLOSE for CLOSE)
* itself after this handler returns.
* Dedicated control-frame handler: keeps PING/PONG/CLOSE out of the data handler
* below, which therefore only deals with TEXT/BINARY frames.
*
* The server has already received the frame body (no allocation needed here) but
* does not reply, so this handler owns the protocol reply: a PONG echoing the
* payload for a PING, an empty CLOSE for a CLOSE, nothing for a PONG. The frame
* is owned by the server and is reused in place for the reply; it must not be
* freed, retained, or grown past the received length.
*
* Type "Ping" in the client to see the full heartbeat round trip: the server
* sends a PING and the client's PONG response lands here.
*/
static esp_err_t ws_control_frame_handler(httpd_req_t *req, const httpd_ws_frame_t *frame)
static esp_err_t ws_control_frame_handler(httpd_req_t *req, httpd_ws_frame_t *frame)
{
switch (frame->type) {
case HTTPD_WS_TYPE_PING:
ESP_LOGI(TAG, "Control frame: PING (len %d), server replies PONG", frame->len);
break;
ESP_LOGI(TAG, "Control frame: PING (len %d), replying PONG", frame->len);
frame->type = HTTPD_WS_TYPE_PONG;
return httpd_ws_send_frame(req, frame);
case HTTPD_WS_TYPE_PONG:
/* Reply to our own PING; nothing to send back (RFC 6455, section 5.5.3) */
ESP_LOGI(TAG, "Control frame: PONG, heartbeat alive");
break;
return ESP_OK;
case HTTPD_WS_TYPE_CLOSE:
ESP_LOGI(TAG, "Control frame: CLOSE (len %d), server replies CLOSE", frame->len);
break;
ESP_LOGI(TAG, "Control frame: CLOSE (len %d), replying CLOSE", frame->len);
frame->len = 0;
frame->payload = NULL;
return httpd_ws_send_frame(req, frame);
default:
ESP_LOGI(TAG, "Control frame: type %d", frame->type);
break;
/* Reserved control opcode: fail the connection (RFC 6455, section 5.2) */
ESP_LOGW(TAG, "Control frame: reserved opcode %d, closing connection", frame->type);
return ESP_FAIL;
}
return ESP_OK;
}
#endif /* CONFIG_EXAMPLE_ENABLE_WS_CONTROL_FRAME_HANDLER */
@@ -312,8 +320,8 @@ static const httpd_uri_t ws = {
.user_ctx = NULL,
.is_websocket = true,
#ifdef CONFIG_EXAMPLE_ENABLE_WS_CONTROL_FRAME_HANDLER
/* Route control frames to the dedicated handler; the server still
* sends the protocol replies itself. */
/* Route control frames to the dedicated handler, which owns the
* protocol replies; echo_handler() then only sees data frames. */
.handle_ws_control_frames = true,
.ws_control_handler = ws_control_frame_handler,
#endif /* CONFIG_EXAMPLE_ENABLE_WS_CONTROL_FRAME_HANDLER */