esp_http_server: a queued session close can close a newer connection that reuses the session slot (IDFGH-18300)
Answers checklist.
- I have read the documentation ESP-IDF Programming Guide and the issue is not addressed there.
- I have updated my IDF branch (master or release) to the latest version and checked that the issue is present there.
- I have searched the issue tracker for a similar issue and not found a similar issue.
IDF version.
v6.1 (tag v6.1, commit fff9895, espressif/idf:v6.1 Docker image)
Espressif SoC revision.
ESP32-P4 chip revision v1.3 (ROM esp32p4-eco2-20240710), 32 MB AP HEX PSRAM at 200 MHz, 16 MB flash
Operating System used.
Windows
How did you build your project?
Other (please specify in More Information)
If you are using Windows, please specify command line type.
CMD
Development Kit.
Custom PCB
Power Supply used.
External 3.3V
What is the expected behavior?
A close queued for a session closes that session and nothing else. That covers a close queued with httpd_sess_trigger_close(), by the server itself after a WebSocket CLOSE frame, or by LRU purge. If the session has already ended when the queued close runs, the close does nothing, and a connection accepted in the meantime is served normally.
What is the actual behavior?
The queued close is bound to the session's slot in the session table (struct sock_db *), not to the connection. If the session ends before the close runs, and a new connection is accepted into the freed slot, the queued close closes the new connection instead:
- usually before the new connection's first request is read, so the client gets no response and sees the connection reset or closed;
- otherwise right after the first response, which ends a kept-alive connection.
The new connection can belong to any client.
With the reproducer under "Steps to reproduce" (Linux target, no hardware needed), every attempt fails:
| esp_http_server | httpd_sess_trigger_close() scenario |
WebSocket CLOSE scenario |
|---|---|---|
| v6.1 | 10 of 10 fail | 10 of 10 fail |
| master e4df0c12f7 | 10 of 10 fail | 10 of 10 fail |
We found it on ESP32-P4 with esp_https_server and a WebSocket endpoint. A client that closed its WebSocket and connected again had the new connection reset, and our reconnect test failed in 4 of 6 runs.
Steps to reproduce.
- Create a project directory with the files below:
httpd_close_race/ ├── CMakeLists.txt ├── sdkconfig.defaults ├── client.py └── main/ ├── CMakeLists.txt ├── idf_component.yml └── main.c - Build and start the server for the Linux target. It listens on port 8001:
cd httpd_close_race idf.py --preview set-target linux idf.py build ./build/httpd_close_race.elf - In a second shell, run both scenarios. The client is Python 3 with the standard library only:The client first opens an idle keep-alive connection and holds it for the whole run, as a browser does. Each attempt then ends one connection and sends
python3 client.py 127.0.0.1 8001 websocket 10 python3 client.py 127.0.0.1 8001 trigger 10GET /twice on a new connection:websocket: opens/ws, sends a text frame and a CLOSE frame, waits for the server's CLOSE, closes the connection, and connects again 300 ms later.trigger: sendsGET /bye, closes the connection, and connects again at once.
- The client prints
FAIL: the first GET / on the new connection (client port P) got no answer. In the server log,accepted fd N (client port P)is followed byclosing fd N (client port P)for that port, and the request is never handled.
The busy work items only make the timing deterministic. They stand in for what keeps the server task busy on a device: asynchronous WebSocket sends queued with httpd_queue_work(), or, with esp_https_server, the TLS handshake of a new connection, which runs inside accept() on the server task. The project also builds for SoC targets, where protocol_examples_common sets up Wi-Fi or Ethernet and the server uses port 80.
CMakeLists.txt# The following lines of boilerplate have to be in your project's CMakeLists
# in this exact order for cmake to work correctly
cmake_minimum_required(VERSION 3.22)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
idf_build_set_property(MINIMAL_BUILD ON)
project(httpd_close_race)sdkconfig.defaultsCONFIG_HTTPD_WS_SUPPORT=y
# Debug logs are enabled at runtime for the httpd, httpd_sess and httpd_parse tags only.
CONFIG_LOG_MAXIMUM_LEVEL_DEBUG=ymain/CMakeLists.txtset(requires nvs_flash esp_netif esp_event esp_http_server)
idf_build_get_property(target IDF_TARGET)
if(${target} STREQUAL "linux")
list(APPEND requires esp_stubs protocol_examples_common)
else()
list(APPEND requires esp_wifi esp_eth)
endif()
idf_component_register(SRCS "main.c"
INCLUDE_DIRS "."
PRIV_REQUIRES ${requires})main/idf_component.ymldependencies:
protocol_examples_common:
path: ${IDF_PATH}/examples/common_components/protocol_examples_common
esp_stubs:
path: ${IDF_PATH}/examples/protocols/linux_stubs/esp_stubs
rules:
- if: "target in [linux]"main/main.c/* esp_http_server: a queued session close ends a newer connection in the same slot.
httpd_sess_trigger_close() queues httpd_sess_close() with a pointer to the
session's slot in the session table, not with the connection. If the session
ends another way before the queued close runs, and a new connection is
accepted into the freed slot, the queued close closes the new connection.
Two ways to get there, each with work queued ahead of the close:
- GET /bye: the handler calls httpd_sess_trigger_close() for its own
connection, and the client closes that connection itself.
- /ws: a WebSocket client sends CLOSE. The server queues the close in
httpd_req_cleanup(), and queues it again each time the loop turns while
the client's FIN is pending. The first close frees the slot; a later one
closes the next connection accepted into it.
The busy work items stand in for what keeps the server task busy on a device
- asynchronous WebSocket sends queued with httpd_queue_work(), or with
esp_https_server the TLS handshake of a new connection, which runs inside
accept(). They only make the timing deterministic.
This example code is in the Public Domain (or CC0 licensed, at your option.)
*/
#include <arpa/inet.h>
#include <stdint.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
#include "esp_event.h"
#include "esp_http_server.h"
#include "esp_log.h"
#include "esp_netif.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "nvs_flash.h"
#include "protocol_examples_common.h"
#define BUSY_WORK_MS 200
#define BYE_BUSY_WORK_ITEMS 2
#define WEBSOCKET_BUSY_WORK_ITEMS 3
static const char *TAG = "repro";
static unsigned peer_port(int fd)
{
struct sockaddr_storage addr;
socklen_t len = sizeof(addr);
if (getpeername(fd, (struct sockaddr *)&addr, &len) != 0) {
return 0;
}
if (addr.ss_family == AF_INET6) {
return ntohs(((struct sockaddr_in6 *)&addr)->sin6_port);
}
return ntohs(((struct sockaddr_in *)&addr)->sin_port);
}
static esp_err_t on_open(httpd_handle_t hd, int fd)
{
ESP_LOGI(TAG, "accepted fd %d (client port %u)", fd, peer_port(fd));
return ESP_OK;
}
static void on_close(httpd_handle_t hd, int fd)
{
ESP_LOGI(TAG, "closing fd %d (client port %u)", fd, peer_port(fd));
close(fd);
}
static void busy_work(void *arg)
{
ESP_LOGI(TAG, "busy work item %d: start", (int)(intptr_t)arg);
vTaskDelay(pdMS_TO_TICKS(BUSY_WORK_MS));
ESP_LOGI(TAG, "busy work item %d: end", (int)(intptr_t)arg);
}
static void queue_busy_work(httpd_handle_t hd, int items)
{
for (int i = 1; i <= items; i++) {
httpd_queue_work(hd, busy_work, (void *)(intptr_t)i);
}
}
static esp_err_t hello_handler(httpd_req_t *req)
{
ESP_LOGI(TAG, "GET / answered on fd %d", httpd_req_to_sockfd(req));
return httpd_resp_sendstr(req, "hello\n");
}
static esp_err_t bye_handler(httpd_req_t *req)
{
int fd = httpd_req_to_sockfd(req);
queue_busy_work(req->handle, BYE_BUSY_WORK_ITEMS);
ESP_LOGI(TAG, "GET /bye on fd %d: httpd_sess_trigger_close()", fd);
httpd_sess_trigger_close(req->handle, fd);
return httpd_resp_sendstr(req, "bye\n");
}
static esp_err_t ws_handler(httpd_req_t *req)
{
if (req->method == HTTP_GET) {
ESP_LOGI(TAG, "WebSocket handshake on fd %d", httpd_req_to_sockfd(req));
return ESP_OK;
}
uint8_t payload[32];
httpd_ws_frame_t frame = { .payload = payload };
esp_err_t err = httpd_ws_recv_frame(req, &frame, sizeof(payload));
if (err != ESP_OK) {
return err;
}
if (frame.type == HTTPD_WS_TYPE_TEXT) {
ESP_LOGI(TAG, "text frame on fd %d: queueing busy work", httpd_req_to_sockfd(req));
queue_busy_work(req->handle, WEBSOCKET_BUSY_WORK_ITEMS);
}
return ESP_OK;
}
static const httpd_uri_t uris[] = {
{ .uri = "/", .method = HTTP_GET, .handler = hello_handler },
{ .uri = "/bye", .method = HTTP_GET, .handler = bye_handler },
{ .uri = "/ws", .method = HTTP_GET, .handler = ws_handler, .is_websocket = true },
};
void app_main(void)
{
ESP_ERROR_CHECK(nvs_flash_init());
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
ESP_ERROR_CHECK(example_connect());
esp_log_level_set("httpd", ESP_LOG_DEBUG);
esp_log_level_set("httpd_sess", ESP_LOG_DEBUG);
esp_log_level_set("httpd_parse", ESP_LOG_DEBUG);
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
#if CONFIG_IDF_TARGET_LINUX
/* Port 80 needs a privileged user on Linux. */
config.server_port = 8001;
#endif
config.open_fn = on_open;
config.close_fn = on_close;
httpd_handle_t server = NULL;
ESP_ERROR_CHECK(httpd_start(&server, &config));
for (size_t i = 0; i < sizeof(uris) / sizeof(uris[0]); i++) {
ESP_ERROR_CHECK(httpd_register_uri_handler(server, &uris[i]));
}
ESP_LOGI(TAG, "listening on port %d", config.server_port);
while (true) {
vTaskDelay(pdMS_TO_TICKS(1000));
}
}client.py"""Client for the httpd_close_race reproducer. Python 3 standard library only.
A browser keeps other connections open, so an idle keep-alive connection is
opened first and held for the whole run. Each attempt then ends one connection
and sends GET / twice on a new one, which should always be answered:
trigger GET /bye (the server calls httpd_sess_trigger_close() for that
connection), close it, and connect again at once.
websocket open /ws, send a text frame (the server queues work) and CLOSE,
wait for the server's CLOSE, close the connection, and connect
again RECONNECT_DELAY_S later - as a reloading page does.
usage: python3 client.py HOST PORT trigger|websocket [ATTEMPTS]
"""
import base64
import os
import socket
import struct
import sys
import time
host, port, mode = sys.argv[1], int(sys.argv[2]), sys.argv[3]
attempts = int(sys.argv[4]) if len(sys.argv) > 4 else 10
# Lands the new connection while the last queued work item before the first
# close runs, given the server's 200 ms work items.
RECONNECT_DELAY_S = 0.3
def read_http_response(connection: socket.socket) -> bytes:
data = b""
while b"\r\n\r\n" not in data:
chunk = connection.recv(1024)
if not chunk:
return data
data += chunk
head, _, body = data.partition(b"\r\n\r\n")
length = next(
(int(line.split(b":", 1)[1]) for line in head.split(b"\r\n") if line.lower().startswith(b"content-length:")),
0,
)
while len(body) < length:
chunk = connection.recv(1024)
if not chunk:
break
body += chunk
return head + b"\r\n\r\n" + body
def get(connection: socket.socket, path: str) -> bytes:
connection.sendall(f"GET {path} HTTP/1.1\r\nHost: {host}:{port}\r\n\r\n".encode())
return read_http_response(connection)
def masked_frame(opcode: int, payload: bytes) -> bytes:
mask = os.urandom(4)
body = bytes(byte ^ mask[index % 4] for index, byte in enumerate(payload))
return struct.pack("!BB", 0x80 | opcode, 0x80 | len(payload)) + mask + body
def end_with_trigger_close() -> None:
connection = socket.create_connection((host, port), timeout=5)
get(connection, "/bye")
connection.close()
def end_with_websocket_close() -> None:
connection = socket.create_connection((host, port), timeout=5)
key = base64.b64encode(os.urandom(16)).decode()
connection.sendall(
(
f"GET /ws HTTP/1.1\r\nHost: {host}:{port}\r\nUpgrade: websocket\r\n"
f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n"
).encode()
)
if b" 101 " not in read_http_response(connection).split(b"\r\n", 1)[0]:
raise ConnectionError("WebSocket handshake refused")
connection.sendall(masked_frame(0x1, b"busy") + masked_frame(0x8, struct.pack("!H", 1000)))
received = b""
while not received.startswith(b"\x88"):
chunk = connection.recv(1024)
if not chunk:
break
received += chunk
connection.close()
time.sleep(RECONNECT_DELAY_S)
def attempt() -> str:
if mode == "trigger":
end_with_trigger_close()
else:
end_with_websocket_close()
# Two requests on the new connection: the stray close can also land just
# after the first answer, which then ends a kept-alive connection.
connection = socket.create_connection((host, port), timeout=5)
client_port = connection.getsockname()[1]
try:
for request in ("first", "second"):
try:
reply = get(connection, "/")
except (ConnectionResetError, BrokenPipeError):
reply = b""
if not reply.startswith(b"HTTP/1.1 200"):
return f"FAIL: the {request} GET / on the new connection (client port {client_port}) got no answer"
finally:
connection.close()
return f"ok: both GET / on the new connection (client port {client_port}) answered"
idle = socket.create_connection((host, port), timeout=5)
get(idle, "/")
print(f"idle keep-alive connection open from client port {idle.getsockname()[1]}", flush=True)
failures = 0
for number in range(1, attempts + 1):
outcome = attempt()
failures += outcome.startswith("FAIL")
print(f"{mode} attempt {number}: {outcome}", flush=True)
time.sleep(1.5)
idle.close()
print(f"{mode}: {failures} of {attempts} attempts failed")Debug Logs.
Client, websocket scenario, unmodified ESP-IDF v6.1:
websocket attempt 1: FAIL: the first GET / on the new connection (client port 40624) got no answer
Server, same attempt (fd 7 is the client's idle keep-alive connection; "<--" notes added):
D (90639132) httpd: httpd_server: processing listen socket 4
D (90639132) httpd: httpd_accept_conn: newfd = 8
D (90639132) httpd_sess: httpd_sess_new: fd = 8
I (90639132) repro: accepted fd 8 (client port 40608) <-- the WebSocket connection
D (90639132) httpd_sess: httpd_sess_new: active sockets: 2
D (90639132) httpd: httpd_accept_conn: complete
D (90639132) httpd: httpd_server: doing select maxfd+1 = 9
D (90639132) httpd: httpd_process_session: processing socket 8
D (90639132) httpd_sess: httpd_sess_process: httpd_req_new
D (90639132) httpd_parse: httpd_req_new: New request, has WS? No, sd->ws_handler valid? No, sd->ws_close? No
[... 31 httpd_parse lines parsing the WebSocket handshake ...]
D (90639133) httpd_sess: httpd_sess_process: httpd_req_delete
D (90639133) httpd_sess: httpd_sess_process: success
D (90639133) httpd: httpd_server: doing select maxfd+1 = 9
D (90639232) httpd: httpd_process_session: processing socket 8
D (90639232) httpd_sess: httpd_sess_process: httpd_req_new
D (90639232) httpd_parse: httpd_req_new: New request, has WS? Yes, sd->ws_handler valid? Yes, sd->ws_close? No
D (90639232) httpd_parse: httpd_req_new: New WS request from existing socket, ws_type=1
I (90639232) repro: text frame on fd 8: queueing busy work <-- 3 work items queued
D (90639232) httpd_sess: httpd_sess_process: httpd_req_delete
D (90639232) httpd_sess: httpd_sess_process: success
D (90639232) httpd: httpd_server: doing select maxfd+1 = 9
D (90639232) httpd: httpd_server: processing ctrl message
D (90639232) httpd: httpd_process_ctrl_msg: work
I (90639232) repro: busy work item 1: start
I (90639432) repro: busy work item 1: end
D (90639432) httpd: httpd_process_session: processing socket 8
D (90639432) httpd_sess: httpd_sess_process: httpd_req_new
D (90639432) httpd_parse: httpd_req_new: New request, has WS? Yes, sd->ws_handler valid? Yes, sd->ws_close? No
D (90639432) httpd_parse: httpd_req_new: New WS request from existing socket, ws_type=8 <-- CLOSE frame
D (90639432) httpd_sess: httpd_sess_process: httpd_req_delete
D (90639432) httpd_parse: httpd_req_cleanup: Try closing WS connection at FD: 8 <-- close #1 queued, 2 work items ahead
D (90639432) httpd_sess: httpd_sess_process: success
D (90639432) httpd: httpd_server: doing select maxfd+1 = 9
D (90639432) httpd: httpd_server: processing ctrl message
D (90639432) httpd: httpd_process_ctrl_msg: work
I (90639432) repro: busy work item 2: start
I (90639632) repro: busy work item 2: end
D (90639632) httpd: httpd_server: doing select maxfd+1 = 9
D (90639632) httpd: httpd_server: processing ctrl message
D (90639632) httpd: httpd_process_ctrl_msg: work
I (90639632) repro: busy work item 3: start
I (90639832) repro: busy work item 3: end
D (90639832) httpd: httpd_process_session: processing socket 8 <-- client's FIN pending
D (90639832) httpd_sess: httpd_sess_process: httpd_req_new
D (90639832) httpd_parse: httpd_req_new: New request, has WS? Yes, sd->ws_handler valid? Yes, sd->ws_close? Yes
D (90639832) httpd_parse: httpd_req_new: WS was marked close
D (90639832) httpd_sess: httpd_sess_process: httpd_req_delete
D (90639832) httpd_parse: httpd_req_cleanup: Try closing WS connection at FD: 8 <-- close #2 queued for the same slot
D (90639832) httpd_sess: httpd_sess_process: success
D (90639832) httpd: httpd_server: doing select maxfd+1 = 9
D (90639832) httpd: httpd_server: processing ctrl message
D (90639832) httpd: httpd_process_ctrl_msg: work <-- close #1
D (90639832) httpd_sess: httpd_sess_delete: fd = 8
I (90639832) repro: closing fd 8 (client port 40608) <-- the WebSocket connection: correct
D (90639832) httpd_sess: httpd_sess_delete: active sockets: 1
D (90639832) httpd: httpd_server: processing listen socket 4
D (90639832) httpd: httpd_accept_conn: newfd = 8
D (90639832) httpd_sess: httpd_sess_new: fd = 8
I (90639832) repro: accepted fd 8 (client port 40624) <-- next connection, same slot
D (90639832) httpd_sess: httpd_sess_new: active sockets: 2
D (90639832) httpd: httpd_accept_conn: complete
D (90639832) httpd: httpd_server: doing select maxfd+1 = 9
D (90639832) httpd: httpd_server: processing ctrl message
D (90639832) httpd: httpd_process_ctrl_msg: work <-- close #2
D (90639832) httpd_sess: httpd_sess_delete: fd = 8
I (90639832) repro: closing fd 8 (client port 40624) <-- the new connection, request never read
D (Source: espressif/esp-idf