mirror of
https://github.com/espressif/esp-idf.git
synced 2026-10-02 03:00:34 +03:00
feat(esp_http_server): add httpd_ws_close_session() for graceful WS shutdown
This commit is contained in:
@@ -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 *********
|
||||
|
||||
Reference in New Issue
Block a user