Merge branch 'feat/ws-control-frame-handler' into 'master'

feat(esp_http_server): separate control frame handler for WS server

Closes IDFGH-17509

See merge request espressif/esp-idf!50616
This commit is contained in:
Mahavir Jain
2026-08-19 14:20:16 +05:30
12 changed files with 532 additions and 59 deletions
@@ -431,6 +431,13 @@ typedef struct httpd_req {
bool ignore_sess_ctx_changes;
} httpd_req_t;
#if CONFIG_HTTPD_WS_SUPPORT
/* Forward declaration of the WebSocket frame type. The full definition appears
* later in this header; only a pointer to it is needed here so that httpd_uri_t
* can carry an optional WebSocket control-frame handler. */
typedef struct httpd_ws_frame httpd_ws_frame_t;
#endif
/**
* @brief Structure for URI handler
*/
@@ -481,6 +488,23 @@ typedef struct httpd_uri {
*/
esp_err_t (*ws_post_handshake_cb)(httpd_req_t *req);
#endif /* CONFIG_HTTPD_WS_POST_HANDSHAKE_CB_SUPPORT */
/**
* 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).
*
* 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);
#endif /* CONFIG_HTTPD_WS_SUPPORT */
} httpd_uri_t;
@@ -89,6 +89,7 @@ struct sock_db {
bool ws_close; /*!< Set to true to close the socket later (when WS Close frame received) */
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 */
void *ws_user_ctx; /*!< Pointer to user context data which will be available to handler for websocket*/
#endif
};
@@ -553,6 +554,61 @@ esp_err_t httpd_ws_respond_server_handshake(httpd_req_t *req, const char *suppor
*/
esp_err_t httpd_ws_get_frame_type(httpd_req_t *req);
#ifdef CONFIG_HTTPD_WS_SUPPORT
/* Control frames carry at most 125 bytes of payload (RFC 6455 §5.5). These values
* match the historical auto-reply path: a 128-byte receive buffer, and a 126-byte
* cap passed to httpd_ws_recv_frame(). */
#define HTTPD_WS_CTRL_FRAME_BUF_LEN 128
#define HTTPD_WS_CTRL_FRAME_MAX_LEN 126
/**
* @brief Receive the body of a WebSocket control frame into a caller buffer.
*
* @note The opcode/FIN must already have been decoded by httpd_ws_get_frame_type().
* On success @p frame describes the received (unmasked) control frame with
* @p frame->payload pointing into @p buf.
*
* @param[in] req WebSocket request
* @param[out] frame Frame descriptor to populate
* @param[in] buf Caller-owned buffer of at least HTTPD_WS_CTRL_FRAME_BUF_LEN bytes
* @param[in] max_len Maximum payload length to accept
* @return
* - ESP_OK : Frame received
* - ESP_ERR_INVALID_STATE : Frame could not be fully received
*/
esp_err_t httpd_ws_recv_control_frame(httpd_req_t *req, httpd_ws_frame_t *frame, uint8_t *buf, size_t max_len);
/**
* @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.
*
* @param[in] req WebSocket request
* @param[in] frame Control frame previously received (modified in place)
* @return
* - ESP_OK : Reply sent (or none needed)
* - others : Socket send failure
*/
esp_err_t httpd_ws_reply_to_control_frame(httpd_req_t *req, httpd_ws_frame_t *frame);
/**
* @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.
*
* @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_err_t httpd_ws_handle_control_frame(httpd_req_t *req);
#endif /* CONFIG_HTTPD_WS_SUPPORT */
/**
* @brief Trigger an httpd session close externally
*
+15 -11
View File
@@ -851,17 +851,21 @@ esp_err_t httpd_req_new(struct httpd_data *hd, struct sock_db *sd)
ESP_LOGD(TAG, LOG_FMT("Received PONG frame"));
}
/* Call handler if it's a non-control frame, a PONG frame,
* or if handler requests control frames as well.
* PONG must be dispatched so that:
* 1. User code that sends PINGs can track responses (heartbeat)
* 2. The PONG frame bytes are consumed from the socket via
* httpd_ws_recv_frame(), preventing TCP stream misalignment */
if (ret == ESP_OK &&
(ra->ws_type < HTTPD_WS_TYPE_CLOSE ||
ra->ws_type == HTTPD_WS_TYPE_PONG ||
sd->ws_control_frames)) {
ret = sd->ws_handler(r);
/* 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.
* - 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
* so its bytes are consumed from the socket (avoiding stream misalignment). */
if (ret == ESP_OK) {
if (ra->ws_type >= HTTPD_WS_TYPE_CLOSE && sd->ws_control_handler != NULL) {
ret = httpd_ws_handle_control_frame(r);
} else if (ra->ws_type < HTTPD_WS_TYPE_CLOSE ||
ra->ws_type == HTTPD_WS_TYPE_PONG ||
sd->ws_control_frames) {
ret = sd->ws_handler(r);
}
}
if (ret != ESP_OK) {
@@ -176,6 +176,7 @@ esp_err_t httpd_register_uri_handler(httpd_handle_t handle,
#ifdef CONFIG_HTTPD_WS_SUPPORT
hd->hd_calls[i]->is_websocket = uri_handler->is_websocket;
hd->hd_calls[i]->handle_ws_control_frames = uri_handler->handle_ws_control_frames;
hd->hd_calls[i]->ws_control_handler = uri_handler->ws_control_handler;
if (uri_handler->supported_subprotocol) {
hd->hd_calls[i]->supported_subprotocol = strdup(uri_handler->supported_subprotocol);
if (hd->hd_calls[i]->supported_subprotocol == NULL) {
@@ -353,6 +354,7 @@ esp_err_t httpd_uri(struct httpd_data *hd)
aux->sd->ws_handshake_done = true;
aux->sd->ws_handler = uri->handler;
aux->sd->ws_control_frames = uri->handle_ws_control_frames;
aux->sd->ws_control_handler = uri->handle_ws_control_frames ? uri->ws_control_handler : NULL;
aux->sd->ws_user_ctx = uri->user_ctx;
#ifdef CONFIG_HTTPD_WS_POST_HANDSHAKE_CB_SUPPORT
+75 -37
View File
@@ -689,6 +689,71 @@ esp_err_t httpd_ws_send_frame_async(httpd_handle_t hd, int fd, httpd_ws_frame_t
return ESP_OK;
}
esp_err_t httpd_ws_recv_control_frame(httpd_req_t *req, httpd_ws_frame_t *frame,
uint8_t *buf, size_t max_len)
{
/* The opcode/FIN were already decoded by httpd_ws_get_frame_type(); a zeroed
* frame (len == 0) makes httpd_ws_recv_frame() read the remaining header and
* payload. Control payloads are <= 125 bytes (RFC 6455 §5.5). */
memset(frame, 0, sizeof(*frame));
frame->payload = buf;
if (httpd_ws_recv_frame(req, frame, max_len) != ESP_OK) {
ESP_LOGD(TAG, LOG_FMT("Cannot receive the full control frame"));
return ESP_ERR_INVALID_STATE;
}
return ESP_OK;
}
esp_err_t httpd_ws_reply_to_control_frame(httpd_req_t *req, httpd_ws_frame_t *frame)
{
switch (frame->type) {
case HTTPD_WS_TYPE_PING:
/* Reply to a PING with a PONG echoing the payload (RFC 6455 §5.5.2/5.5.3) */
ESP_LOGD(TAG, LOG_FMT("Got a WS PING frame, Replying PONG..."));
frame->type = HTTPD_WS_TYPE_PONG;
return httpd_ws_send_frame(req, frame);
case HTTPD_WS_TYPE_CLOSE:
/* Reply to a CLOSE with an empty CLOSE (RFC 6455 §5.5.1) */
ESP_LOGD(TAG, LOG_FMT("Got a WS CLOSE frame, Replying CLOSE..."));
frame->len = 0;
frame->payload = NULL;
return httpd_ws_send_frame(req, frame);
default:
/* PONG and any other control frame require no reply */
return ESP_OK;
}
}
esp_err_t httpd_ws_handle_control_frame(httpd_req_t *req)
{
struct httpd_req_aux *aux = req->aux;
if (aux == NULL || aux->sd == NULL) {
return ESP_ERR_INVALID_ARG;
}
struct sock_db *sd = aux->sd;
/* The server receives the control-frame body itself. Oversized or malformed
* frames are rejected by the max_len cap (zero-trust on client input). */
httpd_ws_frame_t frame;
uint8_t frame_buf[HTTPD_WS_CTRL_FRAME_BUF_LEN] = { 0 };
esp_err_t ret = httpd_ws_recv_control_frame(req, &frame, frame_buf, HTTPD_WS_CTRL_FRAME_MAX_LEN);
if (ret != ESP_OK) {
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;
}
esp_err_t httpd_ws_get_frame_type(httpd_req_t *req)
{
esp_err_t ret = httpd_ws_check_req(req);
@@ -756,48 +821,21 @@ esp_err_t httpd_ws_get_frame_type(httpd_req_t *req)
}
#endif /* CONFIG_HTTPD_WS_STRICTER_RFC6455 */
/* If userspace requests control frames, do not deal with the control frames */
/* If userspace requests control frames, do not deal with the control frames here */
if (!sd->ws_control_frames) {
ESP_LOGD(TAG, LOG_FMT("Handler not requests control frames"));
/* Reply to PING. For PONG and CLOSE, it will be handled elsewhere. */
if (aux->ws_type == HTTPD_WS_TYPE_PING) {
ESP_LOGD(TAG, LOG_FMT("Got a WS PING frame, Replying PONG..."));
/* Read the rest of the PING frame, for PONG to reply back. */
/* Please refer to RFC6455 Section 5.5.2 for more details */
/* Auto-handle PING and CLOSE: receive the body, then send the reply. PONG is
* deliberately not consumed here; it is dispatched to the data handler elsewhere. */
if (aux->ws_type == HTTPD_WS_TYPE_PING || aux->ws_type == HTTPD_WS_TYPE_CLOSE) {
httpd_ws_frame_t frame;
uint8_t frame_buf[128] = { 0 };
memset(&frame, 0, sizeof(httpd_ws_frame_t));
frame.payload = frame_buf;
if (httpd_ws_recv_frame(req, &frame, 126) != ESP_OK) {
ESP_LOGD(TAG, LOG_FMT("Cannot receive the full PING frame"));
return ESP_ERR_INVALID_STATE;
uint8_t frame_buf[HTTPD_WS_CTRL_FRAME_BUF_LEN] = { 0 };
esp_err_t recv_frame_ret = httpd_ws_recv_control_frame(req, &frame, frame_buf,
HTTPD_WS_CTRL_FRAME_MAX_LEN);
if (recv_frame_ret != ESP_OK) {
return recv_frame_ret;
}
/* Now turn the frame to PONG */
frame.type = HTTPD_WS_TYPE_PONG;
return httpd_ws_send_frame(req, &frame);
} else if (aux->ws_type == HTTPD_WS_TYPE_CLOSE) {
ESP_LOGD(TAG, LOG_FMT("Got a WS CLOSE frame, Replying CLOSE..."));
/* Read the rest of the CLOSE frame and response */
/* Please refer to RFC6455 Section 5.5.1 for more details */
httpd_ws_frame_t frame;
uint8_t frame_buf[128] = { 0 };
memset(&frame, 0, sizeof(httpd_ws_frame_t));
frame.payload = frame_buf;
if (httpd_ws_recv_frame(req, &frame, 126) != ESP_OK) {
ESP_LOGD(TAG, LOG_FMT("Cannot receive the full CLOSE frame"));
return ESP_ERR_INVALID_STATE;
}
frame.len = 0;
frame.type = HTTPD_WS_TYPE_CLOSE;
frame.payload = NULL;
return httpd_ws_send_frame(req, &frame);
return httpd_ws_reply_to_control_frame(req, &frame);
}
}
return ESP_OK;
@@ -561,6 +561,98 @@ TEST_CASE("httpd_queue_work fast-fails on ctrl mbox saturation", "[HTTP SERVER]"
}
#ifdef CONFIG_HTTPD_WS_SUPPORT
/* ------------------------------------------------------------------------- *
* White-box fixtures for the dedicated control-frame handler.
*
* These tests drive httpd_req_new() directly against a fake sock_db, feeding a
* crafted (zero-masked) WebSocket frame through a recv_fn override and capturing
* the server's reply through a send_fn override. No TCP/IP is involved, so they
* run identically on hardware and under QEMU.
* ------------------------------------------------------------------------- */
static int s_ws_data_handler_calls;
static int s_ws_control_handler_calls;
static httpd_ws_type_t s_ws_control_seen_type;
static size_t s_ws_control_seen_len;
static esp_err_t s_ws_control_ret;
static const uint8_t *s_ws_recv_data;
static size_t s_ws_recv_len;
static size_t s_ws_recv_off;
static uint8_t s_ws_sent[128];
static size_t s_ws_sent_len;
/* recv_fn override: serve the crafted frame byte stream, one chunk per call. */
static int ws_feed_recv(httpd_handle_t hd, int sockfd, char *buf, size_t buf_len, int flags)
{
(void)hd; (void)sockfd; (void)flags;
size_t remaining = s_ws_recv_len - s_ws_recv_off;
if (remaining == 0) {
return HTTPD_SOCK_ERR_FAIL;
}
size_t n = (buf_len < remaining) ? buf_len : remaining;
memcpy(buf, s_ws_recv_data + s_ws_recv_off, n);
s_ws_recv_off += n;
return (int)n;
}
/* send_fn override: capture whatever the server sends back (the protocol reply). */
static int ws_capture_send(httpd_handle_t hd, int sockfd, const char *buf, size_t buf_len, int flags)
{
(void)hd; (void)sockfd; (void)flags;
for (size_t i = 0; i < buf_len && s_ws_sent_len < sizeof(s_ws_sent); i++) {
s_ws_sent[s_ws_sent_len++] = (uint8_t)buf[i];
}
return (int)buf_len;
}
static esp_err_t ws_data_handler_spy(httpd_req_t *req)
{
(void)req;
s_ws_data_handler_calls++;
return ESP_OK;
}
static esp_err_t ws_control_handler_spy(httpd_req_t *req, const 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;
}
/* Wire a fake session/server and reset all fixtures around a single frame. */
static void ws_unit_ctx_init(struct httpd_data *hd, struct sock_db *session,
const uint8_t *frame, size_t frame_len)
{
s_ws_data_handler_calls = 0;
s_ws_control_handler_calls = 0;
s_ws_control_seen_type = HTTPD_WS_TYPE_CONTINUE;
s_ws_control_seen_len = 0;
s_ws_control_ret = ESP_OK;
s_ws_recv_data = frame;
s_ws_recv_len = frame_len;
s_ws_recv_off = 0;
s_ws_sent_len = 0;
memset(s_ws_sent, 0, sizeof(s_ws_sent));
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.max_open_sockets = 1; /* keep any session enumeration in bounds */
hd->config = config;
hd->hd_sd = session; /* non-NULL so httpd_sess_get() finds the reply target */
hd->hd_req_aux.resp_hdrs = calloc(config.max_resp_headers, sizeof(*hd->hd_req_aux.resp_hdrs));
TEST_ASSERT_NOT_NULL(hd->hd_req_aux.resp_hdrs);
session->fd = 123;
session->handle = (httpd_handle_t) hd;
session->recv_fn = ws_feed_recv;
session->send_fn = ws_capture_send;
session->ws_handshake_done = true;
session->ws_handler = ws_data_handler_spy;
session->ws_close = false;
}
TEST_CASE("WS recv failure marks close without dispatching handler", "[HTTP SERVER][websocket]")
{
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
@@ -944,6 +1036,119 @@ TEST_CASE("WS send uses 16-bit length encoding for exactly 65535-byte payload",
TEST_ASSERT_EQUAL(sizeof(expected_header), ws_send_capture_ctx.len);
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]")
{
/* Masked (zero-key) PING carrying a 2-byte payload "Hi". */
static const uint8_t ping_frame[] = { 0x89, 0x82, 0x00, 0x00, 0x00, 0x00, 'H', 'i' };
struct httpd_data hd = {0};
struct sock_db session = {0};
ws_unit_ctx_init(&hd, &session, ping_frame, sizeof(ping_frame));
session.ws_control_frames = true;
session.ws_control_handler = ws_control_handler_spy;
esp_err_t ret = httpd_req_new(&hd, &session);
TEST_ASSERT_EQUAL(ESP_OK, ret);
TEST_ASSERT_EQUAL(1, s_ws_control_handler_calls);
TEST_ASSERT_EQUAL(HTTPD_WS_TYPE_PING, s_ws_control_seen_type);
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_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]")
{
static const uint8_t close_frame[] = { 0x88, 0x80, 0x00, 0x00, 0x00, 0x00 };
struct httpd_data hd = {0};
struct sock_db session = {0};
ws_unit_ctx_init(&hd, &session, close_frame, sizeof(close_frame));
session.ws_control_frames = true;
session.ws_control_handler = ws_control_handler_spy;
esp_err_t ret = httpd_req_new(&hd, &session);
TEST_ASSERT_EQUAL(ESP_OK, ret);
TEST_ASSERT_EQUAL(1, s_ws_control_handler_calls);
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_TRUE(session.ws_close); /* server marked the session for close */
free(hd.hd_req_aux.resp_hdrs);
}
TEST_CASE("WS control handler receives PONG with no reply", "[HTTP SERVER][websocket]")
{
static const uint8_t pong_frame[] = { 0x8A, 0x80, 0x00, 0x00, 0x00, 0x00 };
struct httpd_data hd = {0};
struct sock_db session = {0};
ws_unit_ctx_init(&hd, &session, pong_frame, sizeof(pong_frame));
session.ws_control_frames = true;
session.ws_control_handler = ws_control_handler_spy;
esp_err_t ret = httpd_req_new(&hd, &session);
TEST_ASSERT_EQUAL(ESP_OK, ret);
TEST_ASSERT_EQUAL(1, s_ws_control_handler_calls);
TEST_ASSERT_EQUAL(HTTPD_WS_TYPE_PONG, s_ws_control_seen_type);
TEST_ASSERT_EQUAL(0, s_ws_data_handler_calls);
TEST_ASSERT_EQUAL(0, s_ws_sent_len); /* a PONG is never answered */
TEST_ASSERT_FALSE(session.ws_close);
free(hd.hd_req_aux.resp_hdrs);
}
TEST_CASE("WS without control handler auto-replies PING (backward compatible)", "[HTTP SERVER][websocket]")
{
/* Mode 1: no control handler, flag off -> server must still auto-reply PONG and
* must not dispatch the PING to the data handler (unchanged legacy behavior). */
static const uint8_t ping_frame[] = { 0x89, 0x80, 0x00, 0x00, 0x00, 0x00 };
struct httpd_data hd = {0};
struct sock_db session = {0};
ws_unit_ctx_init(&hd, &session, ping_frame, sizeof(ping_frame));
session.ws_control_frames = false;
session.ws_control_handler = NULL;
esp_err_t ret = httpd_req_new(&hd, &session);
TEST_ASSERT_EQUAL(ESP_OK, ret);
TEST_ASSERT_EQUAL(0, s_ws_control_handler_calls);
TEST_ASSERT_EQUAL(0, s_ws_data_handler_calls);
TEST_ASSERT_GREATER_THAN(0, s_ws_sent_len);
TEST_ASSERT_EQUAL_HEX8(0x8A, s_ws_sent[0]); /* auto PONG */
free(hd.hd_req_aux.resp_hdrs);
}
TEST_CASE("WS control handler error still replies then 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. */
static const uint8_t ping_frame[] = { 0x89, 0x80, 0x00, 0x00, 0x00, 0x00 };
struct httpd_data hd = {0};
struct sock_db session = {0};
ws_unit_ctx_init(&hd, &session, ping_frame, sizeof(ping_frame));
session.ws_control_frames = true;
session.ws_control_handler = ws_control_handler_spy;
s_ws_control_ret = ESP_FAIL;
esp_err_t ret = httpd_req_new(&hd, &session);
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 */
free(hd.hd_req_aux.resp_hdrs);
}
#endif /* CONFIG_HTTPD_WS_SUPPORT */
/********* URL query / header pointer-accessor tests *********