diff --git a/docs/agent/architecture.md b/docs/agent/architecture.md index e5000fd..5fa8bea 100644 --- a/docs/agent/architecture.md +++ b/docs/agent/architecture.md @@ -112,6 +112,8 @@ A WebSocket connection requires a one-time, principal-bound ticket with a maximu `web_diagnostics` observes public synchronous HTTPS create/close callbacks without replacing socket/transport cleanup, and wraps only the four serial/admin ticket/upgrade handlers. Six always-maintained post-TLS metadata records supply a console-safe occupancy snapshot without querying HTTPD off-owner; an opt-in 32-entry numeric ring adds timing/heap/HTTPD stack samples. Firmware-lifetime connection sequences and capture epochs survive clear/restart and fence fd reuse/toggle races. No tasks/probes/event subscriptions; no authentication/request data retained. UART0/admin SSH commands never wait for HTTPD; browser policy remains unchanged. This is successful-TLS occupancy, not preaccept or failed/in-progress handshake instrumentation; exact limits/overhead in `docs/phase8d11_implementation.md`. +Ordinary HTTPS idle retention is independently enforced by `web_httpd_idle`: one persistent one-second ESP timer, at most one generation-qualified HTTPD work reservation and six owner-only rows. The private adapter observes IDF's all-route successful `req_new`/`req_delete` completion marker, checks actual WS/async flags and pending/readable input, and shuts down only the current expired ordinary fd after 15 seconds of observed idle. TLS-create resets reused-fd observations before diagnostic publication. No TLS cleanup override, LRU eviction, in-progress request interruption or diagnostics dependency. Stop fences submissions before destroying HTTPD; only successful stop retires queued state. Slow owner work and accepted-but-lost nonblocking UDP work preclude a hard wall-clock guarantee; loss stays bounded until successful restart. Timer preparation failure gates HTTPS start. SDK audit, tests and exact limits: `docs/https_idle_cleanup.md`. + Web serial initialization is failure-isolated from the base HTTPS service: if the transport cannot initialize, `web_server_init()` can still succeed and serve authenticated non-WebSocket routes. `web_ui.c` contains authored index/application strings and response policy; it validates `/api/session` before connect/restore, adds explicit Sign out, and cancels stale work on 401/logout/page exit. `web_login_ui` is a standalone public page without protected-asset dependencies. Both authentication documents and app script are no-store. Its restrictive CSP contains a hard-coded hash of the inline loader, so those two must change atomically; preserve same-origin connections, no-referrer behavior, frame denial, and the existing cache policy. `web_assets_data.c` contains checked-in generated arrays for vendored compressed xterm assets and the logo. Normal builds compile these arrays directly; they do not regenerate assets. diff --git a/docs/agent/code-map.md b/docs/agent/code-map.md index 78bcff9..5c9314f 100644 --- a/docs/agent/code-map.md +++ b/docs/agent/code-map.md @@ -63,6 +63,7 @@ This is a semantic map, not a complete file inventory. Start here, then read the **Responsibility:** serve authenticated HTTPS UI/API, issue WebSocket tickets, and adapt browser serial sessions to broker clients. - Files: `src/web_server.{h,c}`, `src/web_serial_transport.{h,c}`, `src/web_ui.{h,c}`, `src/web_console.{h,c}` +- Ordinary HTTPS idle cleanup: `src/web_httpd_idle.{c,h}`, owner sweep in `web_httpd_adapter.{c,h}`, lifecycle/TLS composition in `web_server.c`; `tests/web_httpd_idle/run.py`. Independent of diagnostics/optional transports: 15-second observed idle, one-second timer/one queued probe, six rows, actual WS/async/pending exemptions, safe current-owner shutdown and stop/restart fencing. No LRU/socket/timeout/stack increase. SDK queue/owner-delay limits and target checklist: `docs/https_idle_cleanup.md`. - Opt-in admission diagnostics: `src/web_diagnostics.{c,h}`, `tests/web_diagnostics/run.py`. Public synchronous HTTPS create/close callbacks publish six post-TLS connection records; four ticket/upgrade wrappers feed a 32-entry numeric ring. UART0/admin SSH `web diagnostics enable|disable|show|clear`; no queue/task/cleanup override or capacity change. Full bounds, SDK semantics and preaccept/TLS blind spots: `docs/phase8d11_implementation.md`. - Security files: `src/web_security.{h,c}`, `src/web_cookie_auth.{h,c}`, `src/web_session_store.{h,c}`, `src/web_auth_parse.{h,c}`. Private IDF boundary: `src/web_httpd_adapter.{h,c}`. - Asset files: authored/generated boundary in `src/web_assets_data.{h,c}`, `web_assets/SOURCES.md`, `web_assets/generate_embedded_assets.py` diff --git a/docs/agent/current-state.md b/docs/agent/current-state.md index 04c6a11..1bb49ac 100644 --- a/docs/agent/current-state.md +++ b/docs/agent/current-state.md @@ -4,6 +4,8 @@ This file is working memory. Update it during active work and before handoff; do ## Development state +- **User-authorized bounded ordinary HTTPS idle cleanup complete (2026-09-08), host-tested/build-verified; target pending:** Preserved existing 8D.11 key/diagnostic implementation (initial Git status was clean). User capture: post-TLS occupancy 6/6, ordinary4/serial2/admin0, ordinary connection ages 50–74 s; admin ticket returned in 14 ms, no TLS/upgrade observed and ticket unconsumed. Installed IDF stops selecting listener at capacity with LRU disabled. `web_httpd_idle.{c,h}` plus sole-private-boundary sweep in `web_httpd_adapter`: six owner-only rows, 15-second observed-idle window (three five-second status polls), one-second ESP timer/at most one queued owner probe, current fd shutdown (no reusable-pointer queued close), all-route SDK request-plus-purge completion marker and input readiness/WS/async checks, TLS-create fd reset, submit fence/nonwrapping restart generations. Diagnostics does not gate policy; socket/timeout/LRU/task/stack/asset settings unchanged. Implementation `pio run` PASS 57.55 s, final confirmation PASS 3.13 s; **99,316 B RAM / 1,708,981 B flash (+160/+1,384 versus diagnostic baseline)**. Rows144 B/module static167 B before placement; timer32 B internal heap before overhead; sweep local frame80 B, runtime margins unmeasured. Final idle **18 + SDK guards**, server **18**, diagnostics **12+1** PASS; cookie accounts/admin/settings/serial-settings, admin transport25/tickets12, store/serial, UI68+CSP and canonical console/account/key/lifecycle/policy suites PASS; diff check PASS. Cookie test-double missing `` fixed and all variants rerun. Exact contracts/audit/tests/resources/target checklist: `docs/https_idle_cleanup.md`. Ordinary ages are connection ages, not proved idle duration. No hard wall-clock/admission guarantee: owner-blocking handshake/parser/send delays cleanup; continuously active ordinary sockets are not evicted. Reported queue errors retry, but accepted-and-lost nonblocking UDP work stays one reserved probe until successful stop/restart (tested), rather than accumulating unsafe delayed work. Failed stop remains detached/owned until retry. No hardware/upload/erase/commit, target acceptance or reserve approval; next is the documented target reproduction/soak, not capacity changes. + - **Authorized 8D.11 admission diagnostic slice complete (2026-09-08), host-tested/build-verified; target pending:** `web_diagnostics.{c,h}` uses public synchronous HTTPS create/close callbacks and four ticket/upgrade wrappers, six fixed metadata slots and a 32-record opt-in numeric ring. Console `web diagnostics enable|disable|show|clear` via UART0/admin SSH; browser policy unchanged. No close/open override, queue probe/task, capacity/timeout/log-level/assets/SDK change. Post-TLS owner-published occupancy only; TLS failures/in-progress handshakes and preaccept backlog remain invisible. Final `pio run` PASS **10.30 s, 99,156 B RAM / 1,707,597 B flash**, **+3,080/+3,912 B** versus recorded key-slice baseline. Ring/table target symbols 2,816/192 B; diagnostic handler/record/TLS callback/show local frames 144/144/160/528 B excluding callees, runtime margins unmeasured. PASS diagnostic **12+1**, server lifecycle **16**, canonical console lifecycle/policy/boundary, canonical account/key transactions, cookie/admin and cookie/accounts, admin transport **25**/tickets **12**, browser **68 plus renderer/CSP**, store/serial and diff check. Exact files/commands/contracts and reproduction checklist in `docs/phase8d11_implementation.md`. Existing key work preserved; no device/commit/upload/erase, timeout diagnosis, target acceptance or reserve approval claimed. Next is user target capture, not capacity changes or 8D.12. - **8D.11 partial target evidence (2026-09-08):** User supplied 60-second fresh-boot/full-mix telemetry; no functional/sign-off claim. Full mix eventually established (two SSH public-key sessions, SSH writer + USB/two web observers, both admins), but last browser admission again required retries. Three TLS errors `-0x0050` verified against installed mbedTLS as NET_CONN_RESET, not allocation failure; cause unknown. Loaded internal/DMA free 32,596/24,840 B, lifetime minima 5,468/532 B, largest 22,528 B; per-region minima are conservative/non-simultaneous. Settled free is similar to prior 8D.10, minima much lower; no causal regression attribution. SSH stack minimum-free 18,468 boot/16,276 loaded B. Web send failure/close 1, expired tickets 3; SSH handshake/auth failures zero; no broker drop counters. Exact memory/counters/evidence limits in `docs/phase8d11_implementation.md`. Previously accepted admission issue recurred, not fixed; investigate timing/socket occupancy/allocation correlation before capacity changes. Key algorithms/new UI provenance, mutation checklist, HTTPD/dispatcher margins, cleanup/soak and target sign-off remain pending. Documentation-only update; no source/build/device action. diff --git a/docs/agent/design-decisions.md b/docs/agent/design-decisions.md index af07415..8d09c5c 100644 --- a/docs/agent/design-decisions.md +++ b/docs/agent/design-decisions.md @@ -128,6 +128,8 @@ The following first-slice exclusions are historical and superseded by slice 2: **HTTPD boundary:** `web_httpd_adapter` alone includes private ESP-IDF 5.5.0 structures. Public request getters expose only the first field, so the adapter validates bounded parsed headers/rejects duplicates. `/ws/serial` is an ordinary GET until authenticated transport admission explicitly sends 101 and installs the frame handler; automatic HTTPD upgrades happen before URI handlers. Preserve right-aligned unread pending bytes when wiping request memory. Two Set-Cookie calls append pointer-backed fields, whose distinct buffers must survive through send. HTTPD logs above ERROR are compiled out to prevent header/ticket exposure. +**Ordinary HTTPS idle lifecycle:** Keep six sockets and LRU disabled. `web_httpd_idle` queues at most one owner sweep each second; `web_httpd_adapter` alone reads the installed successful request-plus-purge completion marker. Fifteen seconds of observed ordinary idle (three normal five-second browser polls), current SDK WS/async exemption, pending/readable-input checks and synchronous TLS-create fd invalidation authorize current-owner `shutdown`, never queued `sock_db *` close. Do not use response events, diagnostic wrappers or connection age as the completion/idle boundary. All ordinary response work must finish synchronously or retain the SDK async exemption. Submission fencing precedes HTTPD stop; only successful stop retires queued state and admits a nonreused generation. No tracing dependency, forced per-response close, hard request deadline, arbitrary admission eviction or capacity increase. A reported queue failure retries; accepted-but-lost UDP work stays reserved until successful restart rather than accumulating potentially delayed probes. Exact safety/liveness limits and target checklist: `docs/https_idle_cleanup.md`. + **Consequence:** The version guard is not a source-hash guarantee. Re-audit layout, scratch/pending ownership, logging and handshake/frame dispatch on SDK changes; do not scatter private accesses through application code or assume host doubles prove real socket behavior. No SDK patch is currently applied. See `docs/phase8d3_implementation.md` for verification and target gates. **Relevant files:** `src/web_cookie_auth.{c,h}`, `src/web_session_store.{c,h}`, `src/web_httpd_adapter.{c,h}`, `src/web_server.c`, `src/web_serial_transport.c`, `src/CMakeLists.txt`. diff --git a/docs/https_idle_cleanup.md b/docs/https_idle_cleanup.md new file mode 100644 index 0000000..8013961 --- /dev/null +++ b/docs/https_idle_cleanup.md @@ -0,0 +1,88 @@ +# Bounded ordinary HTTPS idle retention + +Implemented 2026-09-08 on the existing 8D.11 key/diagnostic implementation. Host-tested and firmware-build verified; **not target validated or signed off**. No upload, erase, commit, SDK patch or generated-asset change. + +## Evidence and scope + +User-authorized response to the admission capture: post-TLS occupancy **6/6, ordinary4 / serial2 / admin0**, ordinary connection ages **50–74 seconds**; admin ticket returned in **14 ms**, no subsequent TLS/upgrade observed, ticket unconsumed. These ages measure connection lifetime, not time since the last request. They support investigating retained ordinary sockets, not claiming every captured socket was idle. Installed HTTPD excludes its listening fd from `select` while full with LRU disabled. New connections can therefore wait before TLS or upgrade even when ticket issuance was fast. + +This policy releases **expired idle ordinary HTTPS connections**, not arbitrary ordinary requests to make room. Six sockets, 24 URI handlers, LRU disabled, existing receive/send timeouts (one second), TLS handshake timeout (five seconds), all task stacks/queue depths and browser code remain unchanged. No new task. UART0 recovery, independent USB access, serial broker ownership, tickets, authentication and both WebSocket transports are unchanged. + +## Usage and timeout semantics + +- Automatically enabled for every successfully started HTTPS server, including when diagnostic tracing is disabled and either optional transport is unavailable. No new console command, setting or credential migration. +- **15 seconds of observed ordinary idle retention**, checked by a **one-second ESP timer** that requests work on HTTPD. The three existing five-second browser status-poll intervals leave room for normal keepalive reuse instead of a TLS handshake per request. +- A new post-TLS connection that has sent no request gets the same full idle window. Time spent establishing TLS does not consume it. +- The first owner sweep observing a new connection or a changed successful-request completion marker starts a fresh window. The marker covers every ordinary route, including login, assets, status, typed settings, tickets and keepalive errors that return successfully—not just diagnostic wrappers. Failed requests are deleted by the normal SDK path instead. +- HTTPD pending bytes, TLS pending bytes or a readable TCP fd reset the observation window. Negative TLS pending results and `select` errors conservatively reset it too. No bytes are read or discarded by the probe. +- At the threshold, after current SDK classification and zero-time readiness checks, HTTPD calls `shutdown(current_fd, SHUT_RDWR)`. A successful shutdown is latched; a failed call retries on the next probe. The SDK's subsequent read/delete path owns socket close, TLS destruction, diagnostic close notification and freeing the slot. The probe never calls `close`, overrides TLS cleanup, or queues a session-close pointer. +- **WebSockets (serial and admin), closing WebSockets and async requests are exempt.** The check uses actual SDK flags, not diagnostic metadata. Successful explicit 101/classification and request cleanup finish before the owner can sweep, so there is no ordinary-idle interval during upgrade admission. +- Under an available owner and timely successful work delivery, expect 15 seconds plus the initial observation delay (nominally up to one second), the next probe delay (nominally up to one second), and SDK read/cleanup latency. This is a conservative sampled idle policy, **not a strict wall-clock timeout or admission SLA**. + +### Deliberate limitations + +1. Parsing an incomplete request, synchronous response sends, leftover-body purge and TLS handshakes serialize on HTTPD. The probe cannot interrupt them. Existing per-read/per-send timeouts remain; a peer trickling input can extend overall processing beyond one timeout. This change does not provide a slowloris deadline or solve TLS/memory/global-socket pressure. +2. Four continuously active ordinary connections plus two WebSockets can still fill all six slots. They are not evicted. Likewise, an admission attempt immediately after fresh saturation can hit the browser's existing 15-second timeout before the conservative window expires; the user may still need to retry. Older idle retained sockets are eligible on the next delivered probe. +3. As with any HTTP keepalive timeout, bytes arriving **after** the last readiness check can race a shutdown. Already executing HTTPD requests/responses are protected; future client intent cannot be predicted. A client may need a new connection. No new application-level mutation retry/replay is added. Response completion here means synchronous HTTPD completion, not proof of peer receipt/TCP acknowledgement. +4. `httpd_queue_work` with `CONFIG_HTTPD_QUEUE_WORK_BLOCKING` disabled uses loopback UDP. A reported queue error releases the reservation and retries next tick. **A successful send is not an execution acknowledgement**: an accepted-but-lost control message leaves one reservation pending, disables further probes, and requires successful HTTPS stop/restart to restore probing. There is no speculative reservation timeout: it could accumulate delayed callbacks and violate the one-probe bound. This is explicitly regression-tested, not hidden behind a hard retention guarantee. HTTPD blockage or queue loss also cannot be repaired by raising sockets/LRU/timeouts here. +5. A failed stop leaves cleanup detached and ownership retained for a later stop retry; it does not restart probes on a partially stopped server. Timer allocation/start failure gates HTTPS start with its error rather than silently starting without the policy. UART0/USB recovery is unaffected. + +## Exact installed SDK audit + +All SDK references below are under `~/.platformio/packages/framework-espidf/components/`; installed framework is ESP-IDF **5.5.0** (`3.50500.0`). Production private access remains solely in `src/web_httpd_adapter.c`, with the existing compile-time version guard extended to require idle-lifecycle re-audit. + +- `esp_http_server/src/httpd_main.c`: `httpd_server` selects the listener only with capacity or LRU enabled. Control work runs **before** current data sessions, then accept. `httpd_process_session` skips async sessions and synchronously runs `httpd_sess_process`; errors delete the session before any subsequent owner work. Accept invokes TLS synchronously through `open_fn`. +- `esp_http_server/src/httpd_sess.c`: `httpd_sess_process` calls `httpd_req_new`, then `httpd_req_delete`, and only after both return success assigns `session->lru_counter = ++hd->lru_counter`. This happens even with LRU disabled. New sessions zero the slot, including the marker; the global counter resets when all sessions are deleted. Application calls to `httpd_sess_update_lru_counter` are confined to verified serial-WebSocket send work (`web_serial_transport.c`), which the sweep exempts. This marker is not a timestamp and not an fd-generation token. +- `esp_http_server/src/httpd_parse.c`: `httpd_req_new` synchronously parses and invokes the URI handler. `httpd_req_delete` drains any remaining body; cleanup clears `hd_req_aux.sd` and request pointers. A return from a diagnostic handler wrapper or a response-send call is earlier than this boundary. The sweep requires HTTPD's thread identity and no current `hd_req_aux.sd`, and skips `for_async_req`. Current ordinary handlers do not use async requests, out-of-owner sends, or unfinished chunked responses. Re-audit that contract if introduced. +- `esp_http_server/include/esp_http_server.h` has no global synchronous post-request-cleanup hook. Its event notifications are not such a hook: `esp_http_server_dispatch_event` posts to the event loop. `HTTP_SERVER_EVENT_SENT_DATA` is emitted by `httpd_resp_send` and per `httpd_resp_send_chunk`, **before** handler return/body purge/cleanup. Send/receive overrides belong to HTTPS and do not expose a safe completion hook. URI matching and error handlers likewise cannot supply an all-route post-cleanup boundary. +- `esp_https_server/src/https_server.c`: successful `httpd_ssl_open` stores the transport context and installs TLS send/recv/pending functions before synchronous `HTTPD_SSL_USER_CB_SESS_CREATE`. The application callback invalidates any old row for that fd even if the TLS pointer, socket-slot address and counter value were reused. If fd lookup unexpectedly fails, all observations reset conservatively. The close callback remains the diagnostic observer; SDK destruction remains intact. `httpd_ssl_pending` calls `esp_tls_get_bytes_avail` without consuming data; errors can post an existing SDK error event. +- `httpd_sess_trigger_close` resolves fd to a raw reusable `sock_db *`, then queues `httpd_sess_close`. Its zero-counter/LRU guard does not prove the same connection still occupies that slot. **Not used by this policy.** Direct owner shutdown retains the slot until SDK read cleanup and has no deferred fd/pointer argument that could later close a replacement. +- `httpd_queue_work` uses `cs_send_to_ctrl_sock` / `sendto` in `src/util/ctrl_sock.c`. The actual generated config leaves `CONFIG_HTTPD_QUEUE_WORK_BLOCKING` undefined/off, selecting the nonblocking queue mode. The idle initializer explicitly rejects builds with that blocking option on. Successful `httpd_stop` waits for `THREAD_STOPPED`, frees HTTPD and ends possible old callback execution; failed stop is not a retirement boundary. + +## Ownership and bounded storage + +`src/web_httpd_idle.{c,h}` owns one persistent ESP timer, six static observation rows, lifecycle gate/generation and queued/submitting flags. It uses no request data, secret, dynamic per-connection allocation, payload buffer, new task or additional socket. Timer callback performs only short metadata locking and at most one queue submission; all private session access, readiness and shutdown run on HTTPD. + +`src/web_server.c` serializes lifecycle. Prepare initializes rows **before** SSL startup; the TLS callback and sweep thereafter share the same owner. Attach publishes a nonzero, nonwrapping `uintptr_t` server generation, passed by value as opaque work argument—not a mutable shared descriptor or raw fd. At most one queued/executing probe is reserved; a separate submitting flag remains set until `httpd_queue_work` returns even if work already finished. That closes the callback-before-submit-return race. + +Detach first prevents submissions, then waits at most one second for any submitting call to return. Fence timeout forbids SSL destruction, retaining the handle for retry. An already executing sweep may finish safely while stop waits for HTTPD. Only successful SDK stop retires a discarded queued reservation; restart gets a new generation even if the server handle is reused. A stale generation cannot sweep or clear a newer reservation. No counter wrap or generic off-owner session-list query is accepted. + +`src/web_httpd_adapter.{c,h}` defines the six-row bound and 15-second policy and performs the version-pinned owner sweep. `src/CMakeLists.txt` adds only the new module. Diagnostics remains unchanged internally; server composes idle identity reset followed by existing diagnostic publication. + +## Validation and resource accounting + +Commands executed successfully: + +```sh +python3 tests/web_httpd_idle/run.py +python3 tests/web_admin_transport/server_lifecycle.py +python3 tests/web_diagnostics/run.py +python3 tests/web_cookie_auth/run.py --accounts +python3 tests/web_cookie_auth/run.py --admin +python3 tests/web_cookie_auth/run.py --settings +python3 tests/web_cookie_auth/run.py --serial-settings +python3 tests/web_admin_transport/run.py --tickets +python3 tests/web_session_store/run.py --serial +python3 tests/web_ui_session/run.py +python3 tests/admin_console_boundary/run.py +python3 tests/admin_console_boundary/accounts.py +python3 tests/admin_console_boundary/lifecycle.py +python3 tests/admin_ssh_policy/run.py +pio run +git diff --check +``` + +- Idle suite: **18 lifecycle groups plus SDK/source guards**. Compiles complete production lifecycle module and production sweep, with installed SDK `httpd_sess_process`/`httpd_req_delete`. Uses real host `socketpair`, readiness, shutdown, EOF and fd reuse; TLS, parsing/response callbacks, timer/queue scheduling and session deletion are deterministic doubles. Tests cover full slots, never-used post-TLS idle, five-second polling, pending/readable/error input, slow synchronous handlers/purge, failed requests, upgrade/async/closing-WS exemption, exact fd/TLS/slot/counter reuse, queue failures, early callback return, submit-fence timeout, failed stop, discarded/stale generations, accepted-but-lost UDP work, diagnostic-independent enforcement and timer initialization/restart/exhaustion. No real TLS/ESP-IDF scheduler or target timing claim. +- Server lifecycle **18 groups**, diagnostics **12+1**, admin transport **25** / tickets **12**, browser **68 plus renderer/CSP**, and the listed auth/store/canonical console/account suites pass. The cookie harness required one missing `` include after its private-layout double was expanded; fixed, all listed variants rerun successfully. +- Production build: **57.55 seconds**, **99,316 B RAM / 1,708,981 B flash**, **+160 / +1,384 B** versus the recorded diagnostic baseline (99,156 / 1,707,597). Final no-op confirmation `pio run` also passed in **3.13 seconds** with identical sizes. +- Target object symbols: rows **144 B**; module static symbols **167 B total before linker placement/alignment**. Linked RAM delta need not equal the sum of new symbols. One persistent internal-memory ESP timer allocation: installed non-profiled layout **32 B before allocator overhead**. Control UDP packet/mailbox transient allocation is SDK-owned, not included in static RAM. No task/stack/socket/queue-capacity increase. +- Target disassembly local frames (exclude callees): sweep **80 B**, idle work/timer **32/32 B**, prepare **64 B**, attach/detach/stopped **32 B each**, TLS idle callback **48 B**, composed TLS callback **32 B**. Actual HTTPD/timer stack margins, heap minima, TLS churn/latency and runtime reserves are **unmeasured/unapproved**. + +## Target checklist — not performed + +1. Start with diagnostic capture disabled: establish two serial WebSockets and ordinary HTTPS fetches. Verify idle ordinary sockets disappear after the observation window while both serial clients/lease remain unchanged. Enable capture only as needed to compare close/open occupancy; ages remain connection ages, not idle timestamps. +2. Reproduce the original ordinary4/serial2 full-slot case; wait beyond the idle window, then issue/open admin. Record client ticket/TLS/upgrade timings and occupancy without recording tickets/cookies. Verify no repeat reload loop is needed for already-old idle saturation. Separately test fresh saturation and acknowledge the existing 15-second browser timeout limit. +3. Leave status polling active for several minutes: no five-second TLS reconnect churn; both WebSockets and binary serial data/broker isolation survive. Repeat Settings/account-key operations, large assets, login/logout and two browser contexts. Compare with polling paused to distinguish genuinely idle slots. +4. Slow incomplete headers/bodies, pipelined requests, slow response readers and slow/failed TLS handshakes: no probe-driven close of an executing response or admitted WebSocket. Record owner delays; do not infer an overall request deadline from the unchanged one-second receive timeout. +5. Repeated close/reopen/fd reuse, full-mix stop/start and certificate rotation via supported UART0/SSH/browser lifecycle paths. Confirm UART0 and USB remain available, queued work never affects replacement connections, failed-stop retries retain ownership, and no start allocates a second server. +6. Capture settled/loaded/post-cleanup internal/DMA/PSRAM values and HTTPD/ESP-timer minimum-free stack. Soak at the accepted full client mix. Investigate control queue loss separately if probing appears stuck; successful stop/start is the safe recovery, not an eviction/capacity increase. diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index b119e78..f4edd79 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -42,6 +42,7 @@ idf_component_register( "web_session_store.c" "web_auth_parse.c" "web_httpd_adapter.c" + "web_httpd_idle.c" "web_cookie_auth.c" "web_login_ui.c" "web_console.c" diff --git a/src/web_httpd_adapter.c b/src/web_httpd_adapter.c index 43ef388..dad3b04 100644 --- a/src/web_httpd_adapter.c +++ b/src/web_httpd_adapter.c @@ -4,14 +4,62 @@ #include #include #include +#include +#include #include "esp_idf_version.h" #include "esp_httpd_priv.h" #include "secure_random.h" #if ESP_IDF_VERSION != ESP_IDF_VERSION_VAL(5, 5, 0) -#error "Reaudit HTTPD header storage and pre-handler upgrade behavior for this IDF" +#error "Reaudit HTTPD headers, upgrade, request completion and idle cleanup for this IDF" #endif +/* IDF 5.5.0 httpd_sess_process increments lru_counter only AFTER successful + * req_new + req_delete (handler, response, leftover-body purge and cleanup). + * Work callbacks run between sessions, never inside synchronous parse/TLS/send. + * This counter is an observation marker, NOT permission to enable LRU purge. */ +void web_httpd_idle_sweep(httpd_handle_t server, + web_httpd_idle_row_t rows[WEB_HTTPD_IDLE_SOCKETS], int64_t now) +{ + struct httpd_data *hd = server; + if (!hd || !rows || hd->config.max_open_sockets > WEB_HTTPD_IDLE_SOCKETS || + httpd_os_thread_handle() != hd->hd_td.handle || hd->hd_req_aux.sd) return; + for (unsigned i = 0; i < hd->config.max_open_sockets; ++i) { + struct sock_db *sd = &hd->hd_sd[i]; + web_httpd_idle_row_t *row = &rows[i]; + /* Actual SDK classification, not delayed diagnostic route metadata. */ + if (sd->fd < 0 || sd->for_async_req || sd->ws_handshake_done || sd->ws_close) { + memset(row, 0, sizeof(*row)); + continue; + } + if (!row->observed || row->fd != sd->fd || row->completed != sd->lru_counter) { + *row = (web_httpd_idle_row_t){.fd = sd->fd, .completed = sd->lru_counter, + .idle_since_us = now, .observed = true}; + continue; + } + if (row->shutdown_sent) continue; + /* Control work precedes data processing in httpd_main. Do not expire a + * connection whose next request is buffered in HTTPD, TLS or TCP. Zero + * timeout select does not consume bytes or change TLS receive ownership. + * Errors are conservative too; normal HTTPD owns error cleanup. */ + fd_set ready; + FD_ZERO(&ready); + if (sd->fd >= FD_SETSIZE) { row->idle_since_us = now; continue; } + FD_SET(sd->fd, &ready); + struct timeval timeout = {0}; + if (sd->pending_len || (sd->pending_fn && sd->pending_fn(hd, sd->fd) != 0) || + select(sd->fd + 1, &ready, NULL, NULL, &timeout) != 0) { + row->idle_since_us = now; + continue; + } + if (now - row->idle_since_us < WEB_HTTPD_IDLE_TIMEOUT_US) continue; + /* Still the current fd on its owner; no queued sock_db pointer can later + * target a replacement. HTTPD performs normal TLS/session destruction + * on the next read. A failed shutdown retries on the next probe. */ + if (shutdown(sd->fd, SHUT_RDWR) == 0) row->shutdown_sent = true; + } +} + bool web_httpd_headers_valid(httpd_req_t *request) { if (!request || !request->aux) return false; diff --git a/src/web_httpd_adapter.h b/src/web_httpd_adapter.h index 04b88dd..b7202fb 100644 --- a/src/web_httpd_adapter.h +++ b/src/web_httpd_adapter.h @@ -1,6 +1,19 @@ /* SPDX-License-Identifier: GPL-3.0-only */ #pragma once #include "esp_http_server.h" +#include + +#define WEB_HTTPD_IDLE_SOCKETS 6U +#define WEB_HTTPD_IDLE_TIMEOUT_US INT64_C(15000000) +typedef struct { + uint64_t completed; + int64_t idle_since_us; + int fd; + bool observed, shutdown_sent; +} web_httpd_idle_row_t; +/* HTTPD-owner work boundary only. TLS create must invalidate reused fd rows. */ +void web_httpd_idle_sweep(httpd_handle_t server, + web_httpd_idle_row_t rows[WEB_HTTPD_IDLE_SOCKETS], int64_t now); /* HTTPD-owner only, before body reads or any response. Reject duplicate lines, * including Cookie, rather than trusting first-match public getters. */ diff --git a/src/web_httpd_idle.c b/src/web_httpd_idle.c new file mode 100644 index 0000000..a374fa3 --- /dev/null +++ b/src/web_httpd_idle.c @@ -0,0 +1,137 @@ +/* SPDX-License-Identifier: GPL-3.0-only */ +#include "web_httpd_idle.h" +#include "web_httpd_adapter.h" +#include +#include +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#define IDLE_POLL_US INT64_C(1000000) +#define IDLE_FENCE_US INT64_C(1000000) + +static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED; +static httpd_handle_t s_server; +static esp_timer_handle_t s_timer; +static uintptr_t s_generation; +static bool s_accepting, s_queued, s_submitting; +/* Only HTTPD touches rows while alive; prepare runs before SSL startup. */ +static web_httpd_idle_row_t s_rows[WEB_HTTPD_IDLE_SOCKETS]; + +static void idle_work(void *argument) +{ + uintptr_t generation = (uintptr_t)argument; + taskENTER_CRITICAL(&s_lock); + bool current = s_queued && generation == s_generation; + httpd_handle_t server = current && s_accepting ? s_server : NULL; + taskEXIT_CRITICAL(&s_lock); + if (server) web_httpd_idle_sweep(server, s_rows, esp_timer_get_time()); + taskENTER_CRITICAL(&s_lock); + if (current && generation == s_generation) s_queued = false; + taskEXIT_CRITICAL(&s_lock); +} + +static void idle_timer(void *argument) +{ + (void)argument; + taskENTER_CRITICAL(&s_lock); + httpd_handle_t server = NULL; + uintptr_t generation = s_generation; + if (s_accepting && !s_queued && !s_submitting) { + server = s_server; + s_queued = s_submitting = true; + } + taskEXIT_CRITICAL(&s_lock); + if (!server) return; + esp_err_t error = httpd_queue_work(server, idle_work, (void *)generation); + taskENTER_CRITICAL(&s_lock); + /* A callback may finish before queue_work returns. Keep the submission + * reservation until here so it cannot clear a newer probe's queued flag. */ + if (error != ESP_OK) s_queued = false; + s_submitting = false; + taskEXIT_CRITICAL(&s_lock); +} + +esp_err_t web_httpd_idle_prepare(void) +{ +#if defined(CONFIG_HTTPD_QUEUE_WORK_BLOCKING) && CONFIG_HTTPD_QUEUE_WORK_BLOCKING + return ESP_ERR_NOT_SUPPORTED; +#else + taskENTER_CRITICAL(&s_lock); + bool allowed = !s_server && !s_queued && !s_submitting && s_generation != UINTPTR_MAX; + taskEXIT_CRITICAL(&s_lock); + if (!allowed) return ESP_ERR_INVALID_STATE; + if (!s_timer) { + esp_timer_handle_t timer = NULL; + const esp_timer_create_args_t args = { + .callback = idle_timer, .name = "web_idle", .skip_unhandled_events = true, + }; + esp_err_t error = esp_timer_create(&args, &timer); + if (error == ESP_OK) error = esp_timer_start_periodic(timer, IDLE_POLL_US); + if (error != ESP_OK) { + if (timer) (void)esp_timer_delete(timer); + return error; + } + s_timer = timer; + } + memset(s_rows, 0, sizeof(s_rows)); + return ESP_OK; +#endif +} + +esp_err_t web_httpd_idle_attach(httpd_handle_t server) +{ + taskENTER_CRITICAL(&s_lock); + bool allowed = server && s_timer && !s_server && !s_queued && !s_submitting && + s_generation != UINTPTR_MAX; + if (allowed) { + ++s_generation; /* Never reused, including when HTTPD's handle is reused. */ + s_server = server; + s_accepting = true; + } + taskEXIT_CRITICAL(&s_lock); + return allowed ? ESP_OK : ESP_ERR_INVALID_STATE; +} + +esp_err_t web_httpd_idle_detach(httpd_handle_t server) +{ + taskENTER_CRITICAL(&s_lock); + bool owned = server && s_server == server; + bool absent = !s_server; + if (owned) s_accepting = false; + taskEXIT_CRITICAL(&s_lock); + /* Partial startup may never have attached. */ + if (!owned) return absent ? ESP_OK : ESP_ERR_INVALID_STATE; + int64_t deadline = esp_timer_get_time() + IDLE_FENCE_US; + for (;;) { + taskENTER_CRITICAL(&s_lock); + bool submitting = s_submitting; + taskEXIT_CRITICAL(&s_lock); + if (!submitting) return ESP_OK; + if (esp_timer_get_time() >= deadline) return ESP_ERR_TIMEOUT; + vTaskDelay(1); + } +} + +void web_httpd_idle_stopped(httpd_handle_t server) +{ + taskENTER_CRITICAL(&s_lock); + if (server && s_server == server && !s_accepting && !s_submitting) { + s_server = NULL; + s_queued = false; /* Successful HTTPD stop joined owner and destroyed queue. */ + } + taskEXIT_CRITICAL(&s_lock); +} + +void web_httpd_idle_tls(esp_https_server_user_cb_arg_t *arg) +{ + if (!arg || !arg->tls || arg->user_cb_state != HTTPD_SSL_USER_CB_SESS_CREATE) return; + int fd = -1; + if (esp_tls_get_conn_sockfd(arg->tls, &fd) != ESP_OK || fd < 0) { + /* Identity unavailable: conservatively restart every idle observation. */ + memset(s_rows, 0, sizeof(s_rows)); + return; + } + for (unsigned i = 0; i < WEB_HTTPD_IDLE_SOCKETS; ++i) + if (s_rows[i].fd == fd) memset(&s_rows[i], 0, sizeof(s_rows[i])); +} diff --git a/src/web_httpd_idle.h b/src/web_httpd_idle.h new file mode 100644 index 0000000..b8fa6c4 --- /dev/null +++ b/src/web_httpd_idle.h @@ -0,0 +1,12 @@ +/* SPDX-License-Identifier: GPL-3.0-only */ +#pragma once +#include "esp_https_server.h" + +/* Serialized web_server lifecycle. Prepare before SSL start; stop must fence + * submissions before destroying HTTPD, and retire only after successful stop. */ +esp_err_t web_httpd_idle_prepare(void); +esp_err_t web_httpd_idle_attach(httpd_handle_t server); +esp_err_t web_httpd_idle_detach(httpd_handle_t server); +void web_httpd_idle_stopped(httpd_handle_t server); +/* Synchronous HTTPD-owner TLS callback, composed with diagnostics by server. */ +void web_httpd_idle_tls(esp_https_server_user_cb_arg_t *arg); diff --git a/src/web_server.c b/src/web_server.c index 23afc75..0443cee 100644 --- a/src/web_server.c +++ b/src/web_server.c @@ -29,6 +29,7 @@ #include "web_session_store.h" #include "web_cookie_auth.h" #include "web_httpd_adapter.h" +#include "web_httpd_idle.h" #include "web_diagnostics.h" #include "web_ui.h" #include "wifi_manager.h" @@ -546,6 +547,12 @@ static esp_err_t route_error_handler(httpd_req_t *request, httpd_err_code_t code return ESP_FAIL; /* Do not drain a rejected request body on keepalive. */ } +static void tls_session_callback(esp_https_server_user_cb_arg_t *arg) +{ + web_httpd_idle_tls(arg); + web_diagnostics_tls(arg); +} + esp_err_t web_server_init(void) { esp_err_t error = ensure_mutex(); @@ -600,6 +607,7 @@ esp_err_t web_server_start(void) /* Initialize only after lifecycle admission; failure gates all HTTPS auth. */ error = web_cookie_auth_start(); + if (error == ESP_OK) error = web_httpd_idle_prepare(); uint8_t certificate[WEB_SECURITY_CERTIFICATE_DER_CAPACITY] = {0}; uint8_t private_key[WEB_SECURITY_PRIVATE_KEY_DER_CAPACITY] = {0}; @@ -627,8 +635,8 @@ esp_err_t web_server_start(void) config.prvtkey_len = private_key_length; config.port_secure = WEB_SERVER_PORT; config.tls_handshake_timeout_ms = 5000U; - /* Public synchronous post-TLS observation; HTTPS retains all cleanup. */ - config.user_cb = web_diagnostics_tls; + /* Public synchronous identity reset/observation; HTTPS retains cleanup. */ + config.user_cb = tls_session_callback; error = httpd_ssl_start(&server, &config); } secure_wipe(certificate, sizeof(certificate)); @@ -648,6 +656,7 @@ esp_err_t web_server_start(void) error = httpd_register_err_handler(server, HTTPD_404_NOT_FOUND, route_error_handler); if (error == ESP_OK) error = httpd_register_err_handler(server, HTTPD_405_METHOD_NOT_ALLOWED, route_error_handler); + if (error == ESP_OK) error = web_httpd_idle_attach(server); esp_err_t attach_error = s_serial_transport_error; if (error == ESP_OK && serial_transport_ready) { attach_error = web_serial_transport_attach_server(server); @@ -680,8 +689,10 @@ esp_err_t web_server_start(void) web_cookie_auth_stop(); } if (error != ESP_OK && server != NULL) { - esp_err_t cleanup_error = httpd_ssl_stop(server); + esp_err_t cleanup_error = web_httpd_idle_detach(server); + if (cleanup_error == ESP_OK) cleanup_error = httpd_ssl_stop(server); if (cleanup_error == ESP_OK) { + web_httpd_idle_stopped(server); server = NULL; } else { /* Retain ownership so stop can retry and start cannot allocate a second server. */ @@ -726,6 +737,15 @@ esp_err_t web_server_stop(void) xSemaphoreGive(s_server_mutex); web_cookie_auth_stop(); + esp_err_t idle_error = web_httpd_idle_detach(server); + if (idle_error != ESP_OK) { + /* Never destroy HTTPD while a timer submission still holds its handle. */ + xSemaphoreTake(s_server_mutex, portMAX_DELAY); + s_transitioning = false; + s_last_error = idle_error; + xSemaphoreGive(s_server_mutex); + return idle_error; + } if (admin_transport_owned) { esp_err_t detach_error = web_admin_transport_detach(server); if (detach_error != ESP_OK) { @@ -752,6 +772,7 @@ esp_err_t web_server_stop(void) } esp_err_t error = httpd_ssl_stop(server); + if (error == ESP_OK) web_httpd_idle_stopped(server); if (error == ESP_OK && admin_transport_owned) web_admin_transport_stopped(server); if (error != ESP_OK && serial_transport_attached) { /* Stay detached: old HTTPD work may still be reading static TX storage. */ diff --git a/tests/web_admin_transport/server_lifecycle.py b/tests/web_admin_transport/server_lifecycle.py index c7a96d3..eaace96 100644 --- a/tests/web_admin_transport/server_lifecycle.py +++ b/tests/web_admin_transport/server_lifecycle.py @@ -108,7 +108,28 @@ static void secure_wipe(void *p, size_t n) { assert(!locked); memset(p, 0, n); } HANDLER(root_handler) HANDLER(status_handler) HANDLER(traced_ticket_handler) HANDLER(traced_websocket_handler) HANDLER(asset_handler) HANDLER(web_cookie_auth_handler) HANDLER(traced_admin_ticket_handler) HANDLER(traced_admin_upgrade_handler) -static void web_diagnostics_tls(void *arg) { (void)arg; assert(false); } +typedef void esp_https_server_user_cb_arg_t; +static unsigned tls_steps; +static void web_httpd_idle_tls(void *arg) { assert(arg && tls_steps == 0); ++tls_steps; } +static void web_diagnostics_tls(void *arg) { assert(arg && tls_steps == 1); ++tls_steps; } +static void tls_session_callback(esp_https_server_user_cb_arg_t *arg); +static bool idle_owned, idle_fenced; +static esp_err_t idle_prepare_error, idle_attach_error, idle_detach_error; +static unsigned idle_prepares, idle_attaches, idle_detaches, idle_stoppeds; +static esp_err_t web_httpd_idle_prepare(void) { + assert(!locked && !ssl_live && !idle_owned); ++idle_prepares; return idle_prepare_error; +} +static esp_err_t web_httpd_idle_attach(httpd_handle_t s) { + assert(!locked && s == SERVER && ssl_live && !idle_owned); ++idle_attaches; + idle_owned = idle_attach_error == ESP_OK; idle_fenced = false; return idle_attach_error; +} +static esp_err_t web_httpd_idle_detach(httpd_handle_t s) { + assert(!locked && s == SERVER && ssl_live && !auth_live); ++idle_detaches; + idle_fenced = idle_detach_error == ESP_OK; return idle_detach_error; +} +static void web_httpd_idle_stopped(httpd_handle_t s) { + assert(!locked && s == SERVER && !ssl_live && idle_fenced); ++idle_stoppeds; idle_owned = false; +} HANDLER(serial_settings_handler) HANDLER(web_serial_settings_handler) HANDLER(web_account_settings_handler) HANDLER(web_account_generate_password_handler) HANDLER(web_account_keys_handler) @@ -131,7 +152,8 @@ static esp_err_t httpd_ssl_start(httpd_handle_t *server, const httpd_ssl_config_ assert(config->httpd.max_uri_handlers == 24 && config->port_secure == 443); assert(config->httpd.recv_wait_timeout == 1 && config->httpd.send_wait_timeout == 1); assert(config->tls_handshake_timeout_ms == 5000); - assert(config->user_cb == web_diagnostics_tls); + assert(config->user_cb == tls_session_callback); + tls_steps = 0; config->user_cb(&tls_steps); assert(tls_steps == 2); assert(config->servercert_len == 1 && config->servercert[0] == 1); assert(config->prvtkey_len == 1 && config->prvtkey_pem[0] == 2); if (ssl_start_error != ESP_OK) return ssl_start_error; @@ -233,7 +255,7 @@ static esp_err_t web_serial_transport_detach_server(httpd_handle_t s) { event('S'); ++serial_detaches; serial_live = false; return ESP_OK; } static esp_err_t httpd_ssl_stop(httpd_handle_t s) { - assert(s == SERVER && ssl_live && !auth_live); event('H'); ++ssl_stops; + assert(s == SERVER && ssl_live && !auth_live && idle_fenced); event('H'); ++ssl_stops; if (ssl_stop_error == ESP_OK) ssl_live = false; return ssl_stop_error; } @@ -253,6 +275,9 @@ static void reset(void) { s_last_error = s_serial_transport_error = ESP_ERR_INVALID_STATE; memset(&s_counters, 0, sizeof(s_counters)); mutex_fail = auth_live = ssl_live = admin_owned = serial_live = false; + idle_owned = idle_fenced = false; + idle_prepare_error = idle_attach_error = idle_detach_error = ESP_OK; + idle_prepares = idle_attaches = idle_detaches = idle_stoppeds = 0; serial_init_error = admin_init_error = admin_attach_error = ESP_OK; auth_error = ssl_start_error = ssl_stop_error = admin_detach_error = ESP_OK; serial_inits = admin_inits = auth_starts = auth_stops = ssl_starts = ssl_stops = 0; @@ -267,7 +292,7 @@ static void fresh_registration(void) { registration_calls = registered_count = 0 static void start(void) { assert(web_server_start() == ESP_OK); assert(s_server == SERVER && s_admin_transport_owned && s_serial_transport_attached); - assert(auth_live && ssl_live && admin_owned && serial_live && !s_transitioning); + assert(auth_live && ssl_live && admin_owned && serial_live && !s_transitioning && idle_owned); } static const httpd_uri_t *route(const char *uri) { const httpd_uri_t *found = NULL; @@ -468,12 +493,32 @@ int main(void) { assert(route("/api/settings/accounts/keys")->handler == web_account_keys_handler); assert(web_server_stop() == ESP_OK); puts("PASS optional account keys allocation failure preserves account/generation/auth/transports; restart recovers"); - puts("16 lifecycle groups passed (16 required fatal positions, 10 optional routes, plus failed unregister)"); + reset(); idle_prepare_error = ESP_ERR_NO_MEM; + assert(web_server_start() == ESP_ERR_NO_MEM && !ssl_starts && !auth_live); + assert(idle_prepares == 1 && !idle_attaches && !s_server); + reset(); idle_attach_error = ESP_FAIL; + assert(web_server_start() == ESP_FAIL && !ssl_live && !s_server); + assert(idle_detaches == 1 && idle_stoppeds == 1 && !serial_attaches); + puts("PASS idle timer preparation/attachment failures gate startup and clean partial HTTPD"); + reset(); start(); idle_detach_error = ESP_ERR_TIMEOUT; + assert(web_server_stop() == ESP_ERR_TIMEOUT && ssl_live && idle_owned); + assert(!ssl_stops && !admin_detaches && !serial_detaches && !idle_stoppeds); + assert(!s_transitioning && s_server == SERVER && web_server_start() == ESP_ERR_INVALID_STATE); + idle_detach_error = ESP_OK; ssl_stop_error = ESP_FAIL; + assert(web_server_stop() == ESP_FAIL && idle_owned && !idle_stoppeds); + ssl_stop_error = ESP_OK; + assert(web_server_stop() == ESP_OK && !idle_owned && idle_stoppeds == 1); + fresh_registration(); start(); assert(web_server_stop() == ESP_OK); + puts("PASS idle submit fence failure forbids SSL destruction; failed stop retains ownership until retry/restart"); + puts("18 lifecycle groups passed (16 required fatal positions, 10 optional routes, plus failed unregister)"); return 0; } ''' unit = FAKES + header + '\n' + constants + state + '\n'.join(uri_tables) +callback = re.search(r'^static void tls_session_callback\(.*?^\}', source, re.M | re.S) +assert callback +unit += callback.group() + '\n' unit += function('ensure_mutex') unit += ''.join(function(name) for name in ('web_server_init', 'web_server_start', 'web_server_stop')) unit += TESTS diff --git a/tests/web_cookie_auth/run.py b/tests/web_cookie_auth/run.py index 935e243..6ba44b5 100644 --- a/tests/web_cookie_auth/run.py +++ b/tests/web_cookie_auth/run.py @@ -31,13 +31,18 @@ HEADERS["esp_idf_version.h"] = """ #define ESP_IDF_VERSION ESP_IDF_VERSION_VAL(5,5,0) """ HEADERS["esp_httpd_priv.h"] = """#pragma once +#include #include "esp_http_server.h" -struct sock_db { bool ws_handshake_done; esp_err_t (*ws_handler)(httpd_req_t *); +static inline void *httpd_os_thread_handle(void) { return (void *)1; } +struct sock_db { int fd; bool for_async_req, ws_close; uint64_t lru_counter; + int (*pending_fn)(httpd_handle_t, int); + bool ws_handshake_done; esp_err_t (*ws_handler)(httpd_req_t *); bool ws_control_frames; void *ws_user_ctx; char pending_data[128]; size_t pending_len; }; struct httpd_req_aux { struct sock_db *sd; char *scratch; size_t scratch_cur_size, remaining_len; unsigned req_hdrs_count, resp_hdrs_count; bool ws_handshake_detect; struct resp_hdr { const char *field, *value; } *resp_hdrs; }; -struct httpd_data { struct { unsigned max_resp_headers, max_uri_handlers; void *uri_match_fn; } config; +struct httpd_data { struct { unsigned max_resp_headers, max_uri_handlers, max_open_sockets; void *uri_match_fn; } config; + struct { void *handle; } hd_td; struct httpd_req_aux hd_req_aux; struct sock_db *hd_sd; httpd_uri_t **hd_calls; }; esp_err_t httpd_ws_respond_server_handshake(httpd_req_t *, const char *); """ diff --git a/tests/web_diagnostics/run.py b/tests/web_diagnostics/run.py index a24a61d..5f48fb1 100644 --- a/tests/web_diagnostics/run.py +++ b/tests/web_diagnostics/run.py @@ -24,8 +24,10 @@ for name in ('traced_ticket_handler', 'traced_websocket_handler', match = re.search(r'static esp_err_t ' + name + r'\(httpd_req_t \*request\)\n\{.*?\n\}', server, re.S) assert match, name wrappers.append(match.group()) -# This slice must not take over cleanup, add async probes, or enable SDK logging. -assert 'config.user_cb = web_diagnostics_tls;' in server +# Diagnostics itself must remain observation-only. The server composes the +# independent, always-on idle policy before diagnostic publication. +assert 'config.user_cb = tls_session_callback;' in server +assert 'web_httpd_idle_tls(arg);\n web_diagnostics_tls(arg);' in server assert 'config.httpd.close_fn' not in server and 'config.httpd.open_fn' not in server source = (ROOT / 'src/web_diagnostics.c').read_text() for forbidden in ('httpd_queue_work', 'httpd_get_client_list', 'esp_event_handler_register', diff --git a/tests/web_httpd_idle/README.md b/tests/web_httpd_idle/README.md new file mode 100644 index 0000000..4105532 --- /dev/null +++ b/tests/web_httpd_idle/README.md @@ -0,0 +1,17 @@ +# Ordinary HTTPS idle cleanup regressions + +Run from the repository root: + +```sh +python3 tests/web_httpd_idle/run.py +python3 tests/web_admin_transport/server_lifecycle.py +python3 tests/web_diagnostics/run.py +``` + +Requires a C11 host compiler (`CC`, default `cc`) and the installed ESP-IDF 5.5.0 source (`IDF_PATH`, default `~/.platformio/packages/framework-espidf`). No packages are installed; binaries live in a temporary directory. No network, device or firmware operation. + +`run.py` compiles the complete production idle lifecycle module and the production adapter sweep with installed SDK request-completion/body-purge functions. `fakes.h`/`test.c` provide deterministic TLS/parser/response/timer/queue/session-deletion doubles. Real local POSIX sockets exercise readability, shutdown, peer EOF and numeric fd reuse. + +18 groups cover all six slots, normal five-second polling, never-used post-TLS connections, buffered/pipelined/incomplete input, slow synchronous processing/purge, failed requests, WS/upgrade/async exemptions, fd/TLS/slot/LRU reuse, shutdown retry, queue failure/loss, bounded submission, early callback completion, stop fences, failed stop/restart and stale/nonwrapping generations. The module is tested without linking diagnostics, and source guards enforce independent policy plus composed TLS callback ordering; the diagnostic and server suites exercise their own integration contracts. + +These are not real TLS, ESP-IDF scheduler, TCP-over-network or target timing tests. In particular, accepted-but-lost UDP work is tested as a bounded fail-safe condition requiring successful server restart, not as automatic recovery. Full timeout/SDK audit, resource evidence and unperformed hardware checklist: [`docs/https_idle_cleanup.md`](../../docs/https_idle_cleanup.md). diff --git a/tests/web_httpd_idle/fakes.h b/tests/web_httpd_idle/fakes.h new file mode 100644 index 0000000..0486eb1 --- /dev/null +++ b/tests/web_httpd_idle/fakes.h @@ -0,0 +1,84 @@ +/* SPDX-License-Identifier: GPL-3.0-only */ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +typedef int esp_err_t; +enum { ESP_OK, ESP_FAIL, ESP_ERR_INVALID_ARG, ESP_ERR_INVALID_STATE, + ESP_ERR_NO_MEM, ESP_ERR_TIMEOUT, ESP_ERR_NOT_SUPPORTED }; +typedef void *httpd_handle_t; +typedef struct { int unused; } httpd_uri_t; +struct httpd_req_aux; +typedef struct { httpd_handle_t handle; struct httpd_req_aux *aux; } httpd_req_t; +struct sock_db { + int fd; uint64_t lru_counter; bool for_async_req, ws_handshake_done, ws_close; + size_t pending_len; int (*pending_fn)(httpd_handle_t, int); +}; +struct httpd_req_aux { struct sock_db *sd; size_t remaining_len; }; +struct httpd_data { + struct { unsigned max_open_sockets; } config; + struct { void *handle; } hd_td; + struct sock_db *hd_sd; + struct httpd_req_aux hd_req_aux; + httpd_req_t hd_req; + uint64_t lru_counter; +}; +static bool owner; +static unsigned lock_depth, shutdown_calls, select_calls; +static int shutdown_error, select_error, tls_pending[FD_SETSIZE]; +static int64_t now_us; +static void *httpd_os_thread_handle(void) { return owner ? (void *)1 : (void *)2; } +typedef int portMUX_TYPE; +#define portMUX_INITIALIZER_UNLOCKED 0 +#define taskENTER_CRITICAL(lock) do { (void)(lock); assert(!lock_depth); ++lock_depth; } while (0) +#define taskEXIT_CRITICAL(lock) do { (void)(lock); assert(lock_depth == 1); --lock_depth; } while (0) +static int64_t esp_timer_get_time(void) { assert(!lock_depth); return now_us; } +static void (*delay_hook)(void); +static void vTaskDelay(int ticks) { assert(!lock_depth && ticks == 1); now_us += 100000; if (delay_hook) delay_hook(); } +typedef void *esp_timer_handle_t; +typedef struct { void (*callback)(void *); const char *name; bool skip_unhandled_events; } esp_timer_create_args_t; +static esp_err_t timer_create_error, timer_start_error, queue_error; +static unsigned timer_creates, timer_starts, timer_deletes, queue_calls; +static void (*timer_callback)(void *), (*queued_work)(void *); +static void *queued_arg; +static void (*queue_hook)(void); +static bool inline_work; +static esp_err_t esp_timer_create(const esp_timer_create_args_t *args, esp_timer_handle_t *timer) { + assert(!lock_depth && args->skip_unhandled_events); ++timer_creates; + if (timer_create_error) return timer_create_error; + timer_callback = args->callback; *timer = (void *)3; return ESP_OK; +} +static esp_err_t esp_timer_start_periodic(esp_timer_handle_t timer, int64_t period) { + assert(!lock_depth && timer && period == 1000000); ++timer_starts; return timer_start_error; +} +static esp_err_t esp_timer_delete(esp_timer_handle_t timer) { assert(!lock_depth && timer); ++timer_deletes; return ESP_OK; } +static esp_err_t httpd_queue_work(httpd_handle_t, void (*)(void *), void *); +typedef struct { int fd; bool get_error; } esp_tls_t; +enum { HTTPD_SSL_USER_CB_SESS_CREATE, HTTPD_SSL_USER_CB_SESS_CLOSE }; +typedef struct { esp_tls_t *tls; unsigned user_cb_state; } esp_https_server_user_cb_arg_t; +static esp_err_t esp_tls_get_conn_sockfd(esp_tls_t *tls, int *fd) { + assert(owner && !lock_depth); *fd = tls->fd; return tls->get_error ? ESP_FAIL : ESP_OK; +} +static int test_select(int n, fd_set *r, fd_set *w, fd_set *e, struct timeval *t) { + assert(owner && !lock_depth && !w && !e && t && !t->tv_sec && !t->tv_usec); + ++select_calls; return select_error ? -1 : select(n, r, w, e, t); +} +static int test_shutdown(int fd, int how) { + assert(owner && !lock_depth && how == SHUT_RDWR); ++shutdown_calls; + return shutdown_error ? -1 : shutdown(fd, how); +} +#define select test_select +#define shutdown test_shutdown +#define CONFIG_HTTPD_PURGE_BUF_LEN 32 +#define ESP_LOGD(...) ((void)0) +#define ESP_LOG_BUFFER_HEX_LEVEL(...) ((void)0) +#define MIN(a, b) ((a) < (b) ? (a) : (b)) +static esp_err_t httpd_req_new(struct httpd_data *, struct sock_db *); +static int httpd_req_recv(httpd_req_t *, char *, size_t); +static void httpd_req_cleanup(httpd_req_t *); diff --git a/tests/web_httpd_idle/run.py b/tests/web_httpd_idle/run.py new file mode 100644 index 0000000..4b5be16 --- /dev/null +++ b/tests/web_httpd_idle/run.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Production idle lifecycle/sweep + installed IDF completion path, real host sockets. + +Deterministic owner/queue/TLS doubles, not a TLS server or hardware timing test. +No dependency installation, device operation, or SDK modification. +""" +import os +from pathlib import Path +import re +import subprocess +import tempfile + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +IDF = Path(os.environ.get('IDF_PATH', str(Path.home() / '.platformio/packages/framework-espidf'))) + + +def stripped(path): + return '\n'.join(line for line in path.read_text().splitlines() + if not line.startswith(('#include', '#pragma once'))) + + +def function(source, name): + match = re.search(r'^(?:static )?(?:void|esp_err_t) ' + name + r'\(.*?^\}', source, re.M | re.S) + assert match, name + return match.group() + '\n' + + +sess = (IDF / 'components/esp_http_server/src/httpd_sess.c').read_text() +parse = (IDF / 'components/esp_http_server/src/httpd_parse.c').read_text() +main = (IDF / 'components/esp_http_server/src/httpd_main.c').read_text() +ssl = (IDF / 'components/esp_https_server/src/https_server.c').read_text() +completion = function(sess, 'httpd_sess_process') +assert completion.index('httpd_req_new') < completion.index('httpd_req_delete') < completion.index('session->lru_counter = ++hd->lru_counter') +assert 'Only listen for new connections if server has capacity' in main +assert main.index('/* Case0:') < main.index('/* Case1:') < main.index('/* Case2:') +assert 'httpd_sess_delete(hd, sock_db);' in sess # queued reusable-pointer close is not safe here +assert ssl.index('httpd_sess_set_pending_override') < ssl.index('HTTPD_SSL_USER_CB_SESS_CREATE') +adapter = (ROOT / 'src/web_httpd_adapter.c').read_text() +idle = (ROOT / 'src/web_httpd_idle.c').read_text() +assert 'ESP_IDF_VERSION_VAL(5, 5, 0)' in adapter +for forbidden in ('httpd_sess_trigger_close', 'web_diagnostics', 'xTaskCreate', 'malloc(', 'calloc(', 'ESP_LOG'): + assert forbidden not in idle, forbidden +sweep = function(adapter, 'web_httpd_idle_sweep') +assert 'httpd_sess_trigger_close' not in sweep +assert 'web_httpd_idle_tls(arg);\n web_diagnostics_tls(arg);' in (ROOT / 'src/web_server.c').read_text() +assert 'setInterval(pollStatus, 5000)' in (ROOT / 'src/web_ui.c').read_text() + +with tempfile.TemporaryDirectory(prefix='web-httpd-idle-') as directory: + directory = Path(directory) + unit = directory / 'test.c' + unit.write_text((HERE / 'fakes.h').read_text() + '\n' + + stripped(ROOT / 'src/web_httpd_adapter.h') + '\n' + + stripped(ROOT / 'src/web_httpd_idle.h') + '\n' + + sweep + '\n' + stripped(ROOT / 'src/web_httpd_idle.c') + '\n' + + function(parse, 'httpd_req_delete') + '\n' + completion + '\n' + + (HERE / 'test.c').read_text()) + executable = directory / 'test' + subprocess.run([os.environ.get('CC', 'cc'), '-std=c11', '-Wall', '-Wextra', '-Werror', + '-g', str(unit), '-o', str(executable)], check=True, timeout=30) + subprocess.run([str(executable)], check=True, timeout=20) +print('PASS installed SDK completion/owner-order and production integration guards') diff --git a/tests/web_httpd_idle/test.c b/tests/web_httpd_idle/test.c new file mode 100644 index 0000000..0aea4dc --- /dev/null +++ b/tests/web_httpd_idle/test.c @@ -0,0 +1,329 @@ +/* SPDX-License-Identifier: GPL-3.0-only */ +static struct sock_db sockets[WEB_HTTPD_IDLE_SOCKETS]; +static struct httpd_data server; +static int peers[WEB_HTTPD_IDLE_SOCKETS]; +static esp_tls_t tls_identity; /* Deliberately reuse this identity and descriptors. */ +static int64_t handler_delay, purge_delay; +static size_t leftover; +static bool request_fail, purge_fail, upgrade; +static unsigned purges, responses; + +static void pump(void) +{ + assert(!lock_depth && !server.hd_req_aux.sd); + if (!queued_work) return; + void (*work)(void *) = queued_work; + void *arg = queued_arg; + queued_work = NULL; queued_arg = NULL; + bool previous = owner; owner = true; work(arg); owner = previous; +} + +static esp_err_t httpd_queue_work(httpd_handle_t hd, void (*work)(void *), void *arg) +{ + assert(!lock_depth && hd == &server && !queued_work); + ++queue_calls; + if (queue_error != ESP_OK) return queue_error; + queued_work = work; queued_arg = arg; + if (inline_work) pump(); + if (queue_hook) queue_hook(); + return ESP_OK; +} + +static int pending(httpd_handle_t hd, int fd) +{ + assert(owner && !lock_depth && hd == &server); + return tls_pending[fd]; +} + +static void tick(int64_t at) +{ + now_us = at; + bool previous = owner; owner = false; timer_callback(NULL); owner = previous; + pump(); +} + +static void drop(unsigned i) +{ + if (sockets[i].fd >= 0) { + owner = true; + tls_identity.fd = sockets[i].fd; + esp_https_server_user_cb_arg_t arg = {.tls = &tls_identity, .user_cb_state = HTTPD_SSL_USER_CB_SESS_CLOSE}; + web_httpd_idle_tls(&arg); + close(sockets[i].fd); sockets[i].fd = -1; + } + if (peers[i] >= 0) { close(peers[i]); peers[i] = -1; } +} + +static void connect_slot(unsigned i, int reuse_fd) +{ + int pair[2]; assert(socketpair(AF_UNIX, SOCK_STREAM, 0, pair) == 0); + if (reuse_fd >= 0 && pair[0] != reuse_fd) { + assert(pair[1] != reuse_fd && dup2(pair[0], reuse_fd) == reuse_fd); + close(pair[0]); pair[0] = reuse_fd; + } + assert(pair[0] < FD_SETSIZE); + sockets[i] = (struct sock_db){.fd = pair[0], .pending_fn = pending}; + peers[i] = pair[1]; tls_identity = (esp_tls_t){.fd = pair[0]}; + owner = true; + esp_https_server_user_cb_arg_t arg = {.tls = &tls_identity, .user_cb_state = HTTPD_SSL_USER_CB_SESS_CREATE}; + web_httpd_idle_tls(&arg); +} + +static void reset(void) +{ + /* Each test models a fresh process; restart tests below do NOT reset state. */ + memset(s_rows, 0, sizeof(s_rows)); + s_server = s_timer = NULL; s_generation = 0; + s_accepting = s_queued = s_submitting = false; + server = (struct httpd_data){.config.max_open_sockets = 6, .hd_td.handle = (void *)1, .hd_sd = sockets}; + memset(sockets, 0, sizeof(sockets)); + for (unsigned i = 0; i < 6; ++i) sockets[i].fd = peers[i] = -1; + memset(tls_pending, 0, sizeof(tls_pending)); + owner = true; now_us = 0; lock_depth = shutdown_calls = select_calls = 0; + shutdown_error = select_error = 0; + timer_create_error = timer_start_error = queue_error = ESP_OK; + timer_creates = timer_starts = timer_deletes = queue_calls = 0; + timer_callback = queued_work = NULL; queued_arg = NULL; queue_hook = delay_hook = NULL; + inline_work = request_fail = purge_fail = upgrade = false; + handler_delay = purge_delay = 0; leftover = purges = responses = 0; +} + +static void start(void) +{ + assert(web_httpd_idle_prepare() == ESP_OK); + assert(web_httpd_idle_attach(&server) == ESP_OK); +} + +static void stop(void) +{ + owner = false; assert(web_httpd_idle_detach(&server) == ESP_OK); + /* Model successful HTTPD stop: join work, close sessions, destroy queue. */ + pump(); + for (unsigned i = 0; i < 6; ++i) drop(i); + queued_work = NULL; queued_arg = NULL; + web_httpd_idle_stopped(&server); + assert(!s_server && !s_queued && !s_submitting); +} + +static esp_err_t httpd_req_new(struct httpd_data *hd, struct sock_db *sd) +{ + assert(owner && hd == &server && !hd->hd_req_aux.sd); + hd->hd_req_aux.sd = sd; + hd->hd_req_aux.remaining_len = leftover; + hd->hd_req = (httpd_req_t){.handle = hd, .aux = &hd->hd_req_aux}; + /* Parser/handler/send run synchronously; timers can queue but cannot run + * work on HTTPD until this and the SDK's leftover-body purge return. */ + timer_callback(NULL); + now_us += handler_delay; + unsigned before = shutdown_calls; + web_httpd_idle_sweep(hd, s_rows, now_us); /* defensive active-owner guard */ + assert(shutdown_calls == before); + if (request_fail) { httpd_req_cleanup(&hd->hd_req); return ESP_FAIL; } + ++responses; + if (upgrade) sd->ws_handshake_done = true; + return ESP_OK; +} + +static int httpd_req_recv(httpd_req_t *req, char *data, size_t size) +{ + assert(owner && req->aux && req->aux->sd && size); + ++purges; now_us += purge_delay; + timer_callback(NULL); + if (purge_fail) return -1; + memset(data, 0, size); req->aux->remaining_len -= size; + return (int)size; +} + +static void httpd_req_cleanup(httpd_req_t *req) +{ + req->aux->sd = NULL; req->aux = NULL; req->handle = NULL; +} + +static void complete(unsigned i) +{ + owner = true; + if (httpd_sess_process(&server, &sockets[i]) != ESP_OK) drop(i); + pump(); +} + +static void inline_submit_hook(void) +{ + unsigned before = queue_calls; + assert(!s_queued && s_submitting); + idle_timer(NULL); assert(queue_calls == before); +} + +static void stop_during_submit(void) +{ + assert(s_submitting && s_queued); + assert(web_httpd_idle_detach(&server) == ESP_ERR_TIMEOUT); + assert(s_server == &server && !s_accepting && s_submitting); + assert(web_httpd_idle_prepare() == ESP_ERR_INVALID_STATE); +} + +int main(void) +{ + reset(); timer_create_error = ESP_ERR_NO_MEM; + assert(web_httpd_idle_prepare() == ESP_ERR_NO_MEM && !s_timer && !s_server); + timer_create_error = ESP_OK; timer_start_error = ESP_FAIL; + assert(web_httpd_idle_prepare() == ESP_FAIL && !s_timer && timer_deletes == 1); + timer_start_error = ESP_OK; start(); + assert(timer_creates == 3 && timer_starts == 2); + stop(); start(); assert(timer_creates == 3); stop(); + puts("PASS timer failure cleanup/retry and one persistent timer across restart"); + + reset(); start(); + for (unsigned i = 0; i < 6; ++i) connect_slot(i, -1); + sockets[4].ws_handshake_done = sockets[5].ws_handshake_done = true; + tick(0); tick(14999999); assert(!shutdown_calls); + tick(15000000); assert(shutdown_calls == 4); + tick(16000000); assert(shutdown_calls == 4); + for (unsigned i = 0; i < 4; ++i) { + char byte; assert(recv(peers[i], &byte, 1, MSG_DONTWAIT) == 0); + /* HTTPD's next read owns TLS/free/slot retirement, not the sweep. */ + assert(sockets[i].fd >= 0); drop(i); + } + connect_slot(0, -1); sockets[0].ws_handshake_done = true; /* newly admitted admin */ + tick(60000000); assert(shutdown_calls == 4); stop(); + puts("PASS six full slots: only four expired ordinary sockets shut down; two serial and new admin WS survive"); + + reset(); start(); connect_slot(0, -1); tick(0); + for (unsigned i = 1; i <= 12; ++i) { now_us = (int64_t)i * 5000000; complete(0); } + assert(responses == 12 && !shutdown_calls && s_rows[0].idle_since_us == now_us); + tick(now_us + 15000000); assert(shutdown_calls == 1); stop(); + puts("PASS actual SDK completion marker refreshes five-second ordinary polling without TLS churn"); + + reset(); start(); connect_slot(0, -1); tick(0); + assert(send(peers[0], "G", 1, 0) == 1); tick(15000000); assert(!shutdown_calls); + char byte; assert(recv(sockets[0].fd, &byte, 1, 0) == 1); + now_us = 16000000; complete(0); tick(30000000); assert(!shutdown_calls); + tick(31000000); assert(shutdown_calls == 1); stop(); + puts("PASS control-before-data ordering: readable incomplete next request is not expired"); + + for (unsigned mode = 0; mode < 4; ++mode) { + reset(); start(); connect_slot(0, -1); tick(0); + if (mode == 0) sockets[0].pending_len = 1; + if (mode == 1) tls_pending[sockets[0].fd] = 1; + if (mode == 2) tls_pending[sockets[0].fd] = -1; + if (mode == 3) select_error = 1; + tick(15000000); tick(60000000); assert(!shutdown_calls); + sockets[0].pending_len = 0; tls_pending[sockets[0].fd] = select_error = 0; + tick(74999999); assert(!shutdown_calls); tick(75000000); assert(shutdown_calls == 1); stop(); + } + puts("PASS HTTPD pipeline/TLS buffered input and TLS/select errors conservatively restart idle window"); + + reset(); start(); connect_slot(0, -1); tick(0); + handler_delay = 40000000; purge_delay = 10000000; leftover = 64; + now_us = 14000000; complete(0); + assert(now_us == 74000000 && purges == 2 && responses == 1 && !shutdown_calls); + assert(s_rows[0].completed == 1 && s_rows[0].idle_since_us == now_us); + tick(88999999); assert(!shutdown_calls); tick(89000000); assert(shutdown_calls == 1); stop(); + puts("PASS slow parser/handler/response plus real SDK leftover purge: queued probe observes completion only afterwards"); + + for (unsigned mode = 0; mode < 2; ++mode) { + reset(); start(); connect_slot(0, -1); tick(0); + request_fail = mode == 0; purge_fail = mode == 1; leftover = 64; + handler_delay = 60000000; complete(0); + assert(sockets[0].fd == -1 && server.lru_counter == 0 && !shutdown_calls); stop(); + } + puts("PASS request/purge failure deletion precedes work and never publishes successful completion"); + + reset(); start(); connect_slot(0, -1); tick(0); now_us = 14000000; + upgrade = true; complete(0); tick(60000000); + assert(sockets[0].ws_handshake_done && !s_rows[0].observed && !shutdown_calls); stop(); + reset(); start(); connect_slot(0, -1); tick(0); + sockets[0].for_async_req = true; tick(60000000); assert(!shutdown_calls && !s_rows[0].observed); + sockets[0].for_async_req = false; tick(61000000); tick(75999999); assert(!shutdown_calls); + tick(76000000); assert(shutdown_calls == 1); stop(); + puts("PASS admission upgrade classification before idle publication and async response exemption"); + + reset(); start(); connect_slot(0, -1); tick(0); + int fd = sockets[0].fd; drop(0); now_us = 14000000; connect_slot(0, fd); + /* Both old and new lru=0, same fd, TLS pointer and sock_db address. */ + tick(15000000); assert(!shutdown_calls && s_rows[0].idle_since_us == 15000000); + tick(30000000); assert(shutdown_calls == 1); + drop(0); connect_slot(0, fd); sockets[0].ws_handshake_done = true; + tick(90000000); assert(shutdown_calls == 1); stop(); + puts("PASS identical fd/TLS/sock_db/LRU reuse resets identity; no late shutdown targets replacement WS"); + + reset(); start(); connect_slot(0, -1); tick(0); + shutdown_error = 1; tick(15000000); assert(shutdown_calls == 1 && !s_rows[0].shutdown_sent); + shutdown_error = 0; tick(16000000); assert(shutdown_calls == 2 && s_rows[0].shutdown_sent); + tick(17000000); assert(shutdown_calls == 2); stop(); + puts("PASS failed shutdown retries, successful shutdown is latched until normal owner deletion"); + + reset(); start(); connect_slot(0, -1); tick(0); + queue_error = ESP_FAIL; tick(15000000); tick(16000000); + assert(!s_queued && !s_submitting && !shutdown_calls); + queue_error = ESP_OK; tick(17000000); assert(shutdown_calls == 1); stop(); + puts("PASS queue failures release reservations and later probe enforces unchanged deadline"); + + reset(); start(); connect_slot(0, -1); + idle_timer(NULL); void *old_arg = queued_arg; + for (unsigned i = 0; i < 100; ++i) idle_timer(NULL); + assert(queue_calls == 1 && s_queued); pump(); + inline_work = true; queue_hook = inline_submit_hook; + idle_timer(NULL); assert(queue_calls == 2 && !s_submitting && !s_queued); + queue_hook = NULL; inline_work = false; stop(); + puts("PASS at most one probe; callback-before-submit-return cannot clear or submit a newer probe"); + + reset(); start(); connect_slot(0, -1); tick(0); + now_us = 20000000; queue_hook = stop_during_submit; + idle_timer(NULL); queue_hook = NULL; + assert(!s_submitting && s_queued && !s_accepting && !shutdown_calls); + assert(web_httpd_idle_detach(&server) == ESP_OK); + /* Failed SSL stop: MUST NOT call stopped, prepare/restart remains blocked. */ + assert(web_httpd_idle_prepare() == ESP_ERR_INVALID_STATE); + assert(web_httpd_idle_attach(&server) == ESP_ERR_INVALID_STATE); + pump(); assert(!s_queued && !shutdown_calls && s_server == &server); + unsigned calls = queue_calls; tick(60000000); assert(queue_calls == calls); + stop(); start(); assert(s_accepting); stop(); + puts("PASS in-flight submit fence timeout forbids stop; failed stop disables probes but retains ownership until retry"); + + reset(); start(); connect_slot(0, -1); tick(0); idle_timer(NULL); old_arg = queued_arg; + assert(web_httpd_idle_detach(&server) == ESP_OK && s_queued); + /* Successful SDK stop may discard, rather than run, the queued work. */ + queued_work = NULL; queued_arg = NULL; drop(0); web_httpd_idle_stopped(&server); + assert(!s_queued); start(); connect_slot(0, -1); idle_timer(NULL); + owner = true; idle_work(old_arg); + assert(s_queued && queued_work && !shutdown_calls); pump(); stop(); + puts("PASS successful stop retires discarded queue; stale generation cannot act on reused server or clear new probe"); + + reset(); start(); connect_slot(0, -1); tick(0); + tls_identity.get_error = true; + esp_https_server_user_cb_arg_t arg = {.tls = &tls_identity, .user_cb_state = HTTPD_SSL_USER_CB_SESS_CREATE}; + web_httpd_idle_tls(&arg); assert(!s_rows[0].observed); + tick(15000000); assert(!shutdown_calls); stop(); + puts("PASS unavailable TLS identity fails conservatively, independent of diagnostics"); + + reset(); start(); connect_slot(0, -1); tick(0); now_us = 60000000; + owner = false; web_httpd_idle_sweep(&server, s_rows, now_us); assert(!shutdown_calls); + owner = true; server.config.max_open_sockets = 7; + web_httpd_idle_sweep(&server, s_rows, now_us); assert(!shutdown_calls); + server.config.max_open_sockets = 6; sockets[0].ws_close = true; + tick(now_us); assert(!shutdown_calls); stop(); + s_generation = UINTPTR_MAX; + assert(web_httpd_idle_prepare() == ESP_ERR_INVALID_STATE); + assert(web_httpd_idle_attach(&server) == ESP_ERR_INVALID_STATE); + puts("PASS owner/capacity/closing-WS guards and nonwrapping restart generation"); + reset(); start(); idle_timer(NULL); + /* TLS establishment occupies HTTPD synchronously; only timer task runs. */ + for (unsigned i = 0; i < 5; ++i) { now_us += 1000000; idle_timer(NULL); } + assert(queue_calls == 1 && !shutdown_calls && !s_rows[0].observed); + connect_slot(0, -1); pump(); + assert(s_rows[0].idle_since_us == 5000000); + tick(19999999); assert(!shutdown_calls); tick(20000000); assert(shutdown_calls == 1); stop(); + puts("PASS serialized TLS handshake delays probe; post-TLS unused connection gets a fresh full idle window"); + + reset(); start(); connect_slot(0, -1); tick(0); idle_timer(NULL); + /* Nonblocking IDF control transport is UDP: success is not an execution + * acknowledgement. Never time out the reservation and accumulate probes. */ + queued_work = NULL; queued_arg = NULL; calls = queue_calls; + tick(60000000); tick(120000000); + assert(s_queued && queue_calls == calls && !shutdown_calls); + stop(); start(); connect_slot(0, -1); tick(120000000); + tick(135000000); assert(shutdown_calls == 1); stop(); + puts("PASS accepted-but-lost UDP work remains bounded/fail-safe; successful stop/restart restores probing"); + puts("18 idle lifecycle groups passed (real socket IO, installed SDK completion/purge, deterministic TLS/scheduler)"); + return 0; +}