From cfad6727fe8b7aabd38353a7eab3aa9a19847098 Mon Sep 17 00:00:00 2001 From: Ashish Sharma Date: Thu, 9 Jul 2026 14:30:47 +0800 Subject: [PATCH] feat(esp_http_server): add dedicated WebSocket control-frame handler Closes https://github.com/espressif/esp-idf/issues/18448 --- .../esp_http_server/include/esp_http_server.h | 24 ++++ .../esp_http_server/src/esp_httpd_priv.h | 56 +++++++++ components/esp_http_server/src/httpd_parse.c | 26 ++-- components/esp_http_server/src/httpd_uri.c | 2 + components/esp_http_server/src/httpd_ws.c | 112 ++++++++++++------ 5 files changed, 172 insertions(+), 48 deletions(-) diff --git a/components/esp_http_server/include/esp_http_server.h b/components/esp_http_server/include/esp_http_server.h index f03a5cf95f3..bcebe2dd1aa 100644 --- a/components/esp_http_server/include/esp_http_server.h +++ b/components/esp_http_server/include/esp_http_server.h @@ -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; diff --git a/components/esp_http_server/src/esp_httpd_priv.h b/components/esp_http_server/src/esp_httpd_priv.h index e0fd7492e45..c612ea61603 100644 --- a/components/esp_http_server/src/esp_httpd_priv.h +++ b/components/esp_http_server/src/esp_httpd_priv.h @@ -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 * diff --git a/components/esp_http_server/src/httpd_parse.c b/components/esp_http_server/src/httpd_parse.c index 88d2d88d908..a5be0cced71 100644 --- a/components/esp_http_server/src/httpd_parse.c +++ b/components/esp_http_server/src/httpd_parse.c @@ -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) { diff --git a/components/esp_http_server/src/httpd_uri.c b/components/esp_http_server/src/httpd_uri.c index 75b45561436..09f444256ea 100644 --- a/components/esp_http_server/src/httpd_uri.c +++ b/components/esp_http_server/src/httpd_uri.c @@ -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 diff --git a/components/esp_http_server/src/httpd_ws.c b/components/esp_http_server/src/httpd_ws.c index fa6e1fc3d29..8fde6f6caff 100644 --- a/components/esp_http_server/src/httpd_ws.c +++ b/components/esp_http_server/src/httpd_ws.c @@ -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;