From 5bd8050985d05efad710d66812857fb42dfe71bc Mon Sep 17 00:00:00 2001 From: Ashish Sharma Date: Thu, 9 Jul 2026 14:31:18 +0800 Subject: [PATCH] test(esp_http_server): cover WebSocket control-frame handler --- .../test_apps/main/test_http_server.c | 205 ++++++++++++++++++ .../http_server/ws_echo_server/README.md | 13 ++ .../ws_echo_server/main/Kconfig.projbuild | 10 + .../ws_echo_server/main/ws_echo_server.c | 38 +++- .../pytest_ws_server_example.py | 45 +++- 5 files changed, 300 insertions(+), 11 deletions(-) diff --git a/components/esp_http_server/test_apps/main/test_http_server.c b/components/esp_http_server/test_apps/main/test_http_server.c index 61c1e790833..778ca940b33 100644 --- a/components/esp_http_server/test_apps/main/test_http_server.c +++ b/components/esp_http_server/test_apps/main/test_http_server.c @@ -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 ********* diff --git a/examples/protocols/http_server/ws_echo_server/README.md b/examples/protocols/http_server/ws_echo_server/README.md index 5a113339551..201acc52c4d 100644 --- a/examples/protocols/http_server/ws_echo_server/README.md +++ b/examples/protocols/http_server/ws_echo_server/README.md @@ -76,6 +76,19 @@ Each outgoing frame has the FIN flag set by default. In case an application wants to send fragmented data, it must be done manually by setting the `fragmented` option and using the `final` flag as described in [RFC6455, section 5.4](https://tools.ietf.org/html/rfc6455#section-5.4). +#### Handling control frames + +By default the server replies to control frames (PING, CLOSE) automatically. Setting `handle_ws_control_frames = true` alone routes control frames to the data handler instead, which then has to receive them and send the protocol replies itself. + +This example registers a dedicated control-frame handler on the `/ws` endpoint (see `CONFIG_EXAMPLE_ENABLE_WS_CONTROL_FRAME_HANDLER`, enabled by default): + +```c + .handle_ws_control_frames = true, + .ws_control_handler = ws_control_frame_handler, // observes 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. + ### Hardware Required diff --git a/examples/protocols/http_server/ws_echo_server/main/Kconfig.projbuild b/examples/protocols/http_server/ws_echo_server/main/Kconfig.projbuild index a11c9edf482..c122cac76b7 100644 --- a/examples/protocols/http_server/ws_echo_server/main/Kconfig.projbuild +++ b/examples/protocols/http_server/ws_echo_server/main/Kconfig.projbuild @@ -20,4 +20,14 @@ menu "Example Configuration" In this example, the post-handshake callback is used to send a welcome message to the client after the handshake is complete. + config EXAMPLE_ENABLE_WS_CONTROL_FRAME_HANDLER + bool "Enable dedicated WebSocket control-frame handler" + 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. + endmenu diff --git a/examples/protocols/http_server/ws_echo_server/main/ws_echo_server.c b/examples/protocols/http_server/ws_echo_server/main/ws_echo_server.c index 7abcbbf44c2..c83e142b93a 100644 --- a/examples/protocols/http_server/ws_echo_server/main/ws_echo_server.c +++ b/examples/protocols/http_server/ws_echo_server/main/ws_echo_server.c @@ -139,6 +139,36 @@ static esp_err_t ws_post_handshake_cb(httpd_req_t *req) } #endif /* CONFIG_EXAMPLE_ENABLE_WS_POST_HANDSHAKE_CB */ +#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. + * + * 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) +{ + switch (frame->type) { + case HTTPD_WS_TYPE_PING: + ESP_LOGI(TAG, "Control frame: PING (len %d), server replies PONG", frame->len); + break; + case HTTPD_WS_TYPE_PONG: + ESP_LOGI(TAG, "Control frame: PONG, heartbeat alive"); + break; + case HTTPD_WS_TYPE_CLOSE: + ESP_LOGI(TAG, "Control frame: CLOSE (len %d), server replies CLOSE", frame->len); + break; + default: + ESP_LOGI(TAG, "Control frame: type %d", frame->type); + break; + } + return ESP_OK; +} +#endif /* CONFIG_EXAMPLE_ENABLE_WS_CONTROL_FRAME_HANDLER */ + /* * This handler echos back the received ws data * and triggers an async send if certain message received @@ -278,7 +308,13 @@ static const httpd_uri_t ws = { .method = HTTP_GET, .handler = echo_handler, .user_ctx = NULL, - .is_websocket = true + .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. */ + .handle_ws_control_frames = true, + .ws_control_handler = ws_control_frame_handler, +#endif /* CONFIG_EXAMPLE_ENABLE_WS_CONTROL_FRAME_HANDLER */ }; static const httpd_uri_t ws_partial = { diff --git a/examples/protocols/http_server/ws_echo_server/pytest_ws_server_example.py b/examples/protocols/http_server/ws_echo_server/pytest_ws_server_example.py index d6cfcb5ad52..98a329ec688 100644 --- a/examples/protocols/http_server/ws_echo_server/pytest_ws_server_example.py +++ b/examples/protocols/http_server/ws_echo_server/pytest_ws_server_example.py @@ -111,6 +111,11 @@ def test_examples_protocol_http_ws_echo_server(dut: Dut) -> None: got_ip, got_port = _wait_for_server_ready(dut) + # With the dedicated control-frame handler enabled, PING/PONG/CLOSE are + # observed (logged) by the control handler on the DUT, while the server + # still sends the protocol replies itself. + control_handler_enabled = dut.app.sdkconfig.get('EXAMPLE_ENABLE_WS_CONTROL_FRAME_HANDLER') is True + # Start ws server test with WsClient(got_ip, got_port, uri='ws') as ws: DATA = 'Espressif' @@ -122,6 +127,9 @@ def test_examples_protocol_http_ws_echo_server(dut: Dut) -> None: if expected_opcode == OPCODE_PING: if opcode != OPCODE_PONG or data != DATA: raise RuntimeError(f'Failed to receive correct opcode:{opcode} or data:{data}') + if control_handler_enabled: + # The control-frame handler must have observed the client's PING + dut.expect(rf'Control frame: PING \(len {len(DATA)}\), server replies PONG', timeout=10) continue dut_data = dut.expect(r'Got packet with message: ([A-Za-z0-9_]*)')[1] dut_opcode = dut.expect(r'Packet type: ([0-9]*)')[1].decode() @@ -150,11 +158,17 @@ def test_examples_protocol_http_ws_echo_server(dut: Dut) -> None: data = data.decode() if opcode != OPCODE_PING: raise RuntimeError(f'Failed to receive correct opcode:{opcode}') - # Now we should get a pong in response to our ping - opcode, data = ws.read() - data = data.decode() - if opcode != OPCODE_PONG: - raise RuntimeError(f'Failed to receive correct opcode:{opcode}') + # The client library auto-replies PONG to the server's PING. With the + # control-frame handler enabled, the DUT observes that PONG in the + # control handler and does not echo it; otherwise the data handler + # echoes the PONG back to the client. + if control_handler_enabled: + dut.expect('Control frame: PONG, heartbeat alive', timeout=10) + else: + opcode, data = ws.read() + data = data.decode() + if opcode != OPCODE_PONG: + raise RuntimeError(f'Failed to receive correct opcode:{opcode}') ws.write(data='Ping', opcode=OPCODE_TEXT) # Wait for server to receive the message and send a ping dut.expect(r'Got packet with message: Ping', timeout=10) @@ -164,11 +178,22 @@ def test_examples_protocol_http_ws_echo_server(dut: Dut) -> None: data = data.decode() if opcode != OPCODE_PING: raise RuntimeError(f'Failed to receive correct opcode:{opcode}') - # Now we should get a pong in response to our ping - opcode, data = ws.read() - data = data.decode() - if opcode != OPCODE_PONG: - raise RuntimeError(f'Failed to receive correct opcode:{opcode}') + # The client library auto-replies PONG to the server's PING. With the + # control-frame handler enabled, the DUT observes that PONG in the + # control handler and does not echo it; otherwise the data handler + # echoes the PONG back to the client. + if control_handler_enabled: + dut.expect('Control frame: PONG, heartbeat alive', timeout=10) + else: + opcode, data = ws.read() + data = data.decode() + if opcode != OPCODE_PONG: + raise RuntimeError(f'Failed to receive correct opcode:{opcode}') + + # Leaving the context closes the client connection: the CLOSE frame must be + # delivered to the control-frame handler (the server still replies CLOSE). + if control_handler_enabled: + dut.expect('Control frame: CLOSE', timeout=10) @pytest.mark.wifi_router