feat(esp_http_server): add httpd_ws_close_session() for graceful WS shutdown

This commit is contained in:
Ashish Sharma
2026-05-18 16:39:25 +08:00
parent d0e35277dd
commit 731ef25e37
3 changed files with 397 additions and 38 deletions

View File

@@ -1990,6 +1990,33 @@ esp_err_t httpd_ws_send_data(httpd_handle_t handle, int socket, httpd_ws_frame_t
esp_err_t httpd_ws_send_data_async(httpd_handle_t handle, int socket, httpd_ws_frame_t *frame,
transfer_complete_cb callback, void *arg);
/**
* @brief Initiate a graceful WebSocket close handshake on a session.
*
* Sends a CLOSE frame with the given status code and optional UTF-8 reason,
* marks the session as closing (which blocks any further outbound data
* frames per RFC 6455 §1.4), and lets the underlying TCP socket be torn
* down at the next dispatch boundary. The call is idempotent: if the
* session was already closing it returns ESP_OK without emitting a second
* CLOSE frame, and the code and reason arguments are not validated.
*
* @param[in] hd Server handle.
* @param[in] fd Socket descriptor of the session to close.
* @param[in] code Status code per RFC 6455 §7.4 (e.g., 1000 for normal
* closure). Reserved codes (1005, 1006) and out-of-range
* values are rejected.
* @param[in] reason Optional NUL-terminated UTF-8 reason, or NULL. Must be
* at most 123 bytes so the total control frame payload
* (2-byte code + reason) fits in 125 bytes.
* @return
* - ESP_OK : CLOSE sent (or session was already closing).
* - ESP_ERR_INVALID_ARG : Invalid fd, invalid code, reason exceeds 123 bytes,
* or reason is not valid UTF-8.
* - ESP_ERR_INVALID_STATE : Socket is not an established WebSocket session.
* - ESP_FAIL : Underlying transport send failed.
*/
esp_err_t httpd_ws_close_session(httpd_handle_t hd, int fd, uint16_t code, const char *reason);
#endif /* CONFIG_HTTPD_WS_SUPPORT || __DOXYGEN__ */
/** End of WebSocket related stuff
* @}

View File

@@ -54,6 +54,11 @@ static const char *TAG="httpd_ws";
#define HTTPD_WS_MASK_BIT 0x80U
#define HTTPD_WS_LENGTH_BITS 0x7fU
/* RFC 6455 §5.5: a control frame payload is at most 125 bytes, so a CLOSE
* reason gets whatever is left after the 2-byte status code. */
#define HTTPD_WS_CONTROL_PAYLOAD_MAX 125U
#define HTTPD_WS_CLOSE_REASON_MAX (HTTPD_WS_CONTROL_PAYLOAD_MAX - 2U)
/* RFC 6455 §7.4 close status codes used for protocol-error Close frames */
#define HTTPD_WS_CLOSE_CODE_PROTOCOL_ERROR 1002U
#define HTTPD_WS_CLOSE_CODE_INVALID_UTF8 1007U
@@ -150,7 +155,7 @@ esp_err_t httpd_ws_respond_server_handshake(httpd_req_t *req, const char *suppor
ESP_LOGW(TAG, LOG_FMT("\"Host\" is not found"));
return httpd_ws_send_handshake_error(req, "400 Bad Request", "Missing Host header", NULL);
}
#endif
#endif /* CONFIG_HTTPD_WS_STRICTER_RFC6455 */
/* RFC 6455 §4.2.1: Sec-WebSocket-Version must be present and equal "13" */
size_t version_hdr_len = httpd_req_get_hdr_value_len(req, "Sec-WebSocket-Version");
@@ -165,7 +170,7 @@ esp_err_t httpd_ws_respond_server_handshake(httpd_req_t *req, const char *suppor
return httpd_ws_send_handshake_error(req, "400 Bad Request",
"Invalid Sec-WebSocket-Version header", NULL);
}
#endif
#endif /* CONFIG_HTTPD_WS_STRICTER_RFC6455 */
char *version_val = calloc(1, version_hdr_len + 1);
if (version_val == NULL) {
ESP_LOGE(TAG, "Failed to allocate version header buffer");
@@ -199,7 +204,7 @@ esp_err_t httpd_ws_respond_server_handshake(httpd_req_t *req, const char *suppor
return httpd_ws_send_handshake_error(req, "400 Bad Request",
"Invalid Sec-WebSocket-Key header", NULL);
}
#endif
#endif /* CONFIG_HTTPD_WS_STRICTER_RFC6455 */
char *sec_key_encoded = calloc(1, sec_key_hdr_len + 1);
if (sec_key_encoded == NULL) {
ESP_LOGE(TAG, "Failed to allocate Sec-WebSocket-Key buffer");
@@ -221,7 +226,7 @@ esp_err_t httpd_ws_respond_server_handshake(httpd_req_t *req, const char *suppor
return httpd_ws_send_handshake_error(req, "400 Bad Request",
"Invalid Sec-WebSocket-Key header", NULL);
}
#endif
#endif /* CONFIG_HTTPD_WS_STRICTER_RFC6455 */
/* Prepare server key (Sec-WebSocket-Accept), concat the string */
char server_key_encoded[33] = { '\0' };
@@ -477,6 +482,7 @@ static bool httpd_ws_is_valid_close_code(uint16_t code)
return code >= 3000 && code <= 4999;
}
#if CONFIG_HTTPD_WS_STRICTER_RFC6455
/* Validates the payload of a CLOSE frame (RFC 6455 §5.5.1 / §7.4 / §8.1). */
static esp_err_t httpd_ws_validate_close_frame(httpd_req_t *req, const httpd_ws_frame_t *frame)
{
@@ -517,6 +523,21 @@ static esp_err_t httpd_ws_unmask_payload(uint8_t *payload, size_t len, const uin
return ESP_OK;
}
/* Short reads inside a frame leave the WS stream desynchronized, so fail the
* connection per RFC 6455 §7.1.7 to mark the session closing and let the
* dispatcher tear down the TCP socket cleanly. A CLOSE frame is still
* attempted; if the peer is already gone the send simply fails and
* httpd_ws_fail_connection() logs a warning. */
static inline esp_err_t httpd_ws_recv_short_read_fail(httpd_req_t *req)
{
#if CONFIG_HTTPD_WS_STRICTER_RFC6455
return httpd_ws_fail_connection(req, HTTPD_WS_CLOSE_CODE_PROTOCOL_ERROR);
#else
(void)req;
return ESP_FAIL;
#endif
}
static esp_err_t httpd_ws_recv_frame_internal(httpd_req_t *req, httpd_ws_frame_t *frame, size_t max_len, bool partial)
{
esp_err_t ret = httpd_ws_check_req(req);
@@ -545,7 +566,7 @@ static esp_err_t httpd_ws_recv_frame_internal(httpd_req_t *req, httpd_ws_frame_t
int recv_ret = httpd_recv_with_opt(req, (char *)&second_byte, sizeof(second_byte), HTTPD_RECV_OPT_BLOCKING);
if (recv_ret != (int)sizeof(second_byte)) {
ESP_LOGW(TAG, LOG_FMT("Failed to receive the second byte"));
return ESP_FAIL;
return httpd_ws_recv_short_read_fail(req);
}
/* Parse the second byte */
@@ -574,7 +595,7 @@ static esp_err_t httpd_ws_recv_frame_internal(httpd_req_t *req, httpd_ws_frame_t
recv_ret = httpd_recv_with_opt(req, (char *)length_bytes, sizeof(length_bytes), HTTPD_RECV_OPT_BLOCKING);
if (recv_ret != (int)sizeof(length_bytes)) {
ESP_LOGW(TAG, LOG_FMT("Failed to receive 2 bytes length"));
return ESP_FAIL;
return httpd_ws_recv_short_read_fail(req);
}
uint16_t length = ((uint16_t)(length_bytes[0] << 8U) | (length_bytes[1]));
@@ -592,7 +613,7 @@ static esp_err_t httpd_ws_recv_frame_internal(httpd_req_t *req, httpd_ws_frame_t
recv_ret = httpd_recv_with_opt(req, (char *)length_bytes, sizeof(length_bytes), HTTPD_RECV_OPT_BLOCKING);
if (recv_ret != (int)sizeof(length_bytes)) {
ESP_LOGW(TAG, LOG_FMT("Failed to receive 8 bytes length"));
return ESP_FAIL;
return httpd_ws_recv_short_read_fail(req);
}
#if CONFIG_HTTPD_WS_STRICTER_RFC6455
@@ -631,7 +652,7 @@ static esp_err_t httpd_ws_recv_frame_internal(httpd_req_t *req, httpd_ws_frame_t
recv_ret = httpd_recv_with_opt(req, (char *)aux->mask_key, sizeof(aux->mask_key), HTTPD_RECV_OPT_BLOCKING);
if (recv_ret != (int)sizeof(aux->mask_key)) {
ESP_LOGW(TAG, LOG_FMT("Failed to receive mask key"));
return ESP_FAIL;
return httpd_ws_recv_short_read_fail(req);
}
} else {
/* If the WS frame from client to server is not masked, it should be rejected.
@@ -675,7 +696,7 @@ static esp_err_t httpd_ws_recv_frame_internal(httpd_req_t *req, httpd_ws_frame_t
int read_len = httpd_recv_with_opt(req, (char *)frame->payload + offset, left_len, HTTPD_RECV_OPT_NONE);
if (read_len <= 0) {
ESP_LOGW(TAG, LOG_FMT("Failed to receive payload"));
return ESP_FAIL;
return httpd_ws_recv_short_read_fail(req);
}
offset += read_len;
left_len -= read_len;
@@ -1051,4 +1072,62 @@ esp_err_t httpd_ws_send_data_async(httpd_handle_t handle, int socket, httpd_ws_f
return ESP_OK;
}
esp_err_t httpd_ws_close_session(httpd_handle_t hd, int fd, uint16_t code, const char *reason)
{
struct sock_db *sess = httpd_sess_get(hd, fd);
if (!sess) {
return ESP_ERR_INVALID_ARG;
}
if (!sess->ws_handshake_done) {
return ESP_ERR_INVALID_STATE;
}
/* Idempotent: do not emit a second CLOSE if the session is already closing.
* Checked before argument validation since nothing will be sent. */
if (sess->ws_close) {
return ESP_OK;
}
if (!httpd_ws_is_valid_close_code(code)) {
return ESP_ERR_INVALID_ARG;
}
/* Reason is optional. Reject anything that would push the control frame
* payload past the 125-byte cap or that isn't well-formed UTF-8 (§5.5/§8.1). */
size_t reason_len = (reason != NULL) ? strlen(reason) : 0;
if (reason_len > HTTPD_WS_CLOSE_REASON_MAX) {
return ESP_ERR_INVALID_ARG;
}
if (reason_len > 0 && httpd_ws_validate_utf8((const uint8_t *)reason, reason_len) != ESP_OK) {
return ESP_ERR_INVALID_ARG;
}
uint8_t payload[HTTPD_WS_CONTROL_PAYLOAD_MAX];
payload[0] = (uint8_t)(code >> 8U);
payload[1] = (uint8_t)(code & 0xFFU);
if (reason_len > 0) {
memcpy(&payload[2], reason, reason_len);
}
httpd_ws_frame_t close_frame = {
.final = true,
.fragmented = false,
.type = HTTPD_WS_TYPE_CLOSE,
.payload = payload,
.len = 2 + reason_len,
};
/* Mark closing before send so the gate in httpd_ws_send_frame_async
* blocks any data frame a concurrent task tries to emit after this point.
* If the send fails (transport error), roll back the flag so the caller
* can retry; otherwise a transient TCP failure would permanently strand
* the session. */
sess->ws_close = true;
esp_err_t ret = httpd_ws_send_frame_async(hd, fd, &close_frame);
if (ret != ESP_OK) {
sess->ws_close = false;
}
return ret;
}
#endif /* CONFIG_HTTPD_WS_SUPPORT */

View File

@@ -107,6 +107,16 @@ static int ws_recv_fail_override(httpd_handle_t hd, int sockfd, char *buf, size_
return HTTPD_SOCK_ERR_FAIL;
}
static int ws_send_fail_override(httpd_handle_t hd, int sockfd, const char *buf, size_t buf_len, int flags)
{
(void)hd;
(void)sockfd;
(void)buf;
(void)buf_len;
(void)flags;
return HTTPD_SOCK_ERR_FAIL;
}
static int ws_scripted_recv_override(httpd_handle_t hd, int sockfd, char *buf, size_t buf_len, int flags)
{
(void)hd;
@@ -742,33 +752,6 @@ TEST_CASE("WS handshake unsupported version returns 426 with Sec-WebSocket-Versi
TEST_ASSERT_EQUAL(ESP_OK, httpd_stop(hd));
}
TEST_CASE("WS send uses 16-bit length encoding for exactly 65535-byte payload", "[HTTP SERVER][websocket]")
{
static const uint8_t expected_header[] = { 0x82, 0x7E, 0xFF, 0xFF };
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
struct httpd_data hd = {0};
struct sock_db session = {0};
httpd_ws_frame_t frame = {
.type = HTTPD_WS_TYPE_BINARY,
.payload = NULL,
.len = UINT16_MAX,
};
memset(&ws_send_capture_ctx, 0, sizeof(ws_send_capture_ctx));
hd.config = config;
hd.config.max_open_sockets = 1;
hd.hd_sd = &session;
session.fd = 123;
session.handle = (httpd_handle_t)&hd;
session.send_fn = ws_scripted_send_override;
TEST_ASSERT_EQUAL(ESP_OK, httpd_ws_send_frame_async(&hd, session.fd, &frame));
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));
}
#if CONFIG_HTTPD_WS_STRICTER_RFC6455
TEST_CASE("WS HTTP/1.0 upgrade request returns 400", "[HTTP SERVER][websocket]")
{
@@ -851,6 +834,23 @@ static void ws_setup_recv_fixture(struct httpd_data *hd, httpd_req_t *req,
session->ws_handshake_done = true;
}
/* Common fake-session wiring for the send-path tests: a single-socket server
* whose send is captured. ws_handshake_done is left to the caller so tests can
* exercise the not-yet-handshaken path. */
static void ws_setup_send_fixture(struct httpd_data *hd, struct sock_db *session)
{
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
memset(&ws_send_capture_ctx, 0, sizeof(ws_send_capture_ctx));
hd->config = config;
hd->config.max_open_sockets = 1;
hd->hd_sd = session;
session->fd = 123;
session->handle = (httpd_handle_t)hd;
session->send_fn = ws_scripted_send_override;
}
/* Asserts the session was marked closing and a CLOSE frame carrying the given
* status code was emitted on the wire. */
static void ws_assert_close_sent(const struct sock_db *session, uint16_t code)
@@ -1111,8 +1111,11 @@ TEST_CASE("WS recv rejects CLOSE frame with invalid UTF-8 reason", "[HTTP SERVER
TEST_CASE("WS recv rejects invalid UTF-8 in text frame", "[HTTP SERVER][websocket]")
{
/* Binary frame mismarked as TEXT; payload 0xC0 0xAF is an overlong encoding */
static const uint8_t ws_frame[] = { 0x82, 0x82, 0x00, 0x00, 0x00, 0x00, 0xC0, 0xAF };
/* recv_frame starts at the second byte (get_frame_type already consumed the
* opcode). Second byte 0x82: MASK=1 len=2, zero mask key, payload 0xC0 0xAF
* (an overlong UTF-8 encoding). aux.ws_type is forced to TEXT below so the
* UTF-8 validator runs on this payload. */
static const uint8_t ws_frame[] = { 0x82, 0x00, 0x00, 0x00, 0x00, 0xC0, 0xAF };
struct httpd_data hd = {0};
httpd_req_t req = {0};
@@ -1193,7 +1196,257 @@ TEST_CASE("httpd_ws_validate_utf8 accepts valid and rejects malformed UTF-8", "[
TEST_ASSERT_EQUAL(ESP_ERR_INVALID_ARG, httpd_ws_validate_utf8(stray_trail, sizeof(stray_trail)));
TEST_ASSERT_EQUAL(ESP_ERR_INVALID_ARG, httpd_ws_validate_utf8(truncated, sizeof(truncated)));
}
TEST_CASE("httpd_ws_close_session emits CLOSE with code and reason, marks closing", "[HTTP SERVER][websocket]")
{
/* Expected wire bytes: FIN|CLOSE, len=5, 0x03E8 (=1000), "bye" */
static const uint8_t expected[] = { 0x88, 0x05, 0x03, 0xE8, 'b', 'y', 'e' };
struct httpd_data hd = {0};
struct sock_db session = {0};
ws_setup_send_fixture(&hd, &session);
session.ws_handshake_done = true;
TEST_ASSERT_EQUAL(ESP_OK, httpd_ws_close_session(&hd, session.fd, 1000, "bye"));
TEST_ASSERT_TRUE(session.ws_close);
TEST_ASSERT_EQUAL(sizeof(expected), ws_send_capture_ctx.len);
TEST_ASSERT_EQUAL_UINT8_ARRAY(expected, ws_send_capture_ctx.data, sizeof(expected));
}
TEST_CASE("httpd_ws_close_session rejects invalid input and is idempotent", "[HTTP SERVER][websocket]")
{
struct httpd_data hd = {0};
struct sock_db session = {0};
/* Reason buffer 124 bytes (exceeds 123-byte cap). */
char too_long[125];
memset(too_long, 'x', sizeof(too_long) - 1);
too_long[sizeof(too_long) - 1] = '\0';
/* Reason containing overlong UTF-8. */
static const char bad_utf8[] = { (char)0xC0, (char)0x80, '\0' };
ws_setup_send_fixture(&hd, &session);
/* Not-yet-handshaken socket → ESP_ERR_INVALID_STATE, no send. */
TEST_ASSERT_EQUAL(ESP_ERR_INVALID_STATE, httpd_ws_close_session(&hd, session.fd, 1000, NULL));
TEST_ASSERT_EQUAL(0, ws_send_capture_ctx.len);
session.ws_handshake_done = true;
/* Reserved close code → ESP_ERR_INVALID_ARG, no send. */
TEST_ASSERT_EQUAL(ESP_ERR_INVALID_ARG, httpd_ws_close_session(&hd, session.fd, 1005, NULL));
TEST_ASSERT_EQUAL(0, ws_send_capture_ctx.len);
/* Reason over 123 bytes → ESP_ERR_INVALID_ARG, no send. */
TEST_ASSERT_EQUAL(ESP_ERR_INVALID_ARG, httpd_ws_close_session(&hd, session.fd, 1000, too_long));
TEST_ASSERT_EQUAL(0, ws_send_capture_ctx.len);
/* Reason with malformed UTF-8 → ESP_ERR_INVALID_ARG, no send. */
TEST_ASSERT_EQUAL(ESP_ERR_INVALID_ARG, httpd_ws_close_session(&hd, session.fd, 1000, bad_utf8));
TEST_ASSERT_EQUAL(0, ws_send_capture_ctx.len);
/* Valid close emits a CLOSE frame and marks ws_close. */
TEST_ASSERT_EQUAL(ESP_OK, httpd_ws_close_session(&hd, session.fd, 1000, NULL));
TEST_ASSERT_TRUE(session.ws_close);
TEST_ASSERT_EQUAL(4, ws_send_capture_ctx.len); /* 0x88 0x02 0x03 0xE8 */
/* Second call on an already-closing session is a no-op (returns OK, no second send). */
size_t bytes_after_first = ws_send_capture_ctx.len;
TEST_ASSERT_EQUAL(ESP_OK, httpd_ws_close_session(&hd, session.fd, 1001, "again"));
TEST_ASSERT_EQUAL(bytes_after_first, ws_send_capture_ctx.len);
/* Already-closing wins over argument validation: invalid code and reason
* are not inspected because nothing will be sent. */
TEST_ASSERT_EQUAL(ESP_OK, httpd_ws_close_session(&hd, session.fd, 1005, bad_utf8));
TEST_ASSERT_EQUAL(bytes_after_first, ws_send_capture_ctx.len);
}
TEST_CASE("WS recv short mask-key read fails the connection", "[HTTP SERVER][websocket]")
{
/* Second byte 0x82: MASK=1 len=2. The buffer ends here, so the mask-key
* read returns short. The desynchronized stream must fail the connection
* per RFC 6455 §7.1.7: session marked closing, CLOSE 1002 emitted. */
static const uint8_t ws_frame[] = { 0x82 };
struct httpd_data hd = {0};
httpd_req_t req = {0};
struct httpd_req_aux aux = {0};
struct sock_db session = {0};
httpd_ws_frame_t frame = {0};
ws_setup_recv_fixture(&hd, &req, &aux, &session, ws_frame, sizeof(ws_frame));
aux.ws_type = HTTPD_WS_TYPE_BINARY;
aux.ws_final = true;
TEST_ASSERT_EQUAL(ESP_FAIL, httpd_ws_recv_frame(&req, &frame, 0));
TEST_ASSERT_EQUAL(HTTPD_WS_TYPE_CLOSE, aux.ws_type);
ws_assert_close_sent(&session, 1002);
}
TEST_CASE("httpd_ws_close_session rolls back ws_close when send fails", "[HTTP SERVER][websocket]")
{
struct httpd_data hd = {0};
struct sock_db session = {0};
ws_setup_send_fixture(&hd, &session);
session.send_fn = ws_send_fail_override; /* every send returns -1 */
session.ws_handshake_done = true;
/* First attempt: transport send fails, ws_close must be rolled back so
* the caller can retry and the post-CLOSE outbound gate doesn't strand
* the session. */
TEST_ASSERT_EQUAL(ESP_FAIL, httpd_ws_close_session(&hd, session.fd, 1000, NULL));
TEST_ASSERT_FALSE(session.ws_close);
/* A second attempt with a working transport now succeeds (proving the
* idempotency short-circuit didn't permanently no-op the session). */
session.send_fn = ws_scripted_send_override;
memset(&ws_send_capture_ctx, 0, sizeof(ws_send_capture_ctx));
TEST_ASSERT_EQUAL(ESP_OK, httpd_ws_close_session(&hd, session.fd, 1000, NULL));
TEST_ASSERT_TRUE(session.ws_close);
TEST_ASSERT_EQUAL(4, ws_send_capture_ctx.len); /* 0x88 0x02 0x03 0xE8 */
}
#endif /* CONFIG_HTTPD_WS_STRICTER_RFC6455 */
TEST_CASE("WS send uses 16-bit length encoding for exactly 65535-byte payload", "[HTTP SERVER][websocket]")
{
static const uint8_t expected_header[] = { 0x82, 0x7E, 0xFF, 0xFF };
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
struct httpd_data hd = {0};
struct sock_db session = {0};
httpd_ws_frame_t frame = {
.type = HTTPD_WS_TYPE_BINARY,
.payload = NULL,
.len = UINT16_MAX,
};
memset(&ws_send_capture_ctx, 0, sizeof(ws_send_capture_ctx));
hd.config = config;
hd.config.max_open_sockets = 1;
hd.hd_sd = &session;
session.fd = 123;
session.handle = (httpd_handle_t)&hd;
session.send_fn = ws_scripted_send_override;
TEST_ASSERT_EQUAL(ESP_OK, httpd_ws_send_frame_async(&hd, session.fd, &frame));
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 *********