#19103·esp-idf

esp_http_server: a queued session close can close a newer connection that reuses the session slot (IDFGH-18300)

Author: igkrEnergusCreated Sep 17, 2026Updated Sep 17, 2026
LabelsType: BugStatus: Opened

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.

  1. 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
  2. Build and start the server for the Linux target. It listens on port 8001:
    bash
    cd httpd_close_race
    idf.py --preview set-target linux
    idf.py build
    ./build/httpd_close_race.elf
  3. In a second shell, run both scenarios. The client is Python 3 with the standard library only:
    python3 client.py 127.0.0.1 8001 websocket 10
    python3 client.py 127.0.0.1 8001 trigger 10
    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 GET / 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: sends GET /bye, closes the connection, and connects again at once.
  4. 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 by closing 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
cmake
# 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.defaults
CONFIG_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=y
main/CMakeLists.txt
cmake
set(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.yml
yaml
dependencies:
  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
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
python
"""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.

plain
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 (