diff --git a/docs/agent/architecture.md b/docs/agent/architecture.md index 6647441..4ac265c 100644 --- a/docs/agent/architecture.md +++ b/docs/agent/architecture.md @@ -84,6 +84,8 @@ UART RX -> serial-service RX stream -> broker task The broker drains serial input even with no clients. A full client output stream drops only that client's copy and updates drop counters; it does not block UART reception or other clients. +Active-client counter snapshots expose ID/type/pending/HWM/UART/queued/read/dropped. HWM is maintained independently of web tracing and counter clear seeds current occupancy. Read means transport handoff, not peer receipt. Disconnect removes the client row while global totals retain traffic and unread-output discards. Independent default-disabled web performance capture uses two fixed slot records and nonwrapping epoch/generation fences; toggles fence in-flight samples, disable freezes aggregates, clear preserves enable state. Binary-only timestamps bound reservation-entry to callback-entry before locking, synchronous send calls and completion to broker-read return; idle and intervening work prevent scheduler-only/backlog-at-completion claims. No new instrumentation allocations or scheduling/buffer changes. Exact fields, overhead and capture contracts: `docs/web_throughput_diagnostics.md`. + ### Clients to RS-232 ```text diff --git a/docs/agent/code-map.md b/docs/agent/code-map.md index 50e965a..758676d 100644 --- a/docs/agent/code-map.md +++ b/docs/agent/code-map.md @@ -43,6 +43,7 @@ This is a semantic map, not a complete file inventory. Start here, then read the - Data path: `transport -> broker -> serial service -> UART1`; reverse data is fanned out per client. - Ownership: client IDs are slot/generation-safe; events are advisory and can drop, so use snapshots as authority. - Lifecycle: one permanent task and eight preallocated client slots; slow output drops only for the affected client. +- Diagnostics: `broker counters` adds active-client ID/type/pending/HWM/UART/queued/read/dropped rows; clear seeds HWM from pending, disconnect removes rows but retains global discard accounting. `tests/session_broker_diagnostics/run.py`; capture before disconnect, never use consuming `broker read` as a probe. Semantics/recipe: `docs/web_throughput_diagnostics.md`. ## Native USB CDC @@ -66,6 +67,7 @@ This is a semantic map, not a complete file inventory. Start here, then read the - 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`. +- Independent throughput diagnostics: `web_serial_transport.{c,h}` owns two fixed per-slot binary-TX aggregates and epoch fences; `web_console.c` exposes default-disabled `web performance enable|disable|show|clear`. Queue-entry/callback-entry, synchronous-send and completion/drain-return estimates, not peer receipt or scheduler-only latency. `tests/web_serial_performance/run.py`; resource/evidence limits and UART0 paired capture: `docs/web_throughput_diagnostics.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`. - Legacy removal **user-signed-off 2026-09-08** (unchanged certificate fingerprint, preexisting users usable, full-mix evidence): `user_database` persists missing storage empty and preserves valid v1 user bytes; private derived `v1_admin_marker`, no public bootstrap/migration/sync APIs. `web_security` privately migrates v1 1392-byte material to TLS-only v2 1340-byte material, exact identity/generation retained, commit before publish, fail closed without fallback overwrite. Credential commands removed; user generated passwords and TLS rotation remain. Contracts, downgrade and evidence limits: `docs/legacy_credential_removal.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}`. diff --git a/docs/agent/current-state.md b/docs/agent/current-state.md index e78e6dc..87879e6 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 +- **Web throughput diagnostics handoff (2026-09-08), target unmeasured:** Broker active-client ID/type/pending/HWM/UART/queued/read/dropped rows and independent default-disabled `web performance enable|disable|show|clear` now documented against source. Two fixed web slots, epoch/generation fencing, binary-only queue-entry/callback, synchronous-send and completion/drain timing aggregates; nonempty intervals include idle and are not scheduler-only or proof of completion-time backlog. No CPU160MHz/scheduling/priority/4096-byte broker/512-byte web buffer tuning or new instrumentation allocations. User burst71,292 × four=285,168 expected,265,852 queued,19,316 dropped; browser142,584−123,268 matches, without reported UART loss/disconnect/new login. One-browser no-drop report is not a measured paired sample. Parent broker7/performance suite, console boundary/lifecycle/policy (new SSH-allowed/browser-denied command tests), transport25/tickets12, server lifecycle21, admission diagnostics12+guard, cookie Network+shared PASS; review no confirmed actionable bugs. Parent pio run PASS9.07s,100,100 B RAM/1,748,161 B flash (+552/+3,836 vs Settings presentation), diff check PASS. Fixed extracted console fixture missing new handler double; no production behavior fix. Host sanitizer libraries missing, no sanitizer pass. Real scheduling/overhead/stack margins unmeasured. UART0 quiet/settled full-mix and one-browser capture recipe, before-disconnect attribution, enabled/disabled comparison, 177.8ms retention scale and limits: `docs/web_throughput_diagnostics.md`. Documentation-only pass; no source/tests/assets/device/build/commit action. + - **8D.12/8D.13 + Settings presentation functionally signed off (2026-09-08):** User explicitly accepts after 60-second boot/full-mix telemetry. Web writer8, web17/SSH10/USB11 observers, both admins, two cookies at230400 8N1 RTS/CTS DTR active. Web send/queue/protocol/close-failure0, SSH handshake/auth/IO failures0; retain SSH rejected1 byte, web rejected1 frame/1 byte, serial connect3/disconnect1, cookie login3/logout1, authfailures2 boot/3 loaded without causal diagnosis. Loaded free internal/DMA/PSRAM31,512/23,756/8,112,140 B; minima2,276/156/8,070,736 B; largest20,480/20,480/7,995,392 B. SSH stack min18,468 boot/16,276 loaded. Exact samples/counters/limits: `docs/phase8d12_13_implementation.md`. Supersedes target-pending/no-signoff wording below for implemented phases and visual refinements. Very low lifetime internal/DMA minima remain transient-headroom follow-up (conservative non-simultaneous region sums, not proof allocation failed); HTTPD/dispatcher floors/peak correlation and numeric reserves unapproved, not reopening functional acceptance. No invented individual checklist/soak/full-M3 pass, no 8D.14 authorization. Documentation only, no source/config/build/test/device/assets/commit action. - **Settings presentation unified (2026-09-08), user-requested visual refinement:** Accounts/Network now use Serial-style 600px label/value definition lists, shared form styling, compact muted help, consistent Refresh/result labels and action grouping. Public-key textarea/generated-password fields styled; checkboxes intrinsic-width. Safe DOM text replaces preformatted summaries; readable ASCII SSID/hex fallback retained, pre-wrap preserves significant spaces (review finding fixed with rendered-width regression). IDs/events/auth/mutations/secret cleanup/terminal ownership unchanged; no backend/assets changes. Parent UI100 behavior groups plus HTML/renderer/CSP and headless Chromium geometry/whitespace checks at320/600/1200px PASS; pio run PASS23.69s, 99,548 B RAM / 1,744,325 B flash (+0/+1,744 vs preceding ASCII-summary build). Diff check PASS. Fixtures/browser layout checks are not target visual sign-off or live Wi-Fi validation. No upload/erase/commit. diff --git a/docs/agent/design-decisions.md b/docs/agent/design-decisions.md index f4478c4..cd18568 100644 --- a/docs/agent/design-decisions.md +++ b/docs/agent/design-decisions.md @@ -28,6 +28,8 @@ Only constraints supported by implementation or current project documentation be **Consequence for future changes:** Do not replace fan-out with a blocking shared queue. Any added transport must tolerate partial/no-progress reads and expose drop/backpressure counters. +**Throughput observation, not tuning:** Preserve CPU160MHz, scheduling/priorities and 4096/512-byte broker/web buffers while gathering per-client HWM/drop attribution and independent opt-in web binary-TX timing. Fixed-slot epoch/generation-fenced aggregates avoid stale attribution; no new runtime allocations. Clear preserves queued data and seeds broker HWM; disconnected rows disappear while global discard counts remain. Callback timestamps precede the transport lock; synchronous send return is not peer receipt. Completion-to-read intervals include broker/mutex/control work and possible idle, even when the first read is nonempty; never label them pure scheduling latency or proof of backlog at completion. Compare enabled/disabled target captures before drawing overhead conclusions. Contracts and reproduction: `docs/web_throughput_diagnostics.md`. + **Relevant files:** `src/session_broker.c`, `src/session_broker.h`, `docs/roadmap.md` ## Physical UART ownership and logical writer ownership remain separate diff --git a/docs/command_reference.md b/docs/command_reference.md index a2b2b2c..fe68160 100644 --- a/docs/command_reference.md +++ b/docs/command_reference.md @@ -66,7 +66,7 @@ Defaults are 115200 baud, 8 data bits, no parity, one stop bit, no flow control, | Command | Description | |---|---| | `broker status` / `broker clients` | Show broker state or connected clients. | -| `broker counters` / `broker clear-counters` | Show or clear broker counters. | +| `broker counters` / `broker clear-counters` | Show global totals plus active-client ID/type/pending/HWM/UART/queued/read/dropped; clear counters and seed HWM from current pending bytes without draining output. | | `broker connect ` / `broker disconnect ` | Create or remove a console test client. | | `broker request-writer ` / `broker release-writer ` | Request or relinquish the single writer lease. | | `broker force-writer ` | Administratively assign or clear the writer lease. | @@ -74,7 +74,7 @@ Defaults are 115200 baud, 8 data bits, no parity, one stop bit, no flow control, | `broker read [maximum-bytes]` | Read queued serial output for a client. | | `broker events ` | Show ownership and connection events for a client. | -Each client has a generation-safe ID. There can be one writer and multiple observers; a slow observer loses only its own queued output. +Each client has a generation-safe ID. There can be one writer and multiple observers; a slow observer loses only its own queued output. HWM is bounded by 4,096 bytes; read counts transport handoff, not peer receipt. Disconnected client rows disappear, while global totals retain traffic and unread-output discards until cleared. Capture counters before disconnect. `broker read` consumes queued data and must not be used as a throughput diagnostic probe. See [Web throughput diagnostics](web_throughput_diagnostics.md). ## Native USB CDC-ACM @@ -126,11 +126,15 @@ When the Wi-Fi station receives an IPv4 address, the Wi-Fi manager announces `sa | `web` / `web help` | Show web-service command usage. | | `web status` | Show HTTPS and WebSocket state. | | `web start` / `web stop` | Start or stop HTTPS service. | -| `web counters` / `web clear-counters` | Show or clear web counters. | +| `web counters` / `web clear-counters` | Show or clear ordinary HTTPS/serial WebSocket counters, independently of performance capture. | +| `web diagnostics enable\|disable\|show\|clear` | Independent opt-in admission tracing and post-TLS occupancy; not serial throughput timing. | +| `web performance enable\|disable\|show\|clear` | Default-disabled per-slot binary TX timing/count aggregates. Disable freezes; enable resumes; clear preserves enabled state. Each control operation fences in-flight samples with a new epoch. All actions print a snapshot. | | `web certificate info` | Display certificate identity and fingerprint. | | `web certificate rotate --force` | Replace the HTTPS certificate and private key. Browser admin defers commit and HTTPS restart; both browser routes close and new certificate trust/relogin is required. UART0/admin SSH behavior is unchanged. | | `web reset --force` | Explicitly replace HTTPS certificate/private key only, including missing, incompatible or damaged material; never changes users. | +Use UART0 for quiet throughput captures (authenticated admin SSH also exposes these commands). Performance output includes current-epoch pending age and count/sum/estimated-average/max timings for queue entry to callback entry, synchronous send, and completion to drain return. These are not peer acknowledgements or scheduler-only measurements; nonempty intervals may include idle. No secrets or serial payloads are printed. See [Web throughput diagnostics](web_throughput_diagnostics.md) for exact semantics, saturation limits and the reset/burst/drain/disable/capture-before-disconnect recipe. + HTTPS listens on port 443 only. Authenticate with any current user-database username/password; both `user` and `admin` roles receive the existing status and browser-terminal interface. The device serves vendored xterm.js without Internet access. Browser sessions use one-time account-bound tickets, binary WebSocket frames, and the broker's one-writer rule. The combined **Connect**/**Disconnect** control closes the current WebSocket and pauses automatic reconnect when active; after a user-paused disconnect, it changes to **Connect** to resume connection attempts. Account mutations revoke only that account's tickets and sessions. ## SSH serial transport diff --git a/docs/web_throughput_diagnostics.md b/docs/web_throughput_diagnostics.md new file mode 100644 index 0000000..cd0a078 --- /dev/null +++ b/docs/web_throughput_diagnostics.md @@ -0,0 +1,79 @@ +# Web serial throughput diagnostics + +## Scope and evidence (2026-09-08) + +This is instrumentation, not throughput tuning or a confirmed scheduler diagnosis. UART0 recovery, network-independent native USB, binary-transparent UART1 transport and the broker's one-writer/isolated-observer model remain unchanged. CPU stays at 160 MHz; scheduling, priorities, 4,096-byte broker output buffers and 512-byte web TX payloads are unchanged. + +The motivating user capture reported a 71,292-byte UART burst with four fan-outs: expected 285,168 bytes, queued 265,852, dropped 19,316. The two browsers' expected 142,584 minus actual 123,268 is exactly 19,316. No UART loss, disconnect or new login was reported during this burst. This is consistent with browser-copy loss at broker fan-out, but the old aggregate counters cannot attribute loss to either browser or identify the cause. The user reports no drops with one browser; that is not a measured paired baseline. + +## Broker accounting + +`broker counters` retains global totals and adds one row per active client: generation-safe ID, type, pending bytes, output high-water mark (HWM), UART bytes considered for that client, queued bytes, read bytes and dropped bytes. `broker clients` helps map the IDs to active transports; `web performance show` supplies each web slot's broker ID. + +- HWM is peak output occupancy, bounded by 4,096 bytes. `broker clear-counters` clears totals but seeds each active HWM with current pending occupancy, not zero. It does not drain queues or change ownership. +- `read` means handed to a transport, not received or rendered by a peer. Queued counts successful copies; UART counts bytes considered while the client was connected. +- Disconnected per-client rows disappear and slot/generation reuse resets them. Global counters retain disconnected traffic until cleared, including accepted-but-unread output discarded on disconnect. Thus global drops need not mean only overflow, and queued plus dropped need not equal UART fan-out after disconnect discards. +- Capture before disconnect. **Do not use `broker read` for observation:** it consumes data intended for that transport. Snapshot/counter commands do not consume serial data. + +## Independent web performance capture + +Use physical UART0 for the least intrusive capture. Commands are also available through the authenticated admin SSH registry. `web performance enable|disable|show|clear` is separate from admission tracing (`web diagnostics enable|disable|show|clear`) and from ordinary `web counters|clear-counters`. + +Performance capture defaults disabled. Enable resumes aggregates; disable freezes aggregates; clear resets aggregates while preserving enable state. Each control operation advances a nonwrapping epoch and fences in-flight samples. Exhaustion fails closed. Slot generation and broker identity also fence reuse; this is not a disconnected-session archive. + +Each active one of two fixed slots shows fd, generation, broker ID, pending/measured-pending/executing state and current-epoch `pending_age_us`. Age starts at reservation-path entry and includes an executing send. If `measured_pending=0`, zero age is unavailable, not evidence of immediate service. Disabling invalidates current-epoch pending age; capture a live `show` only if investigating a stuck queue, accepting its perturbation. + +Only serial **binary TX** is measured. Text/control frames are not samples, though they can delay subsequent binary work. No payloads, passwords, keys, cookies, tickets, verifier material or other authentication secrets are retained or printed. + +| Printed timing | Exact interpretation | +|---|---| +| `queue->callback-entry` | Reservation-path entry to HTTPD callback entry, timestamped before the callback takes the transport lock. Includes submission-path work, not just HTTPD queue residence; owned callbacks retired without sending can count. | +| `send-call` | Time around the actual synchronous HTTPD-owner send API call. Return is not peer acknowledgement, browser receipt or rendering. | +| `completion->first-drain-attempt-return` | Successful send-call completion to return of the first subsequent broker read attempt, including empty/error attempts. | +| `completion->next-nonempty-drain-return (includes idle)` | Completion to the next successful nonempty broker read return, possibly after empty attempts and source idle time. | +| `completion->first-attempt-nonempty-return` | Subset where the first subsequent read returns data. Excludes observed empty attempts, but does not prove backlog existed at send completion. | + +Each timing reports count, sum in microseconds, integer estimated average (`sum/count`, zero for no samples) and maximum. Completion intervals are not scheduler-only: they include transport work, broker read/mutex time and intervening control-frame work, and may include idle time even in the first-attempt-nonempty subset. They do not isolate a particular task, TLS operation or network cause. + +`queued_frames/bytes` counts transport reservations, including reported queue failures; `queue_errors` records those failures. `sent_frames/bytes` counts successful send-call returns. `send_errors` includes owner-context rejection, whereas send-call timings require an actual API call. `retired` records measured callbacks retired without sending. Saturating aggregates set `saturated=1`; totals and averages then cease to be reliable. Epoch fencing intentionally prevents old work contaminating a new capture, so do not demand equality across toggles or in-flight clears. + +## Reproducible UART0 capture + +1. Record firmware/build identity, baud/framing/flow control, source burst size, browser versions, client IDs/roles and full-mix topology. Establish all serial clients and both admin routes first. Quiet the UART source and allow queues and connection activity to settle. Do not reconnect/login during the trace. +2. Optionally stop independent admission tracing before the timed capture. Then reset in this order, while quiet: + + ```text + web diagnostics disable + web performance clear + web performance enable + broker clear-counters + serial clear-counters + web clear-counters + ``` + + The first command is optional; record whether admission tracing was enabled. Resets are sequential, not an atomic cross-service snapshot. +3. Send the known burst once, stop the source and wait for transport queues to drain. Avoid repeated console/status printing during the burst. A quiet final broker pending count of zero alone does not prove browser receipt; retain peer byte counts too. +4. Stop performance capture first, then capture all counters **before disconnecting any client**: + + ```text + web performance disable + web performance show + broker counters + serial counters + web counters + broker clients + ``` + + Disable already prints the performance snapshot; `show` is an explicit repeatable frozen-aggregate record. Keep connection counts and source/peer totals with the sample. +5. Only after saving evidence, change to one browser, keeping the other full-mix participants and serial settings equivalent. Quiet/settle and repeat the complete reset/burst/drain/stop/capture sequence. Do not compare a fresh capture to lifetime counters. +6. Separately compare equivalent performance-enabled and performance-disabled bursts with fresh ordinary counter resets. Keep performance disabled for the latter; do not treat retained performance aggregates as that run's measurements. Keep admission tracing state equal and record it. + +At 230400 baud, 8N1, 4,096 bytes represents approximately **177.8 ms** of continuous input retention from an empty broker queue, ignoring flow-control pauses. Compare per-client drops/HWM with latency maxima against this scale, not as a hard deadline or proof of cause; occupancy, idle gaps and timings cover different boundaries. Target follow-up is per-client attribution and these controlled comparisons, not buffer, priority, scheduler or CPU tuning. + +## Resources and validation handoff + +Two fixed web-slot diagnostic records plus epoch/timestamp/state fields and per-client broker HWM fields add bounded storage; there are no new allocations, tasks, queues or payload buffers for this instrumentation. Disabled is not zero overhead: relaxed gate loads/branches remain, and broker occupancy sampling/HWM maintenance is always active. Enabled timestamping and short locked aggregate updates are bounded. Target runtime overhead, stack margins and throughput effects remain unmeasured. + +Parent integrated validation PASS: `python3 tests/session_broker_diagnostics/run.py` (7 groups), `python3 tests/web_serial_performance/run.py` (production queue/send/drain paths with host doubles, epochs, idle, retirement and saturation; suite-level PASS), console boundary `run.py`/`lifecycle.py`, `tests/admin_ssh_policy/run.py`, admin transport `--tickets` (25 transport/12 ticket groups), server lifecycle (21), admission diagnostics (12+guard) and cookie `--network`. Parent updated the extracted console fixture for the new command and added explicit SSH-allowed/browser-denied performance command policy regressions. Independent review found no confirmed actionable production bugs. Synthetic reproduction is not target measurement; real scheduling/critical-section overhead and delayed original-pointer reuse still rely on existing owner lifecycle guarantees. + +Final parent `pio run` **PASS, 9.07 s**, **100,100 B RAM / 1,748,161 B flash**, **+552 B RAM / +3,836 B flash** against the recorded Settings-presentation build. These are whole-build figures, not isolated record sizes or runtime heap measurements. Parent diff check passed; subsequent policy-test additions change no production source and their reruns passed. Host sanitizer libraries are unavailable; no sanitizer pass is claimed. No upload, erase, commit, generated assets, target acceptance, measured enabled/disabled overhead, lossless paired baseline or tuning result. diff --git a/src/console_completion.c b/src/console_completion.c index edc174e..dea357f 100644 --- a/src/console_completion.c +++ b/src/console_completion.c @@ -186,6 +186,10 @@ static const char *const s_completion_candidates[] = { "web diagnostics disable", "web diagnostics show", "web diagnostics clear", + "web performance enable", + "web performance disable", + "web performance show", + "web performance clear", "web certificate", "web certificate info", "web certificate rotate", diff --git a/src/session_broker.c b/src/session_broker.c index 0b0f77b..70376ed 100644 --- a/src/session_broker.c +++ b/src/session_broker.c @@ -149,6 +149,10 @@ static void fan_out_rx_locked(const uint8_t *data, size_t size) slot->counters.uart_rx_bytes += size; size_t queued = xStreamBufferSend(slot->output, data, size, 0); + size_t pending = xStreamBufferBytesAvailable(slot->output); + if (pending > slot->counters.output_high_water_bytes) { + slot->counters.output_high_water_bytes = pending; + } size_t dropped = size - queued; slot->counters.output_queued_bytes += queued; slot->counters.output_dropped_bytes += dropped; @@ -740,6 +744,8 @@ esp_err_t session_broker_clear_counters(void) memset(&s_counters, 0, sizeof(s_counters)); for (size_t i = 0; i < SESSION_BROKER_MAX_CLIENTS; ++i) { memset(&s_slots[i].counters, 0, sizeof(s_slots[i].counters)); + s_slots[i].counters.output_high_water_bytes = + xStreamBufferBytesAvailable(s_slots[i].output); } xSemaphoreGive(s_mutex); return ESP_OK; @@ -758,6 +764,7 @@ esp_err_t session_broker_clear_client_counters(session_broker_client_id_t client return ESP_ERR_NOT_FOUND; } memset(&slot->counters, 0, sizeof(slot->counters)); + slot->counters.output_high_water_bytes = xStreamBufferBytesAvailable(slot->output); xSemaphoreGive(s_mutex); return ESP_OK; } diff --git a/src/session_broker.h b/src/session_broker.h index 3a99feb..476673b 100644 --- a/src/session_broker.h +++ b/src/session_broker.h @@ -49,6 +49,11 @@ typedef struct { session_broker_client_id_t writer_id; } session_broker_event_t; +/* + * Active-client counters cover this connection (or the last counter clear). + * They become unavailable on disconnect and reset on slot/generation reuse; + * global totals retain disconnected clients' traffic until explicitly cleared. + */ typedef struct { /* UART bytes considered for delivery while this client was connected. */ uint64_t uart_rx_bytes; @@ -67,13 +72,18 @@ typedef struct { uint64_t events_queued; uint64_t events_popped; uint64_t event_drops; + /* Peak output occupancy, <= SESSION_BROKER_OUTPUT_SIZE; clear seeds pending. */ + size_t output_high_water_bytes; } session_broker_client_counters_t; typedef struct { uint64_t uart_rx_bytes; /* UART RX drained when there were no connected observers. */ uint64_t unobserved_rx_bytes; - /* Queue/read/drop totals count one copy per client observer. */ + /* Queue/read/drop totals count one copy per client observer. + * Drops include full-buffer losses and unread output discarded on disconnect. + * Read means handed to a transport, not confirmed delivery to its peer. + */ uint64_t output_queued_bytes; uint64_t output_read_bytes; uint64_t output_dropped_bytes; @@ -158,7 +168,9 @@ esp_err_t session_broker_get_global_snapshot(session_broker_global_snapshot_t *s size_t session_broker_list_clients(session_broker_client_snapshot_t *clients, size_t capacity); -/* Counter clearing does not reset client IDs, queued data, or event sequence. */ +/* Counter clearing does not reset client IDs, queued data, or event sequence. + * Client output high-water marks restart at current queued occupancy, not zero. + */ esp_err_t session_broker_clear_counters(void); esp_err_t session_broker_clear_client_counters(session_broker_client_id_t client_id); diff --git a/src/session_console.c b/src/session_console.c index 4e143d3..8deb6d2 100644 --- a/src/session_console.c +++ b/src/session_console.c @@ -182,6 +182,26 @@ static int show_counters(void) counter->events_queued, counter->events_popped, counter->event_drops); + + session_broker_client_snapshot_t clients[SESSION_BROKER_MAX_CLIENTS]; + size_t count = session_broker_list_clients(clients, SESSION_BROKER_MAX_CLIENTS); + printf("Active clients (since connect/clear; lost on disconnect; global totals retained):\n"); + printf("Output bytes: HWM <= %u; clear seeds pending; read = handed to transport.\n", + (unsigned int)SESSION_BROKER_OUTPUT_SIZE); + printf("Global dropped also includes unread output discarded on disconnect.\n"); + printf("ID type pending HWM UART queued read dropped\n"); + for (size_t index = 0; index < count; ++index) { + const session_broker_client_snapshot_t *client = &clients[index]; + printf("%-10lu %-9s %-7u %-7u %" PRIu64 " %" PRIu64 " %" PRIu64 " %" PRIu64 "\n", + (unsigned long)client->id, + client_type_name(client->type), + (unsigned int)client->output_bytes_pending, + (unsigned int)client->counters.output_high_water_bytes, + client->counters.uart_rx_bytes, + client->counters.output_queued_bytes, + client->counters.output_read_bytes, + client->counters.output_dropped_bytes); + } return 0; } diff --git a/src/web_console.c b/src/web_console.c index f3f9d9f..b96cb64 100644 --- a/src/web_console.c +++ b/src/web_console.c @@ -24,6 +24,7 @@ static void print_usage(void) printf(" web status|start|stop\n"); printf(" web counters|clear-counters\n"); printf(" web diagnostics enable|disable|show|clear\n"); + printf(" web performance enable|disable|show|clear\n"); printf(" web certificate info\n"); printf(" web certificate rotate --force\n"); printf(" web reset --force (TLS certificate and private key only)\n"); @@ -295,12 +296,60 @@ static int reset_material(void) return 0; } +static void print_performance_time(const char *name, const web_serial_performance_timing_t *t) +{ + printf(" %s: count=%" PRIu64 " sum_us=%" PRIu64 " avg_us_est=%" PRIu64 " max_us=%" PRIu64 "\n", + name, t->count, t->sum_us, t->count ? t->sum_us / t->count : 0, t->max_us); +} + +static int performance_command(const char *action) +{ + esp_err_t result = ESP_OK; + if (!strcmp(action, "enable")) result = web_serial_performance_enable(true); + else if (!strcmp(action, "disable")) result = web_serial_performance_enable(false); + else if (!strcmp(action, "clear")) result = web_serial_performance_clear(); + else if (strcmp(action, "show")) return 1; + if (result != ESP_OK) { + printf("Web performance: %s\n", esp_err_to_name(result)); + return 1; + } + web_serial_performance_snapshot_t s; + web_serial_performance_snapshot(&s); + printf("Web performance: enabled=%u epoch=%" PRIu32 " epoch_exhausted=%u; binary TX only\n", + s.enabled, s.epoch, s.epoch_exhausted); + printf("Send-call return is synchronous HTTPD-owner bytes send, not peer receipt. Timings are instrumented estimates; saturated=1 invalidates averages/count totals.\n"); + printf("Completion->nonempty includes idle gaps; first-nonempty excludes observed empty attempts, not proof of backlog.\n"); + for (unsigned i = 0; i < WEB_SERIAL_TRANSPORT_MAX_SESSIONS; ++i) { + const web_serial_performance_session_t *r = &s.sessions[i]; + if (!r->active) continue; + printf("slot=%u fd=%d generation=%" PRIu32 " broker=%" PRIu32 + " pending=%u measured_pending=%u executing=%u pending_age_us=%" PRIu64 " saturated=%u\n", + i, r->socket_fd, r->generation, (uint32_t)r->broker_client_id, + r->pending, r->measured_pending, r->executing, r->pending_age_us, r->saturated); + printf(" queued_frames=%" PRIu64 " queued_bytes=%" PRIu64 " queue_errors=%" PRIu64 + " sent_frames=%" PRIu64 " sent_bytes=%" PRIu64 " send_errors=%" PRIu64 " retired=%" PRIu64 "\n", + r->queued_frames, r->queued_bytes, r->queue_errors, r->sent_frames, + r->sent_bytes, r->send_errors, r->retired); + print_performance_time("queue->callback-entry", &r->queue_wait); + print_performance_time("send-call", &r->send_call); + print_performance_time("completion->first-drain-attempt-return", &r->completion_attempt); + print_performance_time("completion->next-nonempty-drain-return (includes idle)", &r->completion_nonempty); + print_performance_time("completion->first-attempt-nonempty-return", &r->completion_first_nonempty); + } + return 0; +} + static int command_web(int argc, char **argv) { if (argc == 1 || (argc == 2 && strcmp(argv[1], "help") == 0)) { print_usage(); return 0; } + if (argc == 3 && strcmp(argv[1], "performance") == 0) { + if (performance_command(argv[2]) == 0) return 0; + print_usage(); + return 1; + } if (argc == 3 && strcmp(argv[1], "diagnostics") == 0) { if (web_diagnostics_command(argv[2]) == 0) return 0; print_usage(); diff --git a/src/web_serial_transport.c b/src/web_serial_transport.c index 5642d7b..b69e9ac 100644 --- a/src/web_serial_transport.c +++ b/src/web_serial_transport.c @@ -86,6 +86,10 @@ typedef struct web_serial_slot { uint8_t rx_data[WEB_SERIAL_TRANSPORT_MAX_RX_PAYLOAD]; uint8_t tx_data[WEB_SERIAL_TRANSPORT_TX_PAYLOAD_SIZE]; web_serial_work_t work; + web_serial_performance_session_t performance; + uint32_t performance_epoch, completion_epoch; + int64_t queued_us, completed_us; + bool completion_waiting, completion_attempted; } web_serial_slot_t; static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED; @@ -103,6 +107,88 @@ static uint32_t s_inflight_handlers; /* Cancels ticket publication across revocation and server detach/re-attach. * Never wraps: exhaustion disables minting for the remainder of the boot. */ static uint64_t s_ticket_epoch; +/* Atomic gate permits a true pre-lock callback-entry timestamp. Never reused. */ +static uint32_t s_performance_gate, s_performance_epoch; + +static uint32_t performance_gate(void) +{ + return __atomic_load_n(&s_performance_gate, __ATOMIC_RELAXED); +} + +static void performance_add(web_serial_slot_t *slot, uint64_t *value, uint64_t n) +{ + if (UINT64_MAX - *value < n) { + *value = UINT64_MAX; + slot->performance.saturated = true; + } else *value += n; +} + +static void performance_time(web_serial_slot_t *slot, + web_serial_performance_timing_t *value, + int64_t start, int64_t end) +{ + uint64_t elapsed = end >= start ? (uint64_t)(end - start) : 0; + performance_add(slot, &value->count, 1); + performance_add(slot, &value->sum_us, elapsed); + if (elapsed > value->max_us) value->max_us = elapsed; +} + +static esp_err_t performance_control(bool enabled, bool clear) +{ + taskENTER_CRITICAL(&s_lock); + if (clear) enabled = performance_gate() != 0; + if (s_performance_epoch == UINT32_MAX) { + __atomic_store_n(&s_performance_gate, 0, __ATOMIC_RELAXED); + taskEXIT_CRITICAL(&s_lock); + return ESP_ERR_INVALID_STATE; + } + ++s_performance_epoch; + __atomic_store_n(&s_performance_gate, enabled ? s_performance_epoch : 0, + __ATOMIC_RELAXED); + for (unsigned i = 0; i < WEB_SERIAL_TRANSPORT_MAX_SESSIONS; ++i) { + s_slots[i].completion_waiting = false; + if (clear) memset(&s_slots[i].performance, 0, sizeof(s_slots[i].performance)); + } + taskEXIT_CRITICAL(&s_lock); + return ESP_OK; +} + +esp_err_t web_serial_performance_enable(bool enabled) +{ + return performance_control(enabled, false); +} + +esp_err_t web_serial_performance_clear(void) +{ + return performance_control(false, true); +} + +esp_err_t web_serial_performance_snapshot(web_serial_performance_snapshot_t *out) +{ + if (!out) return ESP_ERR_INVALID_ARG; + taskENTER_CRITICAL(&s_lock); + uint32_t gate = performance_gate(); + int64_t now = gate ? esp_timer_get_time() : 0; + *out = (web_serial_performance_snapshot_t){ + .enabled = gate != 0, .epoch = s_performance_epoch, + .epoch_exhausted = s_performance_epoch == UINT32_MAX}; + for (unsigned i = 0; i < WEB_SERIAL_TRANSPORT_MAX_SESSIONS; ++i) { + web_serial_slot_t *slot = &s_slots[i]; + web_serial_performance_session_t *row = &out->sessions[i]; + *row = slot->performance; + row->active = slot->state == WEB_SERIAL_SLOT_ACTIVE; + row->socket_fd = slot->socket_fd; + row->generation = slot->generation; + row->broker_client_id = slot->broker_client_id; + row->pending = slot->work_pending; + row->measured_pending = row->pending && gate && slot->performance_epoch == gate; + row->executing = row->measured_pending && row->executing; + row->pending_age_us = row->measured_pending && now >= slot->queued_us ? + (uint64_t)(now - slot->queued_us) : 0; + } + taskEXIT_CRITICAL(&s_lock); + return ESP_OK; +} static esp_err_t identity_is_current(const user_principal_t *principal, web_session_id_t id, bool *current) @@ -377,6 +463,9 @@ static web_serial_slot_t *reserve_slot(httpd_handle_t server, int socket_fd, if (slot->state != WEB_SERIAL_SLOT_FREE) { continue; } + memset(&slot->performance, 0, sizeof(slot->performance)); + slot->performance_epoch = 0; + slot->completion_waiting = false; slot->generation = next_generation(slot->generation); slot->state = WEB_SERIAL_SLOT_RESERVED; slot->server = server; @@ -902,6 +991,8 @@ static void finish_closing_slot_locked(web_serial_slot_t *slot) static void web_serial_send_work(void *argument) { + uint32_t entry_epoch = performance_gate(); + int64_t entry_us = entry_epoch ? esp_timer_get_time() : 0; web_serial_work_t *work = argument; web_serial_slot_t *slot = work != NULL ? work->slot : NULL; if (!slot_pointer_valid(slot)) { @@ -928,6 +1019,14 @@ static void web_serial_send_work(void *argument) generation = work->generation; type = slot->tx_type; length = slot->tx_length; + uint32_t sample_epoch = owned_work && entry_epoch && + entry_epoch == performance_gate() && slot->performance_epoch == entry_epoch && + type == HTTPD_WS_TYPE_BINARY ? entry_epoch : 0; + if (sample_epoch) { + performance_time(slot, &slot->performance.queue_wait, slot->queued_us, entry_us); + slot->performance.executing = valid; + if (!valid) performance_add(slot, &slot->performance.retired, 1); + } if (owned_work && !valid) { slot->work_pending = false; slot->tx_length = 0U; @@ -943,6 +1042,8 @@ static void web_serial_send_work(void *argument) } esp_err_t result = ESP_FAIL; + int64_t send_start = 0, send_end = 0; + bool send_called = false; void *current_context = httpd_sess_get_ctx(server, socket_fd); if (current_context == slot && httpd_ws_get_fd_info(server, socket_fd) == @@ -954,7 +1055,10 @@ static void web_serial_send_work(void *argument) .payload = slot->tx_data, .len = length, }; + send_called = true; + if (sample_epoch && performance_gate() == sample_epoch) send_start = esp_timer_get_time(); result = httpd_ws_send_frame_async(server, socket_fd, &frame); + if (sample_epoch && performance_gate() == sample_epoch) send_end = esp_timer_get_time(); if (result == ESP_OK) { (void)httpd_sess_update_lru_counter(server, socket_fd); } @@ -963,6 +1067,19 @@ static void web_serial_send_work(void *argument) taskENTER_CRITICAL(&s_lock); if (slot->work_pending && slot->generation == generation && work == &slot->work) { + if (sample_epoch && performance_gate() == sample_epoch && + slot->performance_epoch == sample_epoch) { + slot->performance.executing = false; + if (send_called) performance_time(slot, &slot->performance.send_call, send_start, send_end); + if (result == ESP_OK) { + performance_add(slot, &slot->performance.sent_frames, 1); + performance_add(slot, &slot->performance.sent_bytes, length); + slot->completion_epoch = sample_epoch; + slot->completed_us = send_end; + slot->completion_waiting = true; + slot->completion_attempted = false; + } else performance_add(slot, &slot->performance.send_errors, 1); + } slot->work_pending = false; slot->tx_length = 0U; if (result == ESP_OK) { @@ -986,6 +1103,8 @@ static void web_serial_send_work(void *argument) static esp_err_t queue_slot_frame(web_serial_slot_t *slot, uint32_t generation, httpd_ws_type_t type, size_t length) { + uint32_t epoch = performance_gate(); + int64_t queued_us = epoch ? esp_timer_get_time() : 0; httpd_handle_t server; bool prepared = false; @@ -994,6 +1113,13 @@ static esp_err_t queue_slot_frame(web_serial_slot_t *slot, uint32_t generation, slot->generation == generation && !slot->work_pending && !slot->close_requested && slot->server == s_server && length <= sizeof(slot->tx_data)) { + slot->performance_epoch = type == HTTPD_WS_TYPE_BINARY && epoch == performance_gate() ? epoch : 0; + if (slot->performance_epoch) { + slot->queued_us = queued_us; + slot->performance.executing = false; + performance_add(slot, &slot->performance.queued_frames, 1); + performance_add(slot, &slot->performance.queued_bytes, length); + } slot->tx_type = type; slot->tx_length = length; slot->work.slot = slot; @@ -1025,6 +1151,8 @@ static esp_err_t queue_slot_frame(web_serial_slot_t *slot, uint32_t generation, slot->tx_length = 0U; slot->close_requested = true; ++s_counters.queue_failures; + if (epoch && performance_gate() == epoch && slot->performance_epoch == epoch) + performance_add(slot, &slot->performance.queue_errors, 1); } taskEXIT_CRITICAL(&s_lock); notify_transport_task(); @@ -1168,9 +1296,29 @@ static bool prepare_writer_sync(web_serial_slot_t *slot, uint32_t generation, static void drain_binary_output(web_serial_slot_t *slot, uint32_t generation, session_broker_client_id_t client_id) { + uint32_t epoch = performance_gate(); size_t received = 0U; esp_err_t result = session_broker_read(client_id, slot->tx_data, sizeof(slot->tx_data), &received); + int64_t drained_us = epoch && performance_gate() == epoch ? esp_timer_get_time() : 0; + if (epoch) { + taskENTER_CRITICAL(&s_lock); + if (performance_gate() == epoch && slot->completion_epoch == epoch && + slot->generation == generation && slot->broker_client_id == client_id && + slot->state == WEB_SERIAL_SLOT_ACTIVE && slot->completion_waiting) { + bool nonempty = result == ESP_OK && received > 0; + if (!slot->completion_attempted) { + performance_time(slot, &slot->performance.completion_attempt, slot->completed_us, drained_us); + if (nonempty) performance_time(slot, &slot->performance.completion_first_nonempty, slot->completed_us, drained_us); + } + slot->completion_attempted = true; + if (nonempty) { + performance_time(slot, &slot->performance.completion_nonempty, slot->completed_us, drained_us); + slot->completion_waiting = false; + } + } + taskEXIT_CRITICAL(&s_lock); + } if (result == ESP_OK && received > 0U) { (void)queue_slot_frame(slot, generation, HTTPD_WS_TYPE_BINARY, received); } else if (result == ESP_ERR_NOT_FOUND) { diff --git a/src/web_serial_transport.h b/src/web_serial_transport.h index 90d8d96..96a1c11 100644 --- a/src/web_serial_transport.h +++ b/src/web_serial_transport.h @@ -135,6 +135,44 @@ esp_err_t web_serial_transport_revoke_web_session(web_session_id_t id); esp_err_t web_serial_transport_get_snapshot( web_serial_transport_snapshot_t *snapshot); +typedef struct { + uint64_t count, sum_us, max_us; +} web_serial_performance_timing_t; + +typedef struct { + bool active, pending, measured_pending, executing, saturated; + int socket_fd; + uint32_t generation; + session_broker_client_id_t broker_client_id; + uint64_t pending_age_us; + uint64_t queued_frames, queued_bytes, queue_errors; + uint64_t sent_frames, sent_bytes, send_errors, retired; + web_serial_performance_timing_t queue_wait, send_call; + web_serial_performance_timing_t completion_attempt, completion_nonempty; + web_serial_performance_timing_t completion_first_nonempty; +} web_serial_performance_session_t; + +typedef struct { + bool enabled, epoch_exhausted; + uint32_t epoch; + web_serial_performance_session_t sessions[WEB_SERIAL_TRANSPORT_MAX_SESSIONS]; +} web_serial_performance_snapshot_t; + +/* Binary TX only; no broker/HTTPD queries, heap scans, or sensitive identities. + * queued_* counts transport reservations, including reported queue failures; + * sent_* counts successful send-call returns. send_errors includes owner-context + * rejection; send_call timings count only actual API calls. queue_wait includes + * owned callbacks retired without sending. Pending age is since reservation-path + * entry, including an executing send; unavailable epochs are explicitly marked. + * All timing endpoints are local monotonic estimates, never peer acknowledgments. + * Disable freezes aggregates; enable resumes them. Every toggle/clear fences + * in-flight samples. Clear preserves enabled state. Epoch exhaustion fails closed. + * Nonempty completion intervals include idle; first_nonempty requires the first + * subsequent read to return data (not proof of backlog at send completion). */ +esp_err_t web_serial_performance_enable(bool enabled); +esp_err_t web_serial_performance_clear(void); +esp_err_t web_serial_performance_snapshot(web_serial_performance_snapshot_t *out); + /* Clearing counters does not alter tickets, sessions, ownership, or queued data. */ esp_err_t web_serial_transport_clear_counters(void); diff --git a/tests/admin_console_boundary/lifecycle.py b/tests/admin_console_boundary/lifecycle.py index 582bb20..2cb81db 100644 --- a/tests/admin_console_boundary/lifecycle.py +++ b/tests/admin_console_boundary/lifecycle.py @@ -44,6 +44,7 @@ static void vTaskDelay(unsigned delay) { assert(delay==100); ++waits; } #define pdMS_TO_TICKS(ms) (ms) static void print_usage(void) { ++usages; } static int web_diagnostics_command(const char *action) { assert(!strcmp(action, "show")); return 0; } +static int performance_command(const char *action) { return strcmp(action, "show") ? 1 : 0; } static int show_status(void) { assert(false); return 1; } static int show_counters(void) { assert(false); return 1; } static int show_certificate(void) { assert(false); return 1; } @@ -69,6 +70,11 @@ int main(void) { remote=web=false; char *diagnostics[]={"web", "diagnostics", "show"}; assert(command_web(3, diagnostics)==0 && !stops && !scheduled); + char *performance[]={"web", "performance", "show", "extra"}; + assert(command_web(3, performance)==0 && !stops && !scheduled); + assert(command_web(4, performance)==1 && !stops && !scheduled); + performance[2]="invalid"; + assert(command_web(3, performance)==1 && !stops && !scheduled); char *stop[]={"web", "stop"}; remote=web=true; assert(command_web(2,stop)==0 && scheduled==1 && !stops && last_action==ADMIN_CONSOLE_DEFER_WEB_STOP); diff --git a/tests/admin_ssh_policy/run.py b/tests/admin_ssh_policy/run.py index c82998a..fc5a94b 100644 --- a/tests/admin_ssh_policy/run.py +++ b/tests/admin_ssh_policy/run.py @@ -47,6 +47,8 @@ int main(void) { {"", true}, {" ", true}, {" ", true}, {"memory", true}, {"user", true}, {"user list", true}, {"user show bootstrap", true}, {"exit", true}, + {"web performance enable", true}, {"web performance disable", true}, + {"web performance show", true}, {"web performance clear", true}, /* Removed verbs reach the canonical handler, not a bootstrap policy. */ {"user bootstrap", true}, {"user bootstrap extra", true}, {"user recover", false}, {"user recover --force", false}, @@ -75,6 +77,7 @@ int main(void) { const char *web_denied[] = { "web", "web help", "web start", "web stop extra", "web counters", "web clear-counters", "web diagnostics enable", "web diagnostics disable", "web diagnostics show", "web diagnostics clear", + "web performance enable", "web performance disable", "web performance show", "web performance clear", "web credentials show", "web credentials rotate --force", "web certificate info", "web certificate rotate", "web certificate rotate --force extra", "web certificate rotate --force --force", "web certificate rotate --Force", diff --git a/tests/session_broker_diagnostics/fake.h b/tests/session_broker_diagnostics/fake.h new file mode 100644 index 0000000..42873da --- /dev/null +++ b/tests/session_broker_diagnostics/fake.h @@ -0,0 +1,122 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include + +typedef int esp_err_t; +#define ESP_OK 0 +#define ESP_ERR_INVALID_STATE 1 +#define ESP_ERR_INVALID_ARG 2 +#define ESP_ERR_NO_MEM 3 +#define ESP_ERR_NOT_FOUND 4 +#define ESP_ERR_TIMEOUT 5 +static const char *esp_err_to_name(esp_err_t e) { (void)e; return "fake-error"; } +#define MALLOC_CAP_SPIRAM 1 +#define MALLOC_CAP_8BIT 2 +#define MALLOC_CAP_INTERNAL 4 +static size_t allocations; +static void *heap_caps_calloc_prefer(size_t n, size_t s, int choices, ...) { + (void)choices; ++allocations; return calloc(n, s); +} +#define heap_caps_free free + +typedef unsigned TickType_t; +typedef unsigned UBaseType_t; +#define pdTRUE 1 +#define pdPASS 1 +#define portMAX_DELAY UINT32_MAX +#define pdMS_TO_TICKS(ms) (ms) +static int mutex; +typedef int *SemaphoreHandle_t; +static SemaphoreHandle_t xSemaphoreCreateMutex(void) { return &mutex; } +static int xSemaphoreTake(SemaphoreHandle_t m, TickType_t ticks) { + if (*m) { assert(ticks == 0); return 0; } *m = 1; return pdTRUE; +} +static void xSemaphoreGive(SemaphoreHandle_t m) { assert(*m); *m = 0; } +static void vSemaphoreDelete(SemaphoreHandle_t m) { assert(!*m); } + +typedef struct { uint8_t *data; size_t capacity, used; } StaticStreamBuffer_t; +typedef StaticStreamBuffer_t *StreamBufferHandle_t; +static StreamBufferHandle_t xStreamBufferCreateStatic(size_t size, size_t trigger, + uint8_t *data, StaticStreamBuffer_t *s) { + assert(trigger == 1); *s = (StaticStreamBuffer_t){data, size - 1, 0}; return s; +} +static size_t xStreamBufferBytesAvailable(StreamBufferHandle_t s) { + assert(mutex); return s->used; +} +static size_t xStreamBufferSend(StreamBufferHandle_t s, const void *data, size_t size, TickType_t ticks) { + assert(mutex && ticks == 0); + if (size > s->capacity - s->used) size = s->capacity - s->used; + memcpy(s->data + s->used, data, size); s->used += size; return size; +} +static size_t xStreamBufferReceive(StreamBufferHandle_t s, void *data, size_t size, TickType_t ticks) { + assert(mutex && ticks == 0); + if (size > s->used) size = s->used; + memcpy(data, s->data, size); s->used -= size; + memmove(s->data, s->data + size, s->used); return size; +} +static void xStreamBufferReset(StreamBufferHandle_t s) { assert(mutex); s->used = 0; } +static void vStreamBufferDelete(StreamBufferHandle_t s) { (void)s; } + +typedef struct { uint8_t *data; size_t capacity, item_size, used; } StaticQueue_t; +typedef StaticQueue_t *QueueHandle_t; +static QueueHandle_t xQueueCreateStatic(size_t capacity, size_t size, uint8_t *data, StaticQueue_t *q) { + *q = (StaticQueue_t){data, capacity, size, 0}; return q; +} +static int xQueueSend(QueueHandle_t q, const void *item, TickType_t ticks) { + assert(mutex && ticks == 0); if (q->used == q->capacity) return 0; + memcpy(q->data + q->used++ * q->item_size, item, q->item_size); return pdTRUE; +} +static int xQueueReceive(QueueHandle_t q, void *item, TickType_t ticks) { + assert(mutex && ticks == 0); if (!q->used) return 0; + memcpy(item, q->data, q->item_size); --q->used; + memmove(q->data, q->data + q->item_size, q->used * q->item_size); return pdTRUE; +} +static UBaseType_t uxQueueMessagesWaiting(QueueHandle_t q) { assert(mutex); return q->used; } +static void xQueueReset(QueueHandle_t q) { assert(mutex); q->used = 0; } +static void vQueueDelete(QueueHandle_t q) { (void)q; } + +typedef void *TaskHandle_t; +static void (*task_entry)(void *); +static jmp_buf task_exit; +static const uint8_t *serial_input; +static size_t serial_remaining; +static int xTaskCreate(void (*entry)(void *), const char *name, unsigned stack, + void *context, unsigned priority, TaskHandle_t *handle) { + (void)name; (void)stack; (void)context; (void)priority; + task_entry = entry; *handle = &mutex; return pdPASS; +} +static void vTaskDelay(TickType_t ticks) { + assert(!mutex && ticks > 0); if (!serial_remaining) longjmp(task_exit, 1); +} +static size_t serial_service_read(uint8_t *data, size_t size) { + assert(mutex); if (size > serial_remaining) size = serial_remaining; + memcpy(data, serial_input, size); serial_input += size; serial_remaining -= size; return size; +} +static size_t serial_service_write(const uint8_t *data, size_t size) { (void)data; assert(mutex); return size; } +static esp_err_t serial_service_set_session_active(bool active) { (void)active; assert(mutex); return ESP_OK; } + +typedef struct { + const char *command, *help, *hint; + int (*func)(int, char **); + void *argtable; +} esp_console_cmd_t; +static int (*registered_command)(int, char **); +static esp_err_t esp_console_cmd_register(const esp_console_cmd_t *cmd) { + registered_command = cmd->func; return ESP_OK; +} +static char console_output[16384]; +static size_t console_used; +static int capture_printf(const char *format, ...) { + /* Console formatting must happen after releasing the broker mutex. */ + assert(!mutex); + va_list args; va_start(args, format); + int n = vsnprintf(console_output + console_used, sizeof(console_output) - console_used, format, args); + va_end(args); assert(n >= 0 && (size_t)n < sizeof(console_output) - console_used); + console_used += (size_t)n; return n; +} diff --git a/tests/session_broker_diagnostics/run.py b/tests/session_broker_diagnostics/run.py new file mode 100644 index 0000000..ec4f598 --- /dev/null +++ b/tests/session_broker_diagnostics/run.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +"""Compile unchanged production broker/console with isolated deterministic doubles. +No SDK, device, network, or persistent generated files required. +""" +from pathlib import Path +import shutil +import subprocess +import tempfile + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent.parent +with tempfile.TemporaryDirectory(prefix="broker-diagnostics-") as directory: + build = Path(directory) + (build / "freertos").mkdir() + for name in ("session_broker.c", "session_broker.h", "session_console.c", "session_console.h"): + shutil.copyfile(ROOT / "src" / name, build / name) + shutil.copyfile(HERE / "fake.h", build / "fake.h") + shutil.copyfile(HERE / "test.c", build / "test.c") + for name in ("esp_err.h", "esp_heap_caps.h", "esp_console.h", "serial_service.h", + "freertos/FreeRTOS.h", "freertos/queue.h", "freertos/semphr.h", + "freertos/stream_buffer.h", "freertos/task.h"): + (build / name).write_text('#include "fake.h"\n') + subprocess.run(["cc", "-std=c11", "-D_POSIX_C_SOURCE=200809L", "-Wall", "-Wextra", + "-Werror", "-I", str(build), str(build / "test.c"), + "-o", str(build / "test")], check=True) + subprocess.run([str(build / "test")], check=True) diff --git a/tests/session_broker_diagnostics/test.c b/tests/session_broker_diagnostics/test.c new file mode 100644 index 0000000..5ad936d --- /dev/null +++ b/tests/session_broker_diagnostics/test.c @@ -0,0 +1,155 @@ +#include "fake.h" +#include "session_broker.c" +#define printf capture_printf +#include "session_console.c" +#undef printf + +static uint8_t payload[SESSION_BROKER_OUTPUT_SIZE + 256]; +static void feed(size_t size) { + assert(size <= sizeof(payload)); + serial_input = payload; serial_remaining = size; + if (setjmp(task_exit) == 0) task_entry(NULL); + assert(!mutex && serial_remaining == 0); +} +static session_broker_client_snapshot_t snapshot(session_broker_client_id_t id) { + session_broker_client_snapshot_t s; + assert(session_broker_get_client_snapshot(id, &s) == ESP_OK); return s; +} +static session_broker_global_snapshot_t global(void) { + session_broker_global_snapshot_t s; + assert(session_broker_get_global_snapshot(&s) == ESP_OK); return s; +} +static size_t drain(session_broker_client_id_t id, size_t size) { + uint8_t data[sizeof(payload)]; size_t received; + assert(size <= sizeof(data)); + assert(session_broker_read(id, data, size, &received) == ESP_OK); return received; +} +static session_broker_client_id_t connect_type(session_broker_client_type_t type) { + session_broker_client_id_t id; + assert(session_broker_connect(type, "SECRET-NAME-\033[2J", &id) == ESP_OK); return id; +} +static int command(const char *operation, session_broker_client_id_t id, const char *size) { + char id_text[16]; snprintf(id_text, sizeof(id_text), "%u", id); + char *argv[] = {"broker", (char *)operation, id_text, (char *)size}; + console_used = 0; console_output[0] = 0; + return registered_command(id ? (size ? 4 : 3) : 2, argv); +} +static void disconnect_all(void) { + session_broker_client_snapshot_t clients[SESSION_BROKER_MAX_CLIENTS]; + size_t n = session_broker_list_clients(clients, SESSION_BROKER_MAX_CLIENTS); + for (size_t i = 0; i < n; ++i) assert(session_broker_disconnect(clients[i].id) == ESP_OK); +} +static void comparison(unsigned browsers) { + session_broker_client_id_t ids[4]; + ids[0] = connect_type(SESSION_BROKER_CLIENT_USB); + ids[1] = connect_type(SESSION_BROKER_CLIENT_SSH); + for (unsigned i = 0; i < browsers; ++i) ids[2+i] = connect_type(SESSION_BROKER_CLIENT_WEB); + assert(session_broker_clear_counters() == ESP_OK); + size_t total = 0; + while (total < 71292) { + size_t n = 256; + if (n > 71292 - total) n = 71292 - total; + /* Deterministic slow-browser window, not a claim about real scheduling. */ + if (browsers == 2 && total < 23412 && n > 23412 - total) n = 23412 - total; + feed(n); total += n; + for (unsigned i = 0; i < 2+browsers; ++i) { + if (browsers == 2 && i == 3 && total < 23412) continue; + drain(ids[i], sizeof(payload)); + } + } + session_broker_global_snapshot_t g = global(); + uint64_t expected = 71292U * (2+browsers); + uint64_t drops = browsers == 2 ? 19316 : 0; + assert(g.counters.uart_rx_bytes == 71292 && g.counters.disconnections == 0); + assert(g.counters.output_queued_bytes == expected-drops); + assert(g.counters.output_read_bytes == expected-drops && g.counters.output_dropped_bytes == drops); + for (unsigned i = 0; i < 2+browsers; ++i) { + session_broker_client_snapshot_t s = snapshot(ids[i]); + assert(s.counters.output_dropped_bytes == (i == 3 ? drops : 0)); + assert(s.counters.output_high_water_bytes == (i == 3 ? 4096 : 256)); + } + printf("PASS synthetic %u-browser comparison: UART=71292 copies=%" PRIu64 " queued=%" PRIu64 " read=%" PRIu64 " dropped=%" PRIu64 " disconnect=0\n", + browsers, expected, g.counters.output_queued_bytes, g.counters.output_read_bytes, drops); + disconnect_all(); +} +int main(void) { + for (size_t i = 0; i < sizeof(payload); ++i) payload[i] = (uint8_t)i; + assert(session_broker_get_global_snapshot(&(session_broker_global_snapshot_t){0}) == ESP_ERR_INVALID_STATE); + assert(session_broker_init() == ESP_OK); + assert(session_console_register_commands() == ESP_OK); + size_t initial_allocations = allocations; + session_broker_client_id_t fast = connect_type(SESSION_BROKER_CLIENT_USB); + session_broker_client_id_t slow = connect_type(SESSION_BROKER_CLIENT_WEB); + assert(session_broker_request_writer(fast) == ESP_OK); + for (unsigned i = 0; i < 17; ++i) { feed(256); assert(drain(fast, 256) == 256); } + session_broker_client_snapshot_t s = snapshot(slow); + assert(s.output_bytes_pending == 4096 && s.counters.output_high_water_bytes == 4096); + assert(s.counters.output_queued_bytes == 4096 && s.counters.output_dropped_bytes == 256); + s = snapshot(fast); + assert(s.counters.output_read_bytes == 4352 && s.counters.output_high_water_bytes == 256); + assert(s.counters.output_dropped_bytes == 0 && s.is_writer); + puts("PASS isolated fanout overflow and exact 4096-byte high-water"); + + assert(drain(slow, 4000) == 4000); + assert(snapshot(slow).counters.output_high_water_bytes == 4096); + session_broker_global_snapshot_t before = global(); + assert(session_broker_clear_client_counters(slow) == ESP_OK); + s = snapshot(slow); + assert(s.output_bytes_pending == 96 && s.counters.output_high_water_bytes == 96); + assert(s.counters.output_queued_bytes == 0 && s.counters.output_read_bytes == 0 && s.counters.output_dropped_bytes == 0); + assert(global().counters.output_queued_bytes == before.counters.output_queued_bytes); + feed(256); assert(snapshot(slow).counters.output_high_water_bytes == 352); + assert(session_broker_clear_counters() == ESP_OK); + s = snapshot(slow); + assert(s.output_bytes_pending == 352 && s.counters.output_high_water_bytes == 352); + assert(global().writer_id == fast && global().latest_event_sequence == before.latest_event_sequence); + assert(global().counters.uart_rx_bytes == 0 && s.counters.uart_rx_bytes == 0); + assert(drain(slow, 352) == 352); + assert(snapshot(slow).counters.output_read_bytes == 352); + feed(sizeof(payload)); + assert(snapshot(slow).counters.output_dropped_bytes == 256); + puts("PASS per-client/global clears seed pending; subsequent read/drop/HWM accounting"); + + before = global(); + assert(session_broker_disconnect(slow) == ESP_OK); + assert(global().counters.output_dropped_bytes == before.counters.output_dropped_bytes + 4096); + assert(session_broker_get_client_snapshot(slow, &s) == ESP_ERR_NOT_FOUND); + session_broker_client_id_t replacement = connect_type(SESSION_BROKER_CLIENT_WEB); + assert(replacement != slow && (replacement & 7) == (slow & 7)); + s = snapshot(replacement); + assert(s.output_bytes_pending == 0 && s.counters.output_high_water_bytes == 0); + assert(s.counters.output_queued_bytes == 0 && s.counters.output_read_bytes == 0 && s.counters.output_dropped_bytes == 0); + assert(session_broker_clear_client_counters(slow) == ESP_ERR_NOT_FOUND); + puts("PASS disconnect discard retained globally and generation-safe reuse resets diagnostics"); + + while (session_broker_list_clients(NULL, 0) < SESSION_BROKER_MAX_CLIENTS) + connect_type(SESSION_BROKER_CLIENT_INTERNAL); + struct { session_broker_client_snapshot_t row; uint64_t guard; } bounded = {.guard = UINT64_MAX}; + assert(session_broker_list_clients(&bounded.row, 1) == 1 && bounded.guard == UINT64_MAX); + before = global(); + assert(command("counters", 0, NULL) == 0); + assert(strstr(console_output, "pending HWM") && !strstr(console_output, "SECRET-NAME") && !strchr(console_output, '\033')); + unsigned lines = 0; + for (const char *p = strstr(console_output, "ID type"); *p; ++p) lines += *p == '\n'; + assert(lines == 1 + SESSION_BROKER_MAX_CLIENTS && console_used < 4096); + assert(global().counters.output_read_bytes == before.counters.output_read_bytes); + puts("PASS bounded eight-row metadata-only console counters; snapshots do not consume output"); + + feed(1024); + assert(command("read", replacement, "513") == 1); + assert(snapshot(replacement).output_bytes_pending == 1024); + assert(command("read", replacement, "0") == 1); + assert(command("read", replacement, NULL) == 0); + assert(strstr(console_output, "read 512 bytes: 00010203") && !strchr(console_output, '\033')); + assert(snapshot(replacement).output_bytes_pending == 512); + assert(command("read", replacement, "1") == 0); + assert(snapshot(replacement).output_bytes_pending == 511); + puts("PASS console read bounds and binary-safe hexadecimal rendering"); + disconnect_all(); + assert(command("counters", 0, NULL) == 0); + comparison(1); comparison(2); + assert(allocations == initial_allocations); + puts("PASS no post-init broker allocations; 7 diagnostic groups passed"); + cleanup_allocations(); + return 0; +} diff --git a/tests/web_serial_performance/run.py b/tests/web_serial_performance/run.py new file mode 100644 index 0000000..1d75618 --- /dev/null +++ b/tests/web_serial_performance/run.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""Compile actual transport callbacks using the existing serial/store doubles.""" +import os +import pathlib +import subprocess +import sys +import tempfile +sys.dont_write_bytecode = True +HERE = pathlib.Path(__file__).resolve().parent +ROOT = HERE.parents[1] +BASE = ROOT / 'tests/web_session_store' +sys.path.insert(0, str(BASE)) +from run import HEADERS +from serial_headers import SERIAL_HEADERS +os.environ['CCACHE_DISABLE'] = '1' +with tempfile.TemporaryDirectory(prefix='web-performance-') as directory: + tmp = pathlib.Path(directory) + headers = HEADERS | SERIAL_HEADERS + headers['freertos/FreeRTOS.h'] = headers['freertos/FreeRTOS.h'].replace( + 'extern int host_lock_depth;', 'extern int host_lock_depth; void host_before_lock(void);').replace( + '(void)(m); assert(host_lock_depth++', '(void)(m); host_before_lock(); assert(host_lock_depth++') + for name, text in headers.items(): + path = tmp / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + fixture = (BASE / 'serial_test.c').read_text().replace('int main(void)', 'int serial_tests(void)') + fixture = fixture.replace('#include "test.c"', '#include "' + str(BASE / 'test.c') + '"') + fixture = fixture.replace('../../src/web_serial_transport.c', str(ROOT / 'src/web_serial_transport.c')) + (tmp / 'fixture.c').write_text(fixture) + console = (ROOT / 'src/web_console.c').read_text() + start = console.index('static void print_performance_time(') + end = console.index('static int command_web(', start) + (tmp / 'console.inc').write_text( + 'static const char *esp_err_to_name(esp_err_t e) { (void)e; return "error"; }\n' + + console[start:end]) + flags = ['-fsanitize=address,undefined', '-fno-omit-frame-pointer'] if '--sanitize' in sys.argv else [] + subprocess.run(['cc', '-std=c11', '-Wall', '-Wextra', '-Werror', '-g', *flags, + '-I'+str(tmp), '-I'+str(ROOT / 'src'), '-ffunction-sections', '-fdata-sections', + '-Wl,--gc-sections', str(HERE / 'test.c'), str(ROOT / 'src/web_session_store.c'), + str(ROOT / 'src/web_auth_parse.c'), '-o', str(tmp / 'test')], check=True, timeout=30) + subprocess.run([str(tmp / 'test')], check=True, timeout=15) diff --git a/tests/web_serial_performance/test.c b/tests/web_serial_performance/test.c new file mode 100644 index 0000000..0ea3f9d --- /dev/null +++ b/tests/web_serial_performance/test.c @@ -0,0 +1,172 @@ +/* Production queue/callback/drain paths; no payload is printed. */ +#include "fixture.c" +#include "console.inc" +static void (*queued)(void *), (*send_hook)(void), (*queue_hook)(void), (*read_hook)(void); +static void *queued_arg; +static bool early; +static esp_err_t queue_result, send_result; +static size_t read_bytes; +static unsigned sends; +static unsigned lock_delay; +static bool disconnect_stalled; +void host_before_lock(void) { now += lock_delay; lock_delay = 0; } +void vTaskDelay(TickType_t ticks) +{ + assert(!host_lock_depth); now += ticks * 1000; + if (!disconnect_stalled) process_broker_disconnect(&s_slots[0]); +} +session_broker_client_id_t session_broker_get_writer_id(void) { return SESSION_BROKER_NO_CLIENT; } +esp_err_t session_broker_force_writer(session_broker_client_id_t id) { (void)id; return ESP_OK; } +static void detach(void) { assert(web_serial_transport_detach_server(request.handle) == ESP_OK); } +static void reuse_during_read(void) { ++s_slots[0].generation; } + +esp_err_t httpd_queue_work(httpd_handle_t h, void (*fn)(void *), void *arg) +{ + (void)h; assert(!host_lock_depth); + queued = fn; queued_arg = arg; + if (queue_hook) queue_hook(); + if (early && queue_result == ESP_OK) { now += 7; fn(arg); } + return queue_result; +} +esp_err_t httpd_ws_send_frame_async(httpd_handle_t h, int fd, httpd_ws_frame_t *f) +{ + (void)h; (void)fd; assert(!host_lock_depth); assert(f->payload == s_slots[0].tx_data); + ++sends; now += 11; + if (send_hook) send_hook(); + return send_result; +} +esp_err_t httpd_sess_update_lru_counter(httpd_handle_t h, int fd) +{ (void)h; (void)fd; assert(!host_lock_depth); now += 3; return ESP_OK; } +esp_err_t session_broker_read(session_broker_client_id_t id, uint8_t *data, size_t n, size_t *out) +{ + (void)id; (void)data; assert(!host_lock_depth && read_bytes <= n); + if (read_hook) read_hook(); + *out = read_bytes; return ESP_OK; +} +static web_serial_slot_t *setup(void) +{ + serial_reset(); issued_t a = mint(&alice); web_serial_slot_t *s = connect_session(&a); + early = false; disconnect_stalled = false; lock_delay = 0; queue_result = send_result = ESP_OK; + send_hook = queue_hook = read_hook = NULL; read_bytes = 0; sends = 0; + assert(web_serial_performance_enable(false) == ESP_OK); + assert(web_serial_performance_clear() == ESP_OK); + return s; +} +static void enable(void) { assert(web_serial_performance_enable(true) == ESP_OK); } +static void clear(void) { assert(web_serial_performance_clear() == ESP_OK); } +static void disable(void) { assert(web_serial_performance_enable(false) == ESP_OK); } +static void cycle(void) { disable(); enable(); } +static void run_work(void) { assert(queued); queued(queued_arg); } +static void queue_binary(web_serial_slot_t *s) +{ assert(queue_slot_frame(s, s->generation, HTTPD_WS_TYPE_BINARY, 512) == ESP_OK); } +static web_serial_performance_session_t row(void) +{ + web_serial_performance_snapshot_t s; + assert(web_serial_performance_snapshot(&s) == ESP_OK); + return s.sessions[0]; +} +int main(void) +{ + assert(serial_tests() == 0); + web_serial_slot_t *s = setup(); + queue_binary(s); run_work(); assert(row().sent_frames == 0); /* default off */ + enable(); early = true; queue_binary(s); + assert(row().queue_wait.sum_us == 7 && row().send_call.sum_us == 11); + assert(row().sent_frames == 1 && row().sent_bytes == 512 && !row().pending); + now += 20; read_bytes = 512; early = false; + drain_binary_output(s, s->generation, s->broker_client_id); + assert(row().completion_first_nonempty.count == 1 && row().completion_attempt.sum_us == 23); + now += 100; assert(row().pending_age_us == 100 && row().measured_pending); + run_work(); now += 10; read_bytes = 0; + drain_binary_output(s, s->generation, s->broker_client_id); + uint64_t first = row().completion_attempt.sum_us; + now += 1000000; read_bytes = 512; + drain_binary_output(s, s->generation, s->broker_client_id); + assert(row().completion_attempt.sum_us == first); + assert(row().completion_first_nonempty.count == 1 && row().completion_nonempty.max_us > 1000000); + run_work(); + /* Control sends must not contribute or erase the binary completion chain. */ + assert(queue_slot_frame(s, s->generation, HTTPD_WS_TYPE_TEXT, 4) == ESP_OK); run_work(); + drain_binary_output(s, s->generation, s->broker_client_id); + assert(row().completion_nonempty.count == 3); + + s = setup(); enable(); queue_binary(s); now += 5; lock_delay = 100; + run_work(); assert(row().queue_wait.sum_us == 5); /* before validation lock */ + s = setup(); enable(); queue_result = ESP_FAIL; + assert(queue_slot_frame(s, s->generation, HTTPD_WS_TYPE_BINARY, 8) == ESP_FAIL); + assert(row().queue_errors == 1 && !row().pending && s->close_requested); + s = setup(); enable(); send_result = ESP_FAIL; queue_binary(s); run_work(); + assert(row().send_errors == 1 && row().send_call.count == 1 && row().sent_frames == 0); + s = setup(); enable(); queue_binary(s); s->close_requested = true; run_work(); + assert(!sends && row().retired == 1 && !s->work_pending); + + void (*hooks[])(void) = {clear, disable, cycle}; + for (unsigned i = 0; i < 3; ++i) { + s = setup(); enable(); queue_binary(s); hooks[i](); run_work(); + assert(row().sent_frames == 0 && row().queue_wait.count == 0); + s = setup(); enable(); send_hook = hooks[i]; queue_binary(s); run_work(); + assert(row().sent_frames == 0 && row().send_call.count == 0); + s = setup(); enable(); queue_hook = hooks[i]; early = true; queue_binary(s); + assert(row().sent_frames == 0 && row().queue_wait.count == 0); + s = setup(); enable(); queue_hook = hooks[i]; queue_result = ESP_FAIL; + assert(queue_slot_frame(s, s->generation, HTTPD_WS_TYPE_BINARY, 8) == ESP_FAIL); + assert(row().queue_errors == 0 && s->close_requested); + } + s = setup(); enable(); queue_binary(s); run_work(); disable(); + uint64_t frozen = row().sent_frames; queue_binary(s); run_work(); + assert(row().active && row().sent_frames == frozen && !row().measured_pending); + enable(); queue_binary(s); run_work(); assert(row().sent_frames == frozen + 1); + read_hook = clear; read_bytes = 512; + drain_binary_output(s, s->generation, s->broker_client_id); + assert(row().completion_nonempty.count == 0); + + s = setup(); enable(); queue_binary(s); run_work(); + read_hook = reuse_during_read; read_bytes = 512; + drain_binary_output(s, s->generation, s->broker_client_id); + assert(row().completion_nonempty.count == 0 && !s->work_pending); + s = setup(); enable(); queue_binary(s); detach(); run_work(); + assert(!sends && row().sent_frames == 0); + assert(web_serial_transport_attach_server(request.handle) == ESP_OK); + s = setup(); enable(); send_hook = detach; queue_binary(s); run_work(); + assert(sends == 1 && row().sent_frames == 0 && !s->work_pending); + s = setup(); enable(); queue_binary(s); disconnect_stalled = true; + assert(web_serial_transport_detach_server(request.handle) == ESP_ERR_TIMEOUT); + run_work(); assert(!sends && row().sent_frames == 0); + + s = setup(); enable(); queue_binary(s); request.sess_ctx = &s_slots[1]; + run_work(); assert(!sends && row().send_errors == 1 && !row().send_call.count); + + /* A copied/stale callback argument cannot own a reused slot. */ + s = setup(); enable(); queue_binary(s); web_serial_work_t stale = s->work; + web_serial_send_work(&stale); assert(!sends && s->work_pending); + ++s->generation; run_work(); assert(!sends && row().sent_frames == 0); + s = setup(); enable(); queue_binary(s); web_serial_session_free(s); + run_work(); assert(!sends && !s->work_pending); process_broker_disconnect(s); + assert(s->state == WEB_SERIAL_SLOT_FREE); + uint32_t generation; s = reserve_slot(request.handle, 10, &generation); + assert(s && !s->performance.sent_frames && !s->performance_epoch); + + s = setup(); enable(); s->performance.sent_bytes = UINT64_MAX - 1; + queue_binary(s); run_work(); assert(row().sent_bytes == UINT64_MAX && row().saturated); + clear(); assert(!row().saturated); + s->performance.queue_wait.count = UINT64_MAX; + s->performance.queue_wait.sum_us = UINT64_MAX - 1; + queue_binary(s); now += 9; run_work(); + assert(row().queue_wait.count == UINT64_MAX && row().queue_wait.sum_us == UINT64_MAX); + assert(row().queue_wait.max_us == 9 && row().saturated); + clear(); + assert(performance_command("show") == 0); + assert(performance_command("disable") == 0 && !performance_gate()); + assert(performance_command("enable") == 0 && performance_gate()); + assert(performance_command("clear") == 0); + assert(performance_command("bogus") == 1); + s_performance_epoch = UINT32_MAX - 1; enable(); + assert(web_serial_performance_clear() == ESP_ERR_INVALID_STATE && !performance_gate()); + assert(!host_lock_depth); + printf("PASS: performance production queue/send/drain, epochs, idle, retirement, saturation\n"); + printf("host slot diagnostics storage=%zu bytes (two slots); target ABI may differ\n", + 2 * (sizeof(s->performance) + sizeof(s->performance_epoch) + sizeof(s->completion_epoch) + + sizeof(s->queued_us) + sizeof(s->completed_us) + sizeof(s->completion_waiting) + + sizeof(s->completion_attempted))); + return 0; +}