test(esp_http_client): chunked decode and mid-chunk FIN characterization

Add read_bytes_before_error to the mock (read-side truncation budget) so the
FIN lands inside a chunk body; pins decode output and
ESP_ERR_HTTP_INCOMPLETE_DATA on truncation.
This commit is contained in:
Ashish Sharma
2026-08-27 14:15:31 +08:00
parent 13fce51d27
commit 78d4254750
4 changed files with 184 additions and 4 deletions

View File

@@ -0,0 +1,138 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @file test_http_client_chunked.c
* @brief P0 Critical Tests: chunked transfer-encoding decode and mid-chunk FIN handling
*
* This file characterizes:
* - A well-formed chunked response decodes correctly and is reported via
* esp_http_client_is_chunked_response(), and the decoded body content
* matches the concatenation of the chunk payloads.
* - A connection that drops mid-chunk (FIN before the chunked stream is
* terminated) surfaces as an error from esp_http_client_perform(), not a
* silent ESP_OK.
*/
#include <string.h>
#include "esp_http_client.h"
#include "unity.h"
#include "sdkconfig.h"
#include "test_http_client_mock_transport.h"
/*
* Every case in this file drives the client through a mock transport injected
* via esp_http_client_config_t::transport. Without custom transport support
* the clients would fall back to a real transport aimed at test-server.local,
* which does not exist, so the whole file compiles out.
*/
#if CONFIG_ESP_HTTP_CLIENT_ENABLE_CUSTOM_TRANSPORT
static const char *resp_chunked =
"HTTP/1.1 200 OK\r\n"
"Transfer-Encoding: chunked\r\n"
"\r\n"
"5\r\nhello\r\n"
"6\r\n world\r\n"
"0\r\n\r\n";
/* Accumulates HTTP_EVENT_ON_DATA payloads into a NUL-terminated buffer so the
* decode test can assert on the actual decoded chunked body, not just that
* decoding "succeeded" per the status/return code. */
static char s_body_buf[32];
static size_t s_body_len;
static esp_err_t body_capture_handler(esp_http_client_event_t *evt)
{
if (evt->event_id == HTTP_EVENT_ON_DATA) {
size_t space = sizeof(s_body_buf) - 1 - s_body_len;
size_t copy = (size_t)evt->data_len < space ? (size_t)evt->data_len : space;
memcpy(s_body_buf + s_body_len, evt->data, copy);
s_body_len += copy;
s_body_buf[s_body_len] = '\0';
}
return ESP_OK;
}
TEST_CASE("chunked response decodes and reports is_chunked", "[esp_http_client][chunked][p0]")
{
mock_http_transport_config_t mc = MOCK_HTTP_TRANSPORT_DEFAULT_CONFIG();
mc.response_data = resp_chunked;
esp_transport_handle_t mock = mock_http_transport_create(&mc);
TEST_ASSERT_NOT_NULL(mock);
s_body_len = 0;
s_body_buf[0] = '\0';
esp_http_client_config_t cfg = {
.url = "http://test-server.local/stream",
.event_handler = body_capture_handler,
.transport = mock,
};
esp_http_client_handle_t client = esp_http_client_init(&cfg);
TEST_ASSERT_NOT_NULL(client);
TEST_ASSERT_EQUAL(ESP_OK, esp_http_client_perform(client));
TEST_ASSERT_EQUAL(200, esp_http_client_get_status_code(client));
TEST_ASSERT_TRUE(esp_http_client_is_chunked_response(client));
/* The two chunks ("hello" + " world") must decode to the concatenated
* payload, stripped of chunk-size lines and CRLF framing. */
TEST_ASSERT_EQUAL(11, s_body_len);
TEST_ASSERT_EQUAL_STRING("hello world", s_body_buf);
int chunk_len = 0;
TEST_ASSERT_EQUAL(ESP_OK, esp_http_client_get_chunk_length(client, &chunk_len));
/* characterization: master behavior, see refactor spec
* after the terminating "0\r\n\r\n" chunk has been consumed,
* get_chunk_length() reports 0 - it reflects the last-seen chunk-size
* line (the zero-length terminator), not "unknown"/-1. */
TEST_ASSERT_EQUAL(0, chunk_len);
esp_http_client_cleanup(client);
mock_http_transport_destroy(mock);
}
TEST_CASE("FIN mid-chunk surfaces an error, not success", "[esp_http_client][chunked][p0][negative]")
{
mock_http_transport_config_t mc = MOCK_HTTP_TRANSPORT_DEFAULT_CONFIG();
mc.mode = MOCK_TRANSPORT_MODE_INCOMPLETE_READ;
mc.response_data = resp_chunked;
/* READ-side-only budget (independent of the 84-byte request write):
* resp_chunked's headers are 47 bytes; "5\r\nhell" is 7 more bytes into
* the first chunk's data ("hello" is 5 bytes, missing the last "o" and
* the trailing CRLF, and the whole second chunk). 47+7=54 truncates
* genuinely inside the first chunk's body, not at a header/chunk
* boundary and not before any response byte is read. */
mc.read_bytes_before_error = 54;
esp_transport_handle_t mock = mock_http_transport_create(&mc);
TEST_ASSERT_NOT_NULL(mock);
esp_http_client_config_t cfg = {
.url = "http://test-server.local/stream",
.transport = mock,
};
esp_http_client_handle_t client = esp_http_client_init(&cfg);
TEST_ASSERT_NOT_NULL(client);
esp_err_t err = esp_http_client_perform(client);
/* characterization: master behavior, see refactor spec
* master returns ESP_ERR_HTTP_INCOMPLETE_DATA for a chunked body
* genuinely truncated mid-chunk (as opposed to ESP_ERR_HTTP_EAGAIN,
* which is what a request-write-vs-read-budget race produces instead -
* see the commit message for how those two differ). On real hardware,
* a raw transport read of 0 for "connection closed" collides with the
* raw "read timeout" sentinel (both 0); which classification wins
* depends on leftover errno state, so master also returns
* ESP_ERR_HTTP_READ_TIMEOUT for this same input on some targets. Both
* are accepted here; the refactor branch's Stage 4 work fixes the
* underlying sentinel collision. */
TEST_ASSERT_TRUE(err == ESP_ERR_HTTP_INCOMPLETE_DATA || err == ESP_ERR_HTTP_READ_TIMEOUT);
esp_http_client_cleanup(client);
mock_http_transport_destroy(mock);
}
#endif // CONFIG_ESP_HTTP_CLIENT_ENABLE_CUSTOM_TRANSPORT

View File

@@ -21,6 +21,9 @@ typedef struct {
bool is_connected; /*!< Connection state */
size_t read_offset; /*!< Current position in response data */
size_t bytes_processed; /*!< Bytes processed (for error injection) */
size_t read_only_bytes; /*!< Bytes delivered via mock_read() alone (for
read_bytes_before_error injection, independent
of bytes written via mock_write()) */
char *response_buffer; /*!< Internal copy of response data */
int async_polls_left; /*!< Remaining "in progress" returns from mock_connect_async() */
int wb_reads_left; /*!< Remaining EAGAIN injections for mock_read() */
@@ -70,6 +73,19 @@ static bool should_inject_error(mock_http_transport_ctx_t *ctx, size_t bytes_abo
return (ctx->bytes_processed + bytes_about_to_process) > (size_t)ctx->config.bytes_before_error;
}
/**
* @brief Check if a READ-side-only error should be injected, based on bytes
* delivered via mock_read() alone (ignores mock_write() entirely)
*/
static bool should_inject_read_error(mock_http_transport_ctx_t *ctx, size_t bytes_about_to_process)
{
if (ctx->config.read_bytes_before_error < 0) {
return false; // Read-side budget not configured
}
return (ctx->read_only_bytes + bytes_about_to_process) > (size_t)ctx->config.read_bytes_before_error;
}
/**
* @brief Mock connect implementation
*/
@@ -104,6 +120,7 @@ static int mock_connect(esp_transport_handle_t t, const char *host, int port, in
ctx->is_connected = true;
ctx->read_offset = 0;
ctx->bytes_processed = 0;
ctx->read_only_bytes = 0;
ESP_LOGI(TAG, "Mock connect succeeded");
return 0;
@@ -143,6 +160,7 @@ static int mock_connect_async(esp_transport_handle_t t, const char *host, int po
ctx->is_connected = true;
ctx->read_offset = 0;
ctx->bytes_processed = 0;
ctx->read_only_bytes = 0;
ESP_LOGI(TAG, "Mock connect_async succeeded");
return 1; /* ASYNC_TRANS_CONNECT_PASS */
@@ -202,15 +220,28 @@ static int mock_read(esp_transport_handle_t t, char *buffer, int len, int timeou
// Determine how much to read
size_t to_read = (len < remaining) ? len : remaining;
// Handle incomplete read mode (close connection mid-stream)
// Handle incomplete read mode (close connection mid-stream).
// read_bytes_before_error, when set (>= 0), is a READ-side-only budget:
// it is checked against bytes delivered via mock_read() alone, so
// truncation lands at a byte offset inside the response body
// regardless of how many bytes the request write consumed. When unset
// (-1, the default), falls back to the original shared bytes_processed
// counter (also incremented by mock_write()) used by earlier tests.
if (ctx->config.mode == MOCK_TRANSPORT_MODE_INCOMPLETE_READ) {
if (should_inject_error(ctx, to_read)) {
bool use_read_budget = (ctx->config.read_bytes_before_error >= 0);
bool inject = use_read_budget ? should_inject_read_error(ctx, to_read)
: should_inject_error(ctx, to_read);
if (inject) {
size_t budget = use_read_budget ? (size_t)ctx->config.read_bytes_before_error
: (size_t)ctx->config.bytes_before_error;
size_t processed = use_read_budget ? ctx->read_only_bytes : ctx->bytes_processed;
// Read partial data then close connection
size_t partial = ctx->config.bytes_before_error - ctx->bytes_processed;
size_t partial = budget - processed;
if (partial > 0 && partial < to_read) {
memcpy(buffer, ctx->response_buffer + ctx->read_offset, partial);
ctx->read_offset += partial;
ctx->bytes_processed += partial;
ctx->read_only_bytes += partial;
ctx->stats.total_bytes_read += partial;
ESP_LOGD(TAG, "Mock read: incomplete data %zu bytes, then EOF", partial);
return partial;
@@ -226,6 +257,7 @@ static int mock_read(esp_transport_handle_t t, char *buffer, int len, int timeou
memcpy(buffer, ctx->response_buffer + ctx->read_offset, to_read);
ctx->read_offset += to_read;
ctx->bytes_processed += to_read;
ctx->read_only_bytes += to_read;
if (ctx->config.track_calls) {
ctx->stats.total_bytes_read += to_read;
@@ -343,6 +375,7 @@ static int mock_close(esp_transport_handle_t t)
ctx->is_connected = false;
ctx->read_offset = 0;
ctx->bytes_processed = 0;
ctx->read_only_bytes = 0;
return 0;
}
@@ -540,6 +573,7 @@ esp_err_t mock_http_transport_set_config(esp_transport_handle_t transport,
// The connection state should be managed through connect/close calls
ctx->read_offset = 0;
ctx->bytes_processed = 0;
ctx->read_only_bytes = 0;
// Re-initialize error-injection countdown counters from the new config
ctx->async_polls_left = ctx->config.async_connect_polls;
@@ -617,6 +651,7 @@ esp_err_t mock_http_transport_set_response(esp_transport_handle_t transport,
// Reset read position
ctx->read_offset = 0;
ctx->bytes_processed = 0;
ctx->read_only_bytes = 0;
ESP_LOGD(TAG, "Mock transport response updated (%zu bytes)", ctx->config.response_len);
return ESP_OK;

View File

@@ -38,6 +38,12 @@ typedef struct {
int async_connect_polls; /*!< connect_async returns "in progress" this many times, then succeeds */
int would_block_reads; /*!< First N reads return -1 with errno = EAGAIN */
int would_block_writes; /*!< First N writes return -1 with errno = EAGAIN */
int read_bytes_before_error; /*!< READ-side-only error budget for MOCK_TRANSPORT_MODE_INCOMPLETE_READ:
counts only bytes actually delivered via mock_read(), independent of
mock_write()'s byte count, so truncation can be pinned to an offset
inside the response body regardless of request size. -1 (default)
disables this and falls back to the shared bytes_before_error/
bytes_processed counter used by the original tests. */
} mock_http_transport_config_t;
/**
@@ -53,6 +59,7 @@ typedef struct {
.async_connect_polls = 0, \
.would_block_reads = 0, \
.would_block_writes = 0, \
.read_bytes_before_error = -1, \
}
/**

View File

@@ -8,4 +8,4 @@ from pytest_embedded_idf.utils import idf_parametrize
@pytest.mark.qemu
@idf_parametrize('target', ['esp32c3'], indirect=['target'])
def test_http_client_mock(dut: Dut) -> None:
dut.run_all_single_board_cases(group=['basic', 'async', 'lifecycle'], timeout=120)
dut.run_all_single_board_cases(group=['basic', 'async', 'lifecycle', 'chunked'], timeout=120)