Add typed serial settings operations

Route bounded admin mutations through the existing administration
dispatcher,
covering apply, lifecycle, persistence, authorization, and result
tracking.
Add the browser controls, automatic result refresh, regression coverage,
and
phase documentation.
This commit is contained in:
2026-09-08 00:25:31 +02:00
parent 5a2aa0d4d8
commit 42548f6334
27 changed files with 1398 additions and 42 deletions
+4
View File
@@ -152,6 +152,10 @@ The final administrator cannot be deleted or demoted. UART0 is trusted for initi
NVS is not encrypted. Password verifiers improve password storage, but Wi-Fi credentials, legacy recovery credentials, and TLS/SSH private keys remain recoverable under physical flash extraction.
## Typed Serial settings (8D.9)
`web_serial_settings` admits strict bounded admin cookie/Origin/CSRF JSON into one static session-bound operation/result slot, queuing only a non-reused ID on the existing administration dispatcher. HTTPD never runs serial/NVS mutations. The dispatcher checks session/principal currentness and a 30-second dequeue deadline before canonical Apply/Start/Stop/Save/Load/Defaults/Reset APIs; already admitted work may complete after logout. A blocked dispatcher retains the slot, not a timed job cancellation. Results are login-isolated and replaceable after completion; no durable history/idempotent retry guarantee. After acknowledgement the UI checks at one-second intervals, bounded to 10 GET attempts and a 15-second overall deadline including session checks, then automatically refreshes working values for known terminal outcomes. Errors/exhaustion use manual recovery; no automatic mutation retries or navigation resumption. Settings remain visible with stale/pending labels; only Reset confirms saved-NVS overwrite. Selecting the current view is a no-op. Settings UI retains uncertain-result warnings, explicit RAM/NVS/discard explanations and both terminal sockets/lease. Two optional exact GET/POST registrations bring the URI budget to 19, with six sockets and unchanged tasks/stacks/queue depth. `/api/status` also uses the zero-wait serial snapshot and emits `running:null` when unavailable. See `docs/phase8d9_implementation.md` for resource and target-pending evidence.
## Console architecture
UART0 and admin SSH share canonical command implementations:
+2
View File
@@ -76,6 +76,8 @@ This is a semantic map, not a complete file inventory. Start here, then read the
- 8D.6 UI: `web_ui.c` adds admin-only Serial/Admin selection and explicit admin open/close through existing endpoints. Serial socket/client/lease survives mode switches; hidden output drains into independent 5,000-line/64 KiB-pending terminals with visible browser-drop counts. Selected keyboard only; logout/expiry/pagehide closes both with handler cleanup. Session identity changes require a clean document before adopting the view; same-session restore retains hidden-until-validated buffers. Fit readiness retries are bounded to three and cache only success. Focused `tests/web_ui_session/run.py` has 17 groups plus toolbar-order/CSP checks. 8D.6 is user-validated; telemetry, evidence limits and 8D.7 handoff are in `docs/phase8d6_implementation.md`. Numeric reserves remain open; no 8D.7 restriction change.
- 8D.8: `web_ui.c` adds admin-only Settings/Serial without socket/lease changes. `web_server.c` exposes optional admin-only bodyless `GET /api/settings/serial`, eight working serial values, 256-byte response, no writes/NVS. `serial_service_get_snapshot()` is a zero-wait consistent config/running copy. `web_httpd_register_optional_get()` stages both new-route allocations before table publication (installed IDF public registration leaves a dangling descriptor on name-allocation failure); only Settings uses this startup/exact-GET adapter. 17 URI slots, six sockets/no LRU, no new task. Tests: cookie auth `--settings` (5 groups), UI (21 groups), lifecycle (12 groups). Implemented/build-verified, target/signoff pending; exact accounting and inherited registration-audit followup: `docs/phase8d8_implementation.md`. M2 remains signed off; no 8D.9.
- 8D.9: `web_serial_settings.{c,h}` owns strict 256-byte typed mutation admission and one session-bound pending/result slot. Existing `admin_ssh_console` dispatcher consumes only an ID, revalidates currentness/dequeue deadline and calls canonical serial APIs. `web_server.c` adds optional GET/POST `/api/settings/serial-operation` (19 handlers total); `web_cookie_auth_require_json()` retains Origin/CSRF/admin policy, private optional registration supports exact GET/POST. UI adds explicit framing/lifecycle/persistence with automatic completion checks (1 s, at most 10 GETs/15 s overall), refresh on known terminal results and manual uncertainty recovery without socket/lease changes. Settings stay visible/stale while pending; only Reset confirms NVS overwrite; selecting the current view is a no-op. `/api/status` uses a consistent zero-wait serial snapshot (`running:null` when unavailable). Tests: cookie `--serial-settings` (10 groups), `--settings` (6), UI (35 after UX refinement), console boundary and lifecycle (13). Build verified, target/signoff pending; bounds and failure contracts: `docs/phase8d9_implementation.md`. Supersedes 8D.8's no-8D.9 status above.
### Browser admin backend (8D.5)
- **8D.7 current status (2026-09-07): implemented scope validated; M2 explicitly signed off by the user ("Jupp, sign M2 off").** Supersedes M2-open/target-pending/continuation statements in the historical slices below; accepted M2 does not require revalidation. User verified certificate rotation and web start/stop via UART0/SSH admin/web admin, restarting after browser stop via another route; full mix without broker drops up to 230400 baud after external adapter correction is user-reported. Intermittent supported two serial + one admin admission failures, recently not recurring, are accepted nonblocking, not fixed. Browser self/generated/key/legacy-credential and other owner command restrictions remain deferred; bootstrap/recovery remain permanently UART0-only. Numeric memory reserves/stack margins remain unapproved; no full parity or individual unreported checklist passes. Next: separately requested 8D.8 read-only settings entry and Serial page; sign-off alone authorizes no implementation. Evidence: `docs/phase8d7_implementation.md`.
+6
View File
@@ -4,6 +4,12 @@ This file is working memory. Update it during active work and before handoff; do
## Development state
- **8D.9 UX refinement (2026-09-08), host-tested/build-verified; target pending:** User reported successful Apply required awkward manual Check Result then Refresh and approved automatic flow/removing routine popups. `web_ui.c` now keeps settings visible/stale while pending, checks acknowledged operations every 1 s up to 10 GET attempts/15 s overall, then refreshes working values while preserving outcome. Errors/exhaustion/lost ack use manual recovery; no POST retry, no resume after navigation. Only Reset retains a saved-NVS overwrite confirmation; inline discard semantics remain. Current-view selection is a no-op so repeated Settings clicks cannot cancel work. Parent UI **35 groups + renderer/CSP**, `pio run` **10.91 s / 95,708 B RAM / 1,668,837 B flash**, diff check PASS; **0 / +2,112 B** vs original 8D.9. No backend/assets/device/commit action or new signoff. Details/evidence limits in `docs/phase8d9_implementation.md`; target automatic-completion, failure/manual-recovery and full-mix latency checks pending.
- **8D.9 continuation complete (2026-09-07), implemented / reviewed / host-tested / build-verified; target/signoff pending:** Preserved inherited implementation; typed Apply/Start/Stop/Save/Load/Defaults/Reset uses one session-bound slot and the existing dispatcher queue, 256-byte JSON POST and 96-byte explicit GET results. No new tasks/stacks/queue depth or broker lease semantics; 19 URI handlers, six sockets/no LRU. Fixed persistent uncertain-result warnings and consistent zero-wait `/api/status` serial state (`running:null` when unavailable). Final parent Serial 10, Settings/status 6, UI 27/CSP, console boundary and lifecycle 13 groups pass; additional transport/tickets/store/admin/account/lifecycle regressions passed during review. Final `pio run` 23.73 s, **95,708 B RAM / 1,666,725 B flash**, **+128 / +12,196 B** vs 8D.8. Deadline is dequeue admission only; blocked dispatcher retains pending slot, admitted NVS work may finish after logout, completed results can be replaced. Exact contracts/tests/limits and pending target checklist: `docs/phase8d9_implementation.md`. No target/device/asset/commit action, reserve approval or 8D.8/8D.9 signoff. Stop before separately requested 8D.10; M2 acceptance and existing deferred restrictions/admission followups stand.
- **8D.9 original handoff (2026-09-07; superseded by completion above):** User explicitly requests end-to-end Serial typed edits/persistence despite pending 8D.8 target validation. This is continuation authorization, not 8D.8 target signoff. Plan: verify canonical serial and concurrency contracts; implement a single bounded session-bound operation slot on the existing dispatcher with nonblocking HTTP admission/result reads; add explicit Serial controls and focused regressions; finite build, diff review and accurate resource/target handoff. No later domains/popovers/generated assets/device/commit actions. Existing modification to `docs/phase8d8_implementation.md` belongs to parent/user and is preserved. M2 acceptance, deferred restrictions, unresolved admission and unapproved memory/stack followups stand.
- **8D.8 separately authorized, implemented / host-tested / build-verified (2026-09-07), target/signoff pending:** Admin-only Settings/Serial in the existing document, bodyless server-authorized `GET /api/settings/serial`, eight typed working values with 256-byte response bound. Nonblocking serial-state snapshot avoids blocking HTTPD on console reconfiguration; no mutations/NVS/broker/terminal lifecycle side effects. Selection preserves both sockets/lease and hidden output; explicit refresh, safe errors, cancellation/identity/expiry and concurrent-reconnect fencing. One optional URI (17 total), six sockets/no LRU unchanged. Private IDF adapter stages new-route descriptor/name before publication to avoid installed public registration's dangling entry on strdup failure; only the new Settings route uses it, broader inherited registration audit remains a followup. Final `pio run` **24.31 s, 95,580 B RAM / 1,654,529 B flash**, **0 / +5,952 B** vs recorded 8D.7; **+1,048 / +54,556 B** vs 8D.0. Target snapshot/descriptor 36/24 B; requested route/table heap +49 B before overhead, handler local frame 416 B excluding callees, no stack/task/queue/capacity increase. UI **21**, Settings **5**, lifecycle **12**, transport **25**/tickets **12** and auth/store/login/parser/console/policy regressions pass; diff check passes. No device, sanitizer, generated-asset or commit action; inline loader/CSP hashes unchanged and verified. Task/todowrite tools unavailable; implementer review fixes rerun, no independent-agent review claim. See `docs/phase8d8_implementation.md` for exact tests/resources/limits/pending target checklist. **M2 signoff, deferred restrictions, accepted unresolved admission issue and unapproved memory/stack followups stand. Stop before separately requested 8D.9; no new-phase signoff inferred.** This supersedes the older next-8D.8/wait-for-request instructions below, not their signoff/evidence.
- **M2 explicitly signed off by the user (2026-09-07):** After 8D.7 implemented-scope validation and discussion of read-only settings next, the user says "Jupp, sign M2 off". This supersedes all earlier M2-open statements and continuation instructions below; accepted M2 does not require revalidation or imply full browser command parity. Browser self-target/generated-password/key/legacy-credential and other owner-specific command restrictions remain deferred; bootstrap/recovery remain permanently UART0-only. Intermittent supported two serial + one admin web admission failures are accepted nonblocking, not fixed or diagnosed. Numeric memory reserves and stack margins remain unapproved follow-ups, not blockers reopening M2. **Next: 8D.8 read-only settings entry and Serial page, only when separately requested; this sign-off alone authorizes no implementation.** Evidence/history: `docs/phase8d7_implementation.md`. Documentation only; no source/tests/build/device/commit action.
+4
View File
@@ -102,6 +102,10 @@ Phase 8D.4 routes drain/lifecycle operations through a firmware-lifetime immutab
**Relevant files:** `src/user_database.{h,c}`, `src/user_console.c`, `src/web_server.c`, `src/web_serial_transport.c`, `src/ssh_transport.c`
## Typed serial mutations share the administration dispatcher
**8D.9:** HTTPD performs bounded typed admission/result reads only; serial reconfiguration and NVS execute on the existing dispatcher so CLI commands cannot interleave. One global pending slot rejects concurrent work; copied session/principal plus non-reused ID fence stale queued work. A 30-second deadline is checked on dequeue, not a cancellation timer or execution limit. Admitted mutations may finish after revocation; completed results can be replaced. Keep explicit uncertain-outcome recovery and never automatically retry mutations. Apply/Defaults are RAM-only, Save persists working device state rather than browser drafts, and Reset follows canonical apply/persist/best-effort-rollback ordering. Navigation preserves broker clients/writer lease, while explicit serial reconfiguration can discard serial-service pending data. No generic command runner/job history is exposed. See `src/web_serial_settings.{c,h}` and `docs/phase8d9_implementation.md`.
## Browser authentication has a narrow version-pinned HTTPD boundary
**8D.8 read-only settings:** Reuse bodyless GET cookie/current-admin policy and the existing bounded browser API/errors; no CSRF mutation semantics on a read. Obtain working serial config/running atomically with a zero-wait existing serial mutex, never block HTTPD on stop/reconfiguration or inspect NVS. Navigation changes view/input only, preserving both terminal sockets/lease/output; Settings session validation must not supersede serial-admission checks. One optional exact-GET URI raises only handler capacity to 17. The private adapter stages both descriptor/name allocations before publishing, avoiding the installed 5.5.0 public registration's freed table pointer on strdup failure. Serialized startup/exact matcher only, normal HTTPD allocation/free ownership; re-audit this boundary on SDK changes. Existing public registration callers are not refactored by this phase.
+4
View File
@@ -77,6 +77,10 @@ The initial Settings test incorrectly reused a session after testing authoritati
`todowrite` and a task/subagent tool were unavailable in this session. Progress was tracked in commentary and this record. A separate implementer review pass found/fixed the reconnect-supersession and IDF publication issues, followed by affected suite reruns and the final build. **Independent agent review was not available and is not claimed.** No remaining actionable finding was identified in the implementer's final review; that is not an independent review result.
### Independent Review Completion
Parent obtained a separate agent review after implementation. No actionable findings were identified in authorization/secrecy/bounds, nonblocking snapshot locking, navigation/session/expiry races, or the optional registration adapter's ownership against installed IDF 5.5.0. Reviewer reran Settings/auth (5 Settings groups), UI (21 groups plus renderer/CSP), and server lifecycle (12 groups), all passing. This supersedes the implementer-session independent-review limitation above. No production edits or additional build were needed for review. Real browser/HTTPD scheduling, hardware and runtime reserve limits remain unverified.
## Pending Target Checklist
- Record flashed revision/browser(s)/direct-IP or mDNS origin. On desktop and mobile, verify layout, scrolling, keyboard/touch navigation, focus/pressed states, visibility of writer controls and CSP console cleanliness.
+89
View File
@@ -0,0 +1,89 @@
# Phase 8D.9 — Serial edits and persistence
## State and scope (2026-09-07)
Implemented, reviewed, host-tested and firmware-build verified; **target/browser validation and phase signoff remain pending**. Resumed the previous agent's uncommitted implementation at the user's request. Continuation was explicitly authorized despite pending 8D.8 target validation; it does not sign off 8D.8 or 8D.9. M2 acceptance stands. No later domains, quick popover, generated assets, upload, erase, or commit actions.
## UX refinement (2026-09-08)
User reported Apply succeeds but manual Check Result hides the editor without refreshing the displayed configuration. User approved automatic completion/refresh and removal of routine disruption popups. Implemented in `src/web_ui.c`, with no backend/protocol change:
- Actions submit once, show applying/pending and keep settings visible with conflicting controls disabled and an explicit stale-snapshot message.
- After acknowledged submission, result checks wait one second initially and between pending replies, capped at **10 GET attempts and a separate 15-second overall monotonic deadline** including session revalidation. The deadline aborts an in-flight check; late callbacks are fenced. Browser timer scheduling is not a hard real-time guarantee.
- Known terminal results, including failures, automatically refresh working values while retaining outcome/uncertainty messages. Refresh failure leaves existing values visible and explicitly stale; Refresh remains available. The completion refresh has its own existing per-request bounds, outside the auto-check deadline.
- Errors/exhaustion/lost acknowledgements stop automatic checking and retain Check Result for manual recovery. Navigation/pagehide/logout/identity changes cancel checks without auto-resumption; they do not cancel backend work. No automatic POST retry.
- Apply/Save/Load/Defaults/Start/Stop no longer show confirmation dialogs. Reset alone confirms overwriting saved NVS configuration. Inline RAM/NVS/discard semantics remain.
- Review found repeated selection of the current Settings tab cancelled requests/polling; selecting the current view is now a no-op, with a regression covering submission, inter-check delay, in-flight checks and completion refresh.
Final parent validation: `python3 tests/web_ui_session/run.py` **35 behavior groups plus renderer/header/failure/exact CSP checks**, `pio run` **PASS 10.91 s**, `git --no-pager diff --check` PASS. Build **95,708 B RAM / 1,668,837 B flash**, **0 / +2,112 B** versus the original 8D.9 build below (**+128 / +14,308 B** versus 8D.8). Static build accounting is not runtime/stack telemetry. Tests model DOM/fetch/timers/WebSockets; new flow not target-validated. No upload, erase, asset regeneration or commit. Original validation below remains historical evidence, not a claim that backend suites were rerun for this UI-only refinement.
## Contract
- Existing admin-only Settings/Serial now has typed baud, data bits, parity, stop bits, flow control, DTR and RTS-threshold drafts, plus explicit Apply, Start, Stop, Save, Load, Defaults and Reset controls. Server authorization, not UI visibility, enforces admin access.
- `POST /api/settings/serial-operation` requires current cookie/principal, admin role, canonical Origin and CSRF. JSON is at most 256 bytes and four receive calls, with exact action-only or complete Apply schema; unknown/duplicate fields, escapes, nesting, invalid framing and out-of-range values reject. Failed unread bodies close rather than contaminate the next request.
- HTTPD admits only an operation ID to the existing four-entry administration queue with zero wait. `web_serial_settings` owns one static session-bound pending/result slot; the existing 12 KiB dispatcher executes typed serial APIs, never CLI text or HTTPD-owned socket operations. Queue/slot exhaustion returns 503 with Retry-After; no new task or generic job framework.
- Execution checks originating cookie/principal/admin currentness and the 30-second queue-admission deadline before side effects. This deadline is checked on dequeue: a blocked dispatcher retains the slot, and the deadline is neither an execution timeout nor a slot-release timer. Work already admitted to serial/NVS APIs may finish after logout/revocation. IDs do not wrap/reuse; stale queued IDs cannot execute replacement work.
- Bodyless `GET /api/settings/serial-operation` exposes only the current login's retained `{id, action, state}` (96-byte response buffer). States are idle, pending, ok, failed, cancelled, loaded_defaults and rollback_failed. A subsequent admitted operation, including another session's, can replace a completed result. This is neither durable history nor an idempotent retry API.
- Result checking is single-flight and automatically bounded after acknowledgement, with manual recovery as detailed above; mutations are never automatically retried. Lost acknowledgements and replaced results retain uncertainty warnings across further reads, Refresh and navigation. Cancellation/navigation is not a promise that admitted work stopped. Inspect current working settings and CLI storage before deciding to retry.
- Settings navigation preserves both terminal sockets, hidden output, broker client and writer lease. The operation itself may stop/reconfigure UART1 and discard serial-service RX/TX/task-pending bytes; already-fanned broker output and writer ownership remain unchanged. Open USB can restart a stopped service.
### Working versus persisted state
| Action | Canonical behavior |
|---|---|
| Apply | Validate and replace all working fields; running service stop/restart and best-effort rollback use existing service API. Overwrites intervening CLI edits. No NVS write. |
| Start / Stop | Existing service lifecycle APIs; no persistence or broker lease change. |
| Save | Persist device working configuration at execution, **not the browser draft**. |
| Load | Load and apply stored config; absent/incompatible storage applies defaults and reports `loaded_defaults`, without writing NVS. |
| Defaults | Apply defaults to RAM only. |
| Reset | Capture previous working config, apply defaults, persist defaults through canonical reset API; on persistence failure attempt runtime rollback, reporting rollback failure distinctly. |
Refresh replaces the browser draft. Snapshots do not expose a saved-config copy or claim a dirty/persisted comparison. There is no compare-and-swap protection against intervening CLI edits.
## Integration and continuation fixes
- `src/web_serial_settings.{c,h}`: parser, bounded operation slot, typed execution, result protocol.
- `src/admin_ssh_console.{c,h}`: typed ID in existing queue union and dispatcher; no console slot consumed.
- `src/web_cookie_auth.{c,h}`: bounded-JSON mutation authorization while preserving bodyless policies for existing endpoints.
- `src/web_httpd_adapter.{c,h}`: existing failure-safe optional registration generalized to exact GET/POST. `src/web_server.c` registers result GET first and mutation POST second; partial failure cannot leave a mutation-only endpoint. Optional failure preserves both transports. SDK-private boundary remains pinned to IDF 5.5.0; inherited non-Settings registration audit remains open.
- `src/web_ui.c`: explicit typed controls, Reset-only confirmation, bounded automatic completion/refresh and manual result recovery and session/navigation fencing. Review fixed uncertainty warnings disappearing after later result checks/Refresh/navigation.
- `/api/status` now uses the zero-wait serial snapshot rather than blocking config acquisition. Review fixed a separate live running read mixing moments with the snapshot. When unavailable, `running` is JSON `null` and `config_available` is false; consumers must not interpret that as an authoritative stopped state.
- Backend review found no further confirmed runtime defects; added parser/security/queue/currentness/race regressions and documented deadline/result limitations. Existing parent/user edits in `docs/phase8d8_implementation.md` were preserved.
## Validation performed
Final parent sequential run, all passed:
```sh
python3 tests/web_cookie_auth/run.py --serial-settings
python3 tests/web_cookie_auth/run.py --settings
python3 tests/web_ui_session/run.py
python3 tests/admin_console_boundary/run.py
python3 tests/web_admin_transport/server_lifecycle.py
pio run
git --no-pager diff --check
```
- Serial: 10 groups plus shared auth/store regressions, including 288 valid framing/range combinations, 256-byte/four-read boundaries, strict rejection, no HTTPD serial/NVS execution, session isolation, stale IDs, simulated concurrent reads/submissions, canonical CLI/API ordering, failures/reset rollback, account revocation and cookie expiry before the queue deadline.
- Settings/status: 6 groups plus shared regressions. Status serial projection compiles production acquisition/format/arguments, verifies consistent snapshot across changed live state and null on unavailability; it is not the full multi-subsystem handler.
- UI: 27 behavior groups plus production C renderer/header/failure and exact loader CSP hash checks. DOM/fetch/WebSockets are modeled, not on-wire integration.
- Console boundary: FIFO/zero-wait typed admission, full queue preserves all four UART requests; existing console/certificate/SSH-adapter regressions.
- Server: 13 lifecycle groups, including optional GET/POST failure and recovery, six sockets/no LRU.
Additional passing runs during continuation: parent transport `run.py --tickets` (25 transport/12 ticket groups), session store `run.py --serial`; backend review agent cookie `--admin`, console `accounts.py` and `lifecycle.py`. These use deterministic RTOS/UART/NVS/transport doubles, not real hardware scheduling, flash failures or rollback. No sanitizer run or target pass claimed.
### Resource accounting
Final `pio run`: **23.73 s; 95,708 B RAM / 1,666,725 B flash**. Delta against recorded 8D.8: **+128 B RAM / +12,196 B flash**; against 8D.0: **+1,176 / +66,752 B**. Build totals are static/linker accounting, not runtime free heap or stack margins.
Two additional optional handlers (GET and POST at one path) raise the configured URI budget from 17 to **19**. Six HTTPS sockets/no LRU, two serial sockets/one admin socket, existing queue depth, tasks and stack allocations are unchanged. Additional route descriptors/names/table pointers consume runtime heap beyond static RAM; exact allocator overhead and handler/dispatcher high-water marks have not been measured. The bounded request/result buffers are 256/96 bytes. No generated asset changes; loader hash remains unchanged and verified. Numeric memory reserves/stack margins remain unapproved.
## Pending target checklist / next step
1. Flash only by explicit user action/authorization; compare browser Refresh with UART0 serial config/status and confirm ordinary users cannot access settings/mutations.
2. Compare every browser action with CLI semantics, valid framing boundaries and invalid requests. Verify Apply/Defaults versus Save/Load/Reset across reboot; confirm explicit Refresh draft replacement and concurrent CLI overwrite warning.
3. Exercise start/stop/reconfigure under USB + SSH + both browser serial observers + browser/SSH admin. Confirm expected serial-service discards, unchanged broker writer/client identity, isolated output and USB restart behavior; do not mistake expected discards for a regression.
4. Hold dispatcher in a prompt, submit once, observe bounded busy behavior and dequeue deadline cancellation. Test logout/account revocation while queued, expiry, navigation, lost acknowledgement, replacement by another tab/login, and manual uncertain-result recovery without blind resubmission.
5. Where safely injectable, verify NVS/load/apply/rollback failures and physical port fault behavior. Host doubles are not evidence of hardware rollback success.
6. Capture settled boot/full-mix/post-cleanup internal/DMA/PSRAM free/min/largest and dispatcher/HTTPD stack high-water data; test slow/fragmented body requests alongside serial traffic. Existing intermittent supported-mix admission issue remains accepted nonblocking and unresolved.
7. Obtain explicit 8D.8/8D.9 target acceptance as appropriate. Stop before separately requested 8D.10. M2 is not reopened, deferred browser owner/credential restrictions remain, and no memory reserve approval is inferred.
+3 -1
View File
@@ -1,6 +1,8 @@
# Phase 8D — Incremental web administration plan
**Latest implementation (2026-09-07):** Separately user-authorized **8D.8 is implemented / host-tested / build-verified**, with target/browser validation and new phase signoff pending. [8D.8 record](phase8d8_implementation.md): read-only admin Settings/Serial, nonblocking typed snapshot, 17 handlers/six sockets, final 95,580 B RAM / 1,654,529 B flash; exact tests/resources/limits and target checklist recorded. This supersedes older next-8D.8/wait-for-request instructions below, not M2 signoff or evidence. Deferred restrictions, accepted unresolved admission issue and unapproved memory/stack followups remain. Stop before separately requested 8D.9; no new signoff is inferred.
**Latest continuation (2026-09-07):** Separately authorized **8D.9 is implemented / reviewed / host-tested / build-verified**, with 8D.8/8D.9 target acceptance still pending. [8D.9 record](phase8d9_implementation.md): typed Serial framing/lifecycle/persistence, one session-bound operation slot on the existing dispatcher, manual bounded result recovery, 19 handlers/six sockets; final **95,708 B RAM / 1,666,725 B flash**. No new task/queue depth/stack or broker writer semantics. This supersedes older stop-before-8D.9 instructions, not M2 signoff or deferred restrictions, admission followups or unapproved reserves. Stop before separately requested 8D.10; no target signoff inferred.
**Previous implementation (2026-09-07):** Separately user-authorized **8D.8 is implemented / host-tested / build-verified**, with target/browser validation and new phase signoff pending. [8D.8 record](phase8d8_implementation.md): read-only admin Settings/Serial, nonblocking typed snapshot, 17 handlers/six sockets, final 95,580 B RAM / 1,654,529 B flash; exact tests/resources/limits and target checklist recorded. This supersedes older next-8D.8/wait-for-request instructions below, not M2 signoff or evidence. Deferred restrictions, accepted unresolved admission issue and unapproved memory/stack followups remain. Stop before separately requested 8D.9; no new signoff is inferred.
Status: **8D.08D.6 and M1 validated by explicit user sign-off. 8D.7 implemented scope validated and M2 explicitly signed off by the user on 2026-09-07 ("Jupp, sign M2 off"), superseding historical M2-open, target-pending and continuation instructions below without requiring revalidation. Full browser parity is not claimed: self/generated/key/legacy-credential and other owner-specific command restrictions remain deferred; bootstrap/recovery remain permanently UART0-only. Intermittent supported two serial + one admin web admission failures are accepted nonblocking, not fixed. Numeric memory reserves/stack margins remain unapproved. Next is separately requested 8D.8 read-only settings entry and Serial page; sign-off alone authorizes no implementation.** See the [8D.7/M2 sign-off and evidence](phase8d7_implementation.md), [8D.6 implementation record](phase8d6_implementation.md), [8D.5 implementation record](phase8d5_implementation.md), [8D.4 implementation record](phase8d4_implementation.md), [8D.3 implementation record](phase8d3_implementation.md) and [8D.0 baseline/M1 contract](phase8d_baseline.md).
+1
View File
@@ -31,6 +31,7 @@ idf_component_register(
"user_console.c"
"web_security.c"
"web_serial_transport.c"
"web_serial_settings.c"
"web_admin_tickets.c"
"web_admin_transport.c"
"web_assets_data.c"
+18
View File
@@ -16,6 +16,7 @@
#include "linenoise/linenoise.h"
#include "secure_random.h"
#include "user_database.h"
#include "web_serial_settings.h"
#define ADMIN_SSH_CONSOLE_MAX_SESSIONS 2U
#define ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY 4096U
@@ -81,6 +82,7 @@ typedef enum {
ADMIN_REQUEST_SSH = 0,
ADMIN_REQUEST_UART0,
ADMIN_REQUEST_DEFERRED,
ADMIN_REQUEST_SERIAL_SETTINGS,
} admin_request_origin_t;
typedef struct {
@@ -91,6 +93,7 @@ typedef struct {
union {
uint8_t line[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
admin_control_request_t deferred;
uint32_t serial_settings_id;
};
} admin_request_t;
@@ -646,6 +649,16 @@ static void dispatch_registered_command(admin_request_t *request)
static void dispatch_deferred_request(admin_request_t *request);
esp_err_t admin_ssh_console_submit_serial_settings(uint32_t id)
{
taskENTER_CRITICAL(&s_lock);
bool ready = s_dispatch_ready;
taskEXIT_CRITICAL(&s_lock);
if (!ready || !id) return ESP_ERR_INVALID_STATE;
admin_request_t request = {.origin = ADMIN_REQUEST_SERIAL_SETTINGS, .serial_settings_id = id};
return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT;
}
static void worker_task(void *context)
{
(void)context;
@@ -654,6 +667,11 @@ static void worker_task(void *context)
if (xQueueReceive(s_request_queue, &request, portMAX_DELAY) != pdTRUE) {
continue;
}
if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS) {
web_serial_settings_execute(request.serial_settings_id);
secure_wipe(&request, sizeof(request));
continue;
}
if (request.origin == ADMIN_REQUEST_DEFERRED) {
dispatch_deferred_request(&request);
secure_wipe(&request, sizeof(request));
+3
View File
@@ -14,6 +14,9 @@
extern "C" {
#endif
/* Nonblocking typed Serial-settings admission to the canonical dispatcher. */
esp_err_t admin_ssh_console_submit_serial_settings(uint32_t id);
/* Fits the longest supported ECDSA P-256 OpenSSH key import command. */
#define ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY 256U
+15 -3
View File
@@ -173,14 +173,14 @@ void web_cookie_auth_clear_counters(void)
taskEXIT_CRITICAL(&s_lock);
}
esp_err_t web_cookie_auth_require(httpd_req_t *r, bool mutation, bool upgrade,
web_session_view_t *view, bool *allowed)
static esp_err_t require(httpd_req_t *r, bool mutation, bool upgrade, size_t body_limit,
web_session_view_t *view, bool *allowed)
{
char canonical[129] = {0}, token[65] = {0}, csrf[65] = {0};
*allowed = false;
memset(view, 0, sizeof(*view));
if (!web_httpd_headers_valid(r) || !cookies_valid(r) || (!upgrade && strchr(r->uri, '?')) ||
r->content_len || r->method != (mutation ? HTTP_POST : HTTP_GET))
r->content_len > body_limit || r->method != (mutation ? HTTP_POST : HTTP_GET))
return failure(r, "400 Bad Request", "invalid_request");
if (!origin(r, mutation || upgrade, canonical))
return failure(r, "403 Forbidden", "origin");
@@ -212,6 +212,18 @@ esp_err_t web_cookie_auth_require(httpd_req_t *r, bool mutation, bool upgrade,
return ESP_OK;
}
esp_err_t web_cookie_auth_require(httpd_req_t *r, bool mutation, bool upgrade,
web_session_view_t *view, bool *allowed)
{
return require(r, mutation, upgrade, 0, view, allowed);
}
esp_err_t web_cookie_auth_require_json(httpd_req_t *r, size_t body_limit,
web_session_view_t *view, bool *allowed)
{
return require(r, true, false, body_limit, view, allowed);
}
static bool secret(char out[65])
{
uint8_t bytes[32];
+3
View File
@@ -17,3 +17,6 @@ esp_err_t web_cookie_auth_require(httpd_req_t *request, bool mutation,
bool upgrade, web_session_view_t *view,
bool *allowed);
esp_err_t web_cookie_auth_handler(httpd_req_t *request);
/* Same mutation policy, allowing a bounded body; caller validates JSON/content type. */
esp_err_t web_cookie_auth_require_json(httpd_req_t *request, size_t body_limit,
web_session_view_t *view, bool *allowed);
+10 -3
View File
@@ -106,10 +106,11 @@ bool web_httpd_unread_body(httpd_req_t *request)
return aux && aux->remaining_len != 0;
}
esp_err_t web_httpd_register_optional_get(httpd_handle_t server, const httpd_uri_t *uri)
esp_err_t web_httpd_register_optional(httpd_handle_t server, const httpd_uri_t *uri)
{
struct httpd_data *hd = server;
if (!hd || !uri || !uri->uri || !uri->handler || uri->method != HTTP_GET ||
if (!hd || !uri || !uri->uri || !uri->handler ||
(uri->method != HTTP_GET && uri->method != HTTP_POST) ||
uri->is_websocket || uri->supported_subprotocol || hd->config.uri_match_fn)
return ESP_ERR_INVALID_ARG;
size_t length = 0;
@@ -118,7 +119,7 @@ esp_err_t web_httpd_register_optional_get(httpd_handle_t server, const httpd_uri
int slot = -1;
for (unsigned i = 0; i < hd->config.max_uri_handlers; ++i) {
if (!hd->hd_calls[i]) { if (slot < 0) slot = (int)i; }
else if (!strcmp(hd->hd_calls[i]->uri, uri->uri) && hd->hd_calls[i]->method == HTTP_GET)
else if (!strcmp(hd->hd_calls[i]->uri, uri->uri) && hd->hd_calls[i]->method == uri->method)
return ESP_ERR_INVALID_STATE;
}
if (slot < 0) return ESP_ERR_NO_MEM;
@@ -134,3 +135,9 @@ esp_err_t web_httpd_register_optional_get(httpd_handle_t server, const httpd_uri
hd->hd_calls[slot] = copy;
return ESP_OK;
}
esp_err_t web_httpd_register_optional_get(httpd_handle_t server, const httpd_uri_t *uri)
{
if (!uri || uri->method != HTTP_GET) return ESP_ERR_INVALID_ARG;
return web_httpd_register_optional(server, uri);
}
+2
View File
@@ -16,3 +16,5 @@ esp_err_t web_httpd_upgrade(httpd_req_t *request,
/* Serialized server startup only, exact-match ordinary GET, URI <= 127 bytes.
* Stage both allocations before publication; HTTPD owns/frees them on success. */
esp_err_t web_httpd_register_optional_get(httpd_handle_t server, const httpd_uri_t *uri);
/* Same staged startup ownership for ordinary exact GET or POST. */
esp_err_t web_httpd_register_optional(httpd_handle_t server, const httpd_uri_t *uri);
+240
View File
@@ -0,0 +1,240 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "web_serial_settings.h"
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include "admin_ssh_console.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "secure_random.h"
#include "serial_service.h"
#include "web_cookie_auth.h"
#include "web_httpd_adapter.h"
enum { APPLY, START, STOP, SAVE, LOAD, DEFAULTS, RESET, ACTION_COUNT };
static const char *const s_actions[] = {"apply", "start", "stop", "save", "load", "defaults", "reset"};
enum { IDLE, PENDING, OK, FAILED, CANCELLED, LOADED_DEFAULTS, ROLLBACK_FAILED };
static const char *const s_states[] = {"idle", "pending", "ok", "failed", "cancelled", "loaded_defaults", "rollback_failed"};
typedef struct {
uint32_t id;
web_session_id_t session;
user_principal_t principal;
int64_t deadline;
serial_config_t config;
unsigned action, state;
} serial_operation_t;
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
static serial_operation_t s_operation;
static uint32_t s_next_id;
/* Deliberately narrow flat JSON: ASCII names/enums, unsigned decimal integers,
* no escapes, nesting, duplicate/unknown fields, exponent or fractional values. */
static bool parse(const char *body, size_t length, serial_operation_t *operation)
{
const char *keys[] = {"action", "baud", "data_bits", "parity", "stop_bits", "flow", "dtr", "rts_threshold"};
unsigned seen = 0;
size_t pos = 0;
serial_config_defaults(&operation->config);
operation->action = ACTION_COUNT;
#define SPACE() while (pos < length && (body[pos] == ' ' || body[pos] == '\t' || body[pos] == '\r' || body[pos] == '\n')) ++pos
#define TAKE(c) do { SPACE(); if (pos == length || body[pos++] != (c)) return false; } while (0)
TAKE('{');
for (unsigned field = 0; field < 8; ++field) {
if (field) { TAKE(','); }
TAKE('"');
size_t start = pos;
while (pos < length && body[pos] != '"') ++pos;
if (pos == length) return false;
unsigned key = 0;
for (; key < 8; ++key)
if (strlen(keys[key]) == pos - start && !memcmp(body + start, keys[key], pos - start)) break;
if (key == 8 || (seen & (1U << key))) return false;
++pos; TAKE(':'); SPACE();
uint32_t number = 0;
char value[16] = {0};
if (key == 1 || key == 7) {
start = pos;
while (pos < length && body[pos] >= '0' && body[pos] <= '9') {
if (number > 1000000U) return false;
number = number * 10 + (unsigned)(body[pos++] - '0');
}
if (pos == start || (pos - start > 1 && body[start] == '0')) return false;
} else {
TAKE('"'); start = pos;
while (pos < length && body[pos] != '"') {
if (body[pos] < ' ' || body[pos] > '~' || body[pos] == '\\' || pos - start >= sizeof(value) - 1) return false;
++pos;
}
if (pos == length) return false;
memcpy(value, body + start, pos - start); ++pos;
}
switch (key) {
case 0:
for (unsigned i = 0; i < ACTION_COUNT; ++i)
if (!strcmp(value, s_actions[i])) operation->action = i;
if (operation->action == ACTION_COUNT) return false;
break;
case 1: operation->config.baud_rate = number; break;
case 2: if (!serial_config_parse_data_bits(value, &operation->config.data_bits)) return false; break;
case 3: if (!serial_config_parse_parity(value, &operation->config.parity)) return false; break;
case 4: if (!serial_config_parse_stop_bits(value, &operation->config.stop_bits)) return false; break;
case 5: if (!serial_config_parse_flow_control(value, &operation->config.flow_control)) return false; break;
case 6: if (!serial_config_parse_dtr_behavior(value, &operation->config.dtr_behavior)) return false; break;
case 7: operation->config.rts_threshold = number; break;
}
seen |= 1U << key;
SPACE();
if (pos < length && body[pos] == '}') break;
}
TAKE('}'); SPACE();
#undef TAKE
#undef SPACE
return pos == length && seen == (operation->action == APPLY ? 255U : 1U) &&
serial_config_validate(&operation->config) == ESP_OK;
}
void web_serial_settings_execute(uint32_t id)
{
serial_operation_t operation;
taskENTER_CRITICAL(&s_lock);
operation = s_operation;
taskEXIT_CRITICAL(&s_lock);
if (!id || operation.id != id || operation.state != PENDING) {
secure_wipe(&operation, sizeof(operation));
return;
}
bool current = false;
esp_err_t error = web_session_store_check_principal(operation.session, &operation.principal, &current);
unsigned state = CANCELLED;
if (error == ESP_OK && current && operation.principal.role == USER_ROLE_ADMIN &&
esp_timer_get_time() < operation.deadline) {
/* Operation-admission currentness, not cancellation of an admitted NVS
* commit. CLI commands cannot interleave on this single dispatcher. */
serial_config_t config, previous;
bool stored = true;
state = OK;
switch (operation.action) {
case APPLY: error = serial_service_apply_config(&operation.config); break;
case START: error = serial_service_start(); break;
case STOP: error = serial_service_stop(); break;
case SAVE:
error = serial_service_get_config(&config);
if (error == ESP_OK) error = serial_config_save(&config);
break;
case LOAD:
error = serial_config_load(&config, &stored);
if (error == ESP_OK) error = serial_service_apply_config(&config);
if (!stored) state = LOADED_DEFAULTS;
break;
case DEFAULTS:
serial_config_defaults(&config);
error = serial_service_apply_config(&config);
break;
case RESET:
serial_config_defaults(&config);
error = serial_service_get_config(&previous);
if (error == ESP_OK) error = serial_service_apply_config(&config);
if (error == ESP_OK) {
error = serial_config_reset_storage();
if (error != ESP_OK && serial_service_apply_config(&previous) != ESP_OK)
state = ROLLBACK_FAILED;
}
break;
default: error = ESP_ERR_INVALID_ARG; break;
}
if (error != ESP_OK && state != ROLLBACK_FAILED) state = FAILED;
}
taskENTER_CRITICAL(&s_lock);
if (s_operation.id == id && s_operation.state == PENDING) {
s_operation.state = state;
secure_wipe(&s_operation.principal, sizeof(s_operation.principal));
secure_wipe(&s_operation.config, sizeof(s_operation.config));
}
taskEXIT_CRITICAL(&s_lock);
secure_wipe(&operation, sizeof(operation));
}
static esp_err_t respond(httpd_req_t *request, const char *status, const char *body)
{
esp_err_t error = httpd_resp_set_status(request, status);
if (error == ESP_OK) error = httpd_resp_set_type(request, "application/json; charset=utf-8");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
if (error == ESP_OK) error = httpd_resp_sendstr(request, body);
return web_httpd_unread_body(request) ? ESP_FAIL : error;
}
esp_err_t web_serial_settings_handler(httpd_req_t *request)
{
web_session_view_t view = {0};
bool allowed = false;
bool mutation = request->method == HTTP_POST;
esp_err_t error = mutation
? web_cookie_auth_require_json(request, 256, &view, &allowed)
: web_cookie_auth_require(request, false, false, &view, &allowed);
if (error != ESP_OK || !allowed) goto done;
if (view.principal.role != USER_ROLE_ADMIN) {
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
goto done;
}
serial_operation_t operation = {0};
if (mutation) {
char type[40] = {0}, body[256];
size_t received = 0;
bool valid = request->content_len &&
httpd_req_get_hdr_value_str(request, "Content-Type", type, sizeof(type)) == ESP_OK &&
(!strcmp(type, "application/json") || !strcmp(type, "application/json; charset=utf-8"));
/* Finite bytes and receive calls; timeout/error closes, never retry/drain. */
for (unsigned reads = 0; valid && received < request->content_len && reads < 4; ++reads) {
int count = httpd_req_recv(request, body + received, request->content_len - received);
if (count <= 0 || (size_t)count > request->content_len - received) valid = false;
else received += (size_t)count;
}
valid = valid && received == request->content_len && parse(body, received, &operation);
secure_wipe(body, sizeof(body));
if (!valid) {
error = respond(request, "400 Bad Request", "{\"error\":\"invalid_serial_request\"}");
goto done;
}
operation.session = view.id;
operation.principal = view.principal;
operation.deadline = esp_timer_get_time() + 30000000LL;
operation.state = PENDING;
taskENTER_CRITICAL(&s_lock);
bool busy = s_operation.state == PENDING || s_next_id == UINT32_MAX;
if (!busy) {
operation.id = ++s_next_id;
s_operation = operation;
}
taskEXIT_CRITICAL(&s_lock);
if (busy || admin_ssh_console_submit_serial_settings(operation.id) != ESP_OK) {
taskENTER_CRITICAL(&s_lock);
if (!busy && s_operation.id == operation.id) secure_wipe(&s_operation, sizeof(s_operation));
taskEXIT_CRITICAL(&s_lock);
error = httpd_resp_set_hdr(request, "Retry-After", "1");
if (error == ESP_OK) error = respond(request, "503 Service Unavailable", "{\"error\":\"busy\"}");
secure_wipe(&operation, sizeof(operation));
goto done;
}
} else {
taskENTER_CRITICAL(&s_lock);
if (s_operation.session == view.id) {
operation.id = s_operation.id;
operation.action = s_operation.action;
operation.state = s_operation.state;
}
taskEXIT_CRITICAL(&s_lock);
}
char response[96];
int written = snprintf(response, sizeof(response), "{\"id\":%" PRIu32 ",\"action\":\"%s\",\"state\":\"%s\"}",
operation.id, operation.id ? s_actions[operation.action] : "none", s_states[operation.state]);
error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL :
respond(request, mutation ? "202 Accepted" : "200 OK", response);
secure_wipe(&operation, sizeof(operation));
done:
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
return error;
}
+14
View File
@@ -0,0 +1,14 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include <stdint.h>
#include "esp_http_server.h"
/* HTTPD owns requests/responses; the existing admin dispatcher alone executes.
* One global slot rejects mutations while pending (including execution). GET
* exposes only the caller's session result; a later admitted operation replaces
* that result, so this is not a durable history or an idempotent retry API.
* The 30-second deadline is checked when dequeued, not a completion deadline or
* a timer that frees the slot. Revocation/expiry cancels before operation
* admission; admitted serial/NVS work may finish after the session is gone. */
esp_err_t web_serial_settings_handler(httpd_req_t *request);
void web_serial_settings_execute(uint32_t id);
+20 -4
View File
@@ -23,6 +23,7 @@
#include "user_database.h"
#include "web_security.h"
#include "web_serial_transport.h"
#include "web_serial_settings.h"
#include "web_admin_transport.h"
#include "web_session_store.h"
#include "web_cookie_auth.h"
@@ -223,7 +224,7 @@ static esp_err_t status_handler(httpd_req_t *request)
increment_counter(&s_counters.status_requests);
wifi_manager_snapshot_t wifi = {0};
serial_config_t serial_config = {0};
serial_service_snapshot_t serial_snapshot = {0};
serial_service_counters_t serial_counters = {0};
session_broker_global_snapshot_t broker = {0};
usb_cdc_transport_snapshot_t usb = {0};
@@ -235,7 +236,8 @@ static esp_err_t status_handler(httpd_req_t *request)
char response[WEB_SERVER_STATUS_JSON_CAPACITY];
bool wifi_available = wifi_manager_get_snapshot(&wifi) == ESP_OK;
bool serial_config_available = serial_service_get_config(&serial_config) == ESP_OK;
bool serial_config_available = serial_service_get_snapshot(&serial_snapshot) == ESP_OK;
serial_config_t serial_config = serial_snapshot.config;
serial_service_get_counters(&serial_counters);
bool broker_available = session_broker_get_global_snapshot(&broker) == ESP_OK;
bool usb_available = usb_cdc_transport_get_snapshot(&usb) == ESP_OK;
@@ -279,7 +281,7 @@ static esp_err_t status_handler(httpd_req_t *request)
wifi_available ? (unsigned int)wifi.sta_channel : 0U,
wifi_available && wifi.ap_running ? "true" : "false",
wifi_available ? (unsigned int)wifi.ap_client_count : 0U,
serial_service_is_running() ? "true" : "false",
serial_config_available ? (serial_snapshot.running ? "true" : "false") : "null",
serial_config_available ? "true" : "false",
serial_config_available ? serial_config.baud_rate : 0U,
serial_config_available ? safe_string(serial_config_data_bits_to_string(serial_config.data_bits)) : "unknown",
@@ -375,6 +377,17 @@ static const httpd_uri_t s_serial_settings_uri = {
.handler = serial_settings_handler,
};
static const httpd_uri_t s_serial_operation_get_uri = {
.uri = "/api/settings/serial-operation",
.method = HTTP_GET,
.handler = web_serial_settings_handler,
};
static const httpd_uri_t s_serial_operation_post_uri = {
.uri = "/api/settings/serial-operation",
.method = HTTP_POST,
.handler = web_serial_settings_handler,
};
static const httpd_uri_t s_root_uri = {
.uri = "/",
.method = HTTP_GET,
@@ -563,7 +576,7 @@ esp_err_t web_server_start(void)
config.httpd.max_open_sockets = 6;
config.httpd.max_uri_handlers =
sizeof(s_uri_handlers) / sizeof(s_uri_handlers[0]) +
sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 3U;
sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 5U;
/* Exhaustion rejects new sockets, never evicts an existing serial writer. */
config.httpd.lru_purge_enable = false;
config.httpd.recv_wait_timeout = 1;
@@ -611,6 +624,9 @@ esp_err_t web_server_start(void)
admin_transport_owned = web_admin_transport_attach(server) == ESP_OK;
/* Optional settings allocation failure must not disable either terminal. */
(void)web_httpd_register_optional_get(server, &s_serial_settings_uri);
if (web_httpd_register_optional(server, &s_serial_operation_get_uri) == ESP_OK &&
web_httpd_register_optional(server, &s_serial_operation_post_uri) != ESP_OK)
(void)httpd_unregister_uri_handler(server, s_serial_operation_get_uri.uri, HTTP_GET);
}
if (error != ESP_OK) {
web_cookie_auth_stop();
+152 -10
View File
@@ -83,6 +83,9 @@ static const char s_index_html[] =
".settings-page{overflow:auto;padding:8px;min-height:0}.settings-page h2{margin:0 0 8px;font-size:18px}"
".settings-values{display:grid;grid-template-columns:minmax(110px,1fr) minmax(0,2fr);gap:8px 16px;max-width:600px}"
".settings-values dt{color:var(--muted)}.settings-values dd{margin:0;overflow-wrap:anywhere}\n"
".serial-edit{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;max-width:600px}"
".serial-edit label{display:grid;gap:4px;color:var(--muted)}.serial-edit input,.serial-edit select{font:inherit;width:100%;min-width:0;padding:8px;background:var(--panel);color:var(--text);border:1px solid var(--line);border-radius:6px}"
".serial-actions{display:flex;flex-wrap:wrap;gap:8px;margin:12px 0}\n"
".terminal-toolbar{flex-wrap:wrap}.terminal-toolbar .button{min-height:32px;padding:4px 10px}\n"
"@media(max-width:850px){html,body{overflow:auto}.page{height:auto;min-height:100dvh;grid-template-rows:auto auto minmax(280px,1fr)}"
".terminal-panel{min-height:280px}.dashboard{grid-template-columns:1fr}.controls{align-items:flex-start}"
@@ -175,7 +178,7 @@ static const char s_index_html[] =
"<div id=\"terminal\" class=\"terminal-host\"></div>\n"
"<div id=\"admin-terminal\" class=\"terminal-host\" hidden></div>\n"
"<section id=\"serial-settings\" class=\"settings-page\" aria-label=\"Serial settings\" hidden>"
"<h2>Serial</h2><p class=\"connection-detail\">Read-only working UART1 configuration, not saved NVS values. "
"<h2>Serial</h2><p class=\"connection-detail\">Working UART1 configuration below is not a saved NVS snapshot. "
"Navigation leaves both terminals connected and preserves the serial writer lease.</p>"
"<button id=\"refresh-settings\" class=\"button\" type=\"button\">Refresh</button>"
"<p id=\"settings-detail\" class=\"connection-detail\" role=\"status\">Select Refresh to read current values.</p>"
@@ -184,7 +187,33 @@ static const char s_index_html[] =
"<dt>Data bits</dt><dd id=\"setting-data_bits\"></dd><dt>Parity</dt><dd id=\"setting-parity\"></dd>"
"<dt>Stop bits</dt><dd id=\"setting-stop_bits\"></dd><dt>Flow control</dt><dd id=\"setting-flow\"></dd>"
"<dt>DTR behavior</dt><dd id=\"setting-dtr\"></dd><dt>RTS threshold</dt><dd id=\"setting-rts_threshold\"></dd>"
"</dl></section>\n"
"</dl>"
"<div id=\"serial-edit\" hidden><h3>Edit Working Configuration</h3>"
"<div class=\"serial-edit\">"
"<label>Baud rate<input id=\"edit-baud\" type=\"number\" min=\"110\" max=\"1000000\" step=\"1\"></label>"
"<label>Data bits<select id=\"edit-data_bits\"><option>7</option><option>8</option></select></label>"
"<label>Parity<select id=\"edit-parity\"><option>none</option><option>even</option><option>odd</option></select></label>"
"<label>Stop bits<select id=\"edit-stop_bits\"><option>1</option><option>2</option></select></label>"
"<label>Flow control<select id=\"edit-flow\"><option>none</option><option>rts-cts</option></select></label>"
"<label>DTR behavior<select id=\"edit-dtr\"><option>inactive</option><option>active</option><option>on-connect</option></select></label>"
"<label>RTS threshold<input id=\"edit-rts_threshold\" type=\"number\" min=\"1\" max=\"127\" step=\"1\"></label></div>"
"<p class=\"connection-detail\">Edits are a browser draft until Apply. Apply replaces all working fields, including intervening CLI edits. "
"Save persists the device's working configuration at execution, not this draft. Refresh discards the draft. "
"Load uses stored settings, or defaults if storage is absent/incompatible. Defaults changes RAM only; Reset applies and saves defaults.</p>"
"<p class=\"connection-detail\">Stop or reconfiguration discards queued serial-service RX/TX and pending bytes. "
"Broker clients, writer lease and already-fanned output remain. Open USB may restart a stopped service. "
"Service running state is not saved.</p>"
"<div class=\"serial-actions\">"
"<button id=\"serial-apply\" class=\"button\" type=\"button\">Apply to RAM</button>"
"<button id=\"serial-save\" class=\"button\" type=\"button\">Save Working to NVS</button>"
"<button id=\"serial-load\" class=\"button\" type=\"button\">Load</button>"
"<button id=\"serial-defaults\" class=\"button\" type=\"button\">Defaults in RAM</button>"
"<button id=\"serial-reset\" class=\"button\" type=\"button\">Reset and Save Defaults</button>"
"<button id=\"serial-start\" class=\"button\" type=\"button\">Start</button>"
"<button id=\"serial-stop\" class=\"button\" type=\"button\">Stop</button></div></div>"
"<button id=\"serial-result\" class=\"button\" type=\"button\">Check Operation Result</button>"
"<p id=\"serial-operation-detail\" class=\"connection-detail\" role=\"status\">Check Result after any uncertain submission; never assume timeout or navigation cancels an operation.</p>"
"</section>\n"
"</section>\n"
"</main>\n"
"</body>\n"
@@ -215,18 +244,60 @@ static const char s_app_js[] =
"const settingsHost = element('serial-settings'), settingsDetail = element('settings-detail');\n"
"const settingsFields = ['running', 'baud', 'data_bits', 'parity', 'stop_bits', 'flow', 'dtr', 'rts_threshold'];\n"
"let settingsAbort = null;\n"
"const serialActions = ['apply', 'save', 'load', 'defaults', 'reset', 'start', 'stop'];\n"
"let serialOperationId = 0, serialOperationPending = false, serialAwaitingAck = false;\n"
"let serialOutcomeWarning = '', serialAuto = null;\n"
"function stopSerialAuto(recovery = false) {\n"
" if (!serialAuto) return;\n"
" window.clearTimeout(serialAuto.timer); window.clearTimeout(serialAuto.deadline); serialAuto = null;\n"
" if (recovery) element('serial-operation-detail').textContent += ' Automatic checking stopped; outcome still uncertain. Select Check Result; do not resubmit.';\n"
"}\n"
"function expireSerialAuto(auto) {\n"
" if (serialAuto !== auto) return;\n"
" stopSerialAuto(true);\n"
" if (settingsAbort) settingsAbort.abort();\n"
" settingsAbort = null; serialButtons();\n"
"}\n"
"function scheduleSerialCheck() {\n"
" const auto = serialAuto;\n"
" if (!auto) return;\n"
" if (auto.attempts >= 10) { stopSerialAuto(true); serialButtons(); return; }\n"
" auto.timer = window.setTimeout(() => {\n"
" if (serialAuto !== auto) return;\n"
" if (performance.now() >= auto.until) { expireSerialAuto(auto); return; }\n"
" ++auto.attempts; serialOperation(null, true);\n"
" }, 1000);\n"
"}\n"
"function startSerialAuto() {\n"
" const auto = {attempts: 0, timer: 0, deadline: 0, until: performance.now() + 15000}; serialAuto = auto;\n"
" auto.deadline = window.setTimeout(() => expireSerialAuto(auto), 15000);\n"
" scheduleSerialCheck();\n"
"}\n"
"function serialButtons() {\n"
" const busy = !!settingsAbort || !!serialAuto;\n"
" for (const action of serialActions) element('serial-' + action).disabled = busy || serialOperationPending;\n"
" for (const key of settingsFields.slice(1)) element('edit-' + key).disabled = busy || serialOperationPending;\n"
" element('refresh-settings').disabled = busy;\n"
" element('serial-result').disabled = busy;\n"
"}\n"
"function clearSettings() {\n"
" if (!serialAuto && serialOperationPending) element('serial-operation-detail').textContent = serialOutcomeWarning + 'Operation outcome pending or unknown. Select Check Result on return; navigation does not cancel backend work.';\n"
" stopSerialAuto(true);\n"
" if (settingsAbort) settingsAbort.abort();\n"
" settingsAbort = null; settingsHost.hidden = true; element('settings-values').hidden = true;\n"
" element('serial-edit').hidden = true;\n"
" for (const key of settingsFields.slice(1)) element('edit-' + key).value = '';\n"
" serialButtons();\n"
" for (const key of settingsFields) element('setting-' + key).textContent = '';\n"
" element('refresh-settings').disabled = false; settingsDetail.textContent = 'Select Refresh to read current values.';\n"
"}\n"
"async function refreshSettings() {\n"
" if (selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || settingsAbort) return;\n"
" clearSettings(); settingsHost.hidden = false;\n"
" if (selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || settingsAbort || serialAuto) return;\n"
" settingsHost.hidden = false;\n"
" const controller = new AbortController(), generation = workGeneration; settingsAbort = controller;\n"
" serialButtons();\n"
" const current = () => settingsAbort === controller && selected === 'settings';\n"
" element('refresh-settings').disabled = true; settingsDetail.textContent = 'Reading serial configuration...';\n"
" element('refresh-settings').disabled = true; settingsDetail.textContent = 'Reading serial configuration... Previous snapshot is stale until refreshed.';\n"
" try {\n"
" if (!await loadSession(generation, controller.signal, false)) throw new Error('Session check cancelled');\n"
" if (!current()) return;\n"
@@ -237,11 +308,79 @@ static const char s_app_js[] =
" !['1', '2'].includes(value.stop_bits) || !['none', 'rts-cts'].includes(value.flow) ||\n"
" !['inactive', 'active', 'on-connect'].includes(value.dtr) || !Number.isInteger(value.rts_threshold) || value.rts_threshold < 1 || value.rts_threshold > 127) throw new Error('Invalid snapshot');\n"
" for (const key of settingsFields) element('setting-' + key).textContent = key === 'running' ? (value[key] ? 'Running' : 'Stopped') : String(value[key]);\n"
" element('settings-values').hidden = false; settingsDetail.textContent = 'Snapshot loaded. Refresh to see later changes; nothing is applied or saved here.';\n"
" for (const key of settingsFields.slice(1)) element('edit-' + key).value = String(value[key]);\n"
" element('serial-edit').hidden = false; element('settings-values').hidden = false; settingsDetail.textContent = (serialOperationPending ? 'Snapshot may be stale: operation outcome pending or unknown. ' : 'Working snapshot loaded. ') + 'Apply changes RAM; Save explicitly persists working settings. Refresh replaces your draft.';\n"
" } catch (error) {\n"
" if (live(generation) && current()) settingsDetail.textContent = (error.status ? error.message : 'Serial snapshot could not be read or was invalid.') + ' Select Refresh to retry.';\n"
" if (live(generation) && current()) settingsDetail.textContent = (error.status ? error.message : 'Serial snapshot could not be read or was invalid.') + ' Snapshot stale or unavailable. Select Refresh to retry.';\n"
" } finally {\n"
" if (current()) { settingsAbort = null; element('refresh-settings').disabled = false; }\n"
" if (current()) { settingsAbort = null; element('refresh-settings').disabled = false; serialButtons(); }\n"
" }\n"
"}\n"
"async function serialOperation(action, automatic = false) {\n"
" if (selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || settingsAbort || (action && serialOperationPending)) return;\n"
" if (!automatic && serialAuto) return;\n"
" const detail = element('serial-operation-detail');\n"
" let refresh = false, poll = false;\n"
" let body;\n"
" if (action) {\n"
" if (element('serial-edit').hidden) return;\n"
" const value = {action};\n"
" if (action === 'apply') {\n"
" for (const key of settingsFields.slice(1)) value[key] = element('edit-' + key).value;\n"
" if (!/^[0-9]{1,7}$/.test(value.baud) || !/^[0-9]{1,3}$/.test(value.rts_threshold)) { detail.textContent = 'Enter whole-number baud and RTS threshold.'; return; }\n"
" value.baud = Number(value.baud); value.rts_threshold = Number(value.rts_threshold);\n"
" if (value.baud < 110 || value.baud > 1000000 || value.rts_threshold < 1 || value.rts_threshold > 127 ||\n"
" !['7','8'].includes(value.data_bits) || !['none','even','odd'].includes(value.parity) || !['1','2'].includes(value.stop_bits) ||\n"
" !['none','rts-cts'].includes(value.flow) || !['inactive','active','on-connect'].includes(value.dtr)) { detail.textContent = 'Invalid serial framing or range.'; return; }\n"
" }\n"
" if (action === 'reset' && !window.confirm('Reset applies defaults and overwrites saved NVS configuration. Continue?')) return;\n"
" body = JSON.stringify(value);\n"
" if (new TextEncoder().encode(body).length > 256) return;\n"
" }\n"
" const controller = new AbortController(), generation = workGeneration; settingsAbort = controller;\n"
" const auto = automatic ? serialAuto : null;\n"
" const current = () => {\n"
" if (auto && serialAuto === auto && performance.now() >= auto.until) expireSerialAuto(auto);\n"
" return settingsAbort === controller && selected === 'settings';\n"
" };\n"
" element('refresh-settings').disabled = true; serialButtons();\n"
" detail.textContent = serialOutcomeWarning + (action ? (action === 'apply' ? 'Applying' : action) + '... Submitting once; completion will be checked automatically.' : 'Reading latest result for this login...');\n"
" settingsDetail.textContent = 'Snapshot stale: operation pending or outcome not yet checked.';\n"
" try {\n"
" if (!await loadSession(generation, controller.signal, false)) throw new Error('Session check cancelled');\n"
" if (!current()) return;\n"
" if (action) { serialOperationPending = true; serialAwaitingAck = true; }\n"
" const {payload: result} = await api('/api/settings/serial-operation', generation, {method: action ? 'POST' : 'GET', body, signal: controller.signal, limit: 96, current});\n"
" if (!result || Object.keys(result).length !== 3 || !Number.isInteger(result.id) || result.id < 0 || result.id > 4294967295 ||\n"
" !['none', ...serialActions].includes(result.action) || !['idle','pending','ok','failed','cancelled','loaded_defaults','rollback_failed'].includes(result.state) ||\n"
" ((result.id === 0) !== (result.state === 'idle')) || ((result.id === 0) !== (result.action === 'none')) ||\n"
" (action && (!result.id || result.action !== action || result.state !== 'pending'))) throw new Error('Invalid operation response');\n"
" const uncertain = !action && serialAwaitingAck;\n"
" const replaced = !action && serialOperationId && serialOperationId !== result.id;\n"
" if (action) serialOutcomeWarning = '';\n"
" else if (uncertain) serialOutcomeWarning = 'Submission acknowledgement was lost; this latest result may belong to an earlier operation or another tab. Inspect before retrying. ';\n"
" else if (replaced) serialOutcomeWarning = 'Previous result was replaced or unavailable; its outcome is unknown. ';\n"
" serialOperationId = result.id; serialOperationPending = result.state === 'pending'; serialAwaitingAck = false;\n"
" const messages = {idle: 'No retained result. Outcome may be unknown; inspect working settings and CLI storage before retrying.',\n"
" pending: 'Pending: queued or executing; do not resubmit. Automatic checks are bounded; Check Result is available for recovery.',\n"
" ok: 'Operation completed. Apply/Defaults change RAM only; Save/Reset persist NVS.',\n"
" loaded_defaults: 'Load applied defaults because saved storage was absent or incompatible. NVS was not changed.',\n"
" failed: 'Operation failed; rollback is best-effort and state may have changed. Inspect working settings and UART0 before retrying.',\n"
" rollback_failed: 'Reset persistence and runtime rollback failed. Inspect UART0 and working settings; do not assume defaults were saved.',\n"
" cancelled: 'Operation rejected before execution because the login or queue deadline was no longer current.'};\n"
" detail.textContent = serialOutcomeWarning + result.action + ': ' + messages[result.state];\n"
" poll = result.state === 'pending' && (!!action || automatic);\n"
" refresh = !action && result.state !== 'pending' && result.state !== 'idle';\n"
" } catch (error) {\n"
" if (live(generation) && current()) detail.textContent = serialOutcomeWarning + (error.status ? error.message : 'Operation outcome unknown.') + ' Check Result and Refresh before any explicit retry. No automatic retry.';\n"
" } finally {\n"
" if (current()) {\n"
" settingsAbort = null;\n"
" if (poll) { if (action) startSerialAuto(); else scheduleSerialCheck(); }\n"
" else stopSerialAuto();\n"
" serialButtons();\n"
" if (refresh) await refreshSettings();\n"
" }\n"
" }\n"
"}\n"
"let accountRole = 'user', selected = 'serial';\n"
@@ -287,6 +426,7 @@ static const char s_app_js[] =
" updateControls();\n"
"}\n"
"function selectTerminal(mode) {\n"
" if (mode === selected) return;\n"
" if (unloading || navigating || loggingOut || !sessionVerified || (mode !== 'serial' && accountRole !== 'admin')) return;\n"
" clearSettings();\n"
" selected = mode;\n"
@@ -428,7 +568,7 @@ static const char s_app_js[] =
" return JSON.parse(new TextDecoder('utf-8', {fatal: true}).decode(bytes.subarray(0, length)));\n"
" } finally { await reader.cancel().catch(() => {}); }\n"
"}\n"
"async function api(path, generation, {method = 'GET', signal, limit = 512, current = () => true} = {}) {\n"
"async function api(path, generation, {method = 'GET', body, signal, limit = 512, current = () => true} = {}) {\n"
" const controller = new AbortController();\n"
" const abort = () => controller.abort();\n"
" if (signal) { signal.addEventListener('abort', abort, {once: true}); if (signal.aborted) abort(); }\n"
@@ -438,7 +578,7 @@ static const char s_app_js[] =
/* Non-CORS POST with no-referrer serializes Origin as null in browsers. */
" const response = await fetch(path, {method, credentials: 'same-origin', mode: method === 'POST' ? 'cors' : 'same-origin',\n"
" cache: 'no-store', redirect: 'error', signal: controller.signal,\n"
" ...(method === 'POST' ? {headers: {'X-CSRF-Token': csrf}, body: ''} : {})});\n"
" ...(method === 'POST' ? {headers: {'X-CSRF-Token': csrf, ...(body === undefined ? {} : {'Content-Type': 'application/json'})}, body: body === undefined ? '' : body} : {})});\n"
" if (!live(generation) || controller.signal.aborted || !current()) throw new Error('Cancelled');\n"
" if (response.status === 401) { login(); throw new Error('Session ended.'); }\n"
" if (!response.ok) {\n"
@@ -707,6 +847,8 @@ static const char s_app_js[] =
"element('select-admin').addEventListener('click', () => selectTerminal('admin'));\n"
"element('select-settings').addEventListener('click', () => selectTerminal('settings'));\n"
"element('refresh-settings').addEventListener('click', refreshSettings);\n"
"for (const action of serialActions) element('serial-' + action).addEventListener('click', () => serialOperation(action));\n"
"element('serial-result').addEventListener('click', () => serialOperation(null));\n"
"adminToggle.addEventListener('click', () => { if (adminSocket || adminAbort) closeAdmin(); else openAdmin(); });\n"
"const fitTerminal = () => {\n"
" fitFrame = 0;\n"
+8 -1
View File
@@ -31,6 +31,13 @@ typedef int *SemaphoreHandle_t;
#define pdMS_TO_TICKS(x) (x)
#define CONSOLE_COMPLETION_OUTPUT_CAPACITY 1024U
static unsigned lock_depth, ticks, runs, actions;
static uint32_t serial_settings_executed;
static unsigned serial_settings_preceding_runs, queue_send_wait;
static void web_serial_settings_execute(uint32_t id) {
assert(!lock_depth);
serial_settings_executed = id;
serial_settings_preceding_runs = runs;
}
static bool principal_current = true, queue_full, owner_drained = true;
static TaskHandle_t current_task = (void *)1;
static jmp_buf loop_done;
@@ -58,7 +65,7 @@ static unsigned ulTaskNotifyTake(int b, unsigned t) { (void)b; (void)t; return 1
static QueueHandle_t xQueueCreateStatic(unsigned n, size_t s, uint8_t *b, StaticQueue_t *q)
{ (void)b; q->size = s; q->capacity = n; assert(n*s <= sizeof(q->bytes)); return q; }
static int xQueueSend(QueueHandle_t q, const void *p, unsigned t)
{ (void)t; if (queue_full || q->count==q->capacity) return 0;
{ queue_send_wait=t; if (queue_full || q->count==q->capacity) return 0;
memcpy(q->bytes+q->count*q->size,p,q->size); ++q->count; return 1; }
static int xQueueReceive(QueueHandle_t q, void *p, unsigned t)
{ (void)t; if (!q->count) longjmp(loop_done,1); memcpy(p,q->bytes,q->size);
+27
View File
@@ -313,5 +313,32 @@ int main(void)
assert(!lock_depth);
test_currentness();
test_dispatch_currentness();
unsigned before_serial = runs;
assert(admin_ssh_console_submit_serial_settings(0) == ESP_ERR_INVALID_STATE);
s_dispatch_ready = false;
assert(admin_ssh_console_submit_serial_settings(1) == ESP_ERR_INVALID_STATE);
s_dispatch_ready = true; queue_full = true;
assert(admin_ssh_console_submit_serial_settings(1) == ESP_ERR_TIMEOUT);
queue_full = false;
admin_request_t preceding_uart = {.origin = ADMIN_REQUEST_UART0};
assert(xQueueSend(s_request_queue, &preceding_uart, 0));
queue_send_wait = portMAX_DELAY;
assert(admin_ssh_console_submit_serial_settings(17) == ESP_OK && !serial_settings_executed);
assert(queue_send_wait == 0);
admin_request_t typed;
memcpy(&typed, s_request_queue->bytes + sizeof(typed), sizeof(typed));
assert(typed.origin == ADMIN_REQUEST_SERIAL_SETTINGS && typed.serial_settings_id == 17);
assert(s_request_queue->capacity == 4 && sizeof(typed.line) == 257);
pump(worker_task);
assert(serial_settings_executed == 17 && runs == before_serial + 1 && !s_request_queue->count);
assert(serial_settings_preceding_runs == before_serial + 1);
for (unsigned i = 0; i < s_request_queue->capacity; ++i)
assert(xQueueSend(s_request_queue, &preceding_uart, 0));
queue_send_wait = portMAX_DELAY;
assert(admin_ssh_console_submit_serial_settings(18) == ESP_ERR_TIMEOUT);
assert(queue_send_wait == 0 && s_request_queue->count == 4 && serial_settings_executed == 17);
pump(worker_task);
assert(runs == before_serial + 5 && serial_settings_executed == 17 && !s_request_queue->count);
puts("PASS: typed Serial admission uses zero wait on success/full queue, preserves all four queued UART requests and FIFO execution, no command-string dispatch");
puts("PASS: admission/identity, two owners, completion contention/reopen, history, queued stale/revoked work, UART dispatch, hidden/disconnected prompts, exit-to-SELF_CLOSE, deferred rejection/drain/close, 5s output backpressure");
}
+30 -11
View File
@@ -37,8 +37,8 @@ def define(path, name):
uri_tables = re.findall(r'^static const httpd_uri_t(?: \*const)? \w+\[?\]? = \{.*?^\};',
source, re.M | re.S)
# Non-array declarations have no brackets; explicit shape avoids silent omission.
if len(uri_tables) != 14:
raise RuntimeError('Review URI extraction: expected 12 descriptors and two tables')
if len(uri_tables) != 16:
raise RuntimeError('Review URI extraction: expected 14 descriptors and two tables')
state = source[source.index('static SemaphoreHandle_t s_server_mutex;'):
source.index('static esp_err_t ensure_mutex(void)')]
header = (ROOT / 'src/web_server.h').read_text()
@@ -95,6 +95,7 @@ static unsigned registration_calls, registration_fail_at, registered_count, unre
static bool unregister_fail;
static bool settings_fail;
static unsigned settings_calls;
static unsigned operation_calls, operation_fail_at;
static const httpd_uri_t *registered[32];
static char events[128]; static size_t event_length;
static void event(char value) { assert(!locked && event_length + 1 < sizeof(events)); events[event_length++] = value; events[event_length] = 0; }
@@ -107,6 +108,7 @@ HANDLER(root_handler) HANDLER(status_handler) HANDLER(ticket_handler)
HANDLER(websocket_handler) HANDLER(asset_handler) HANDLER(web_cookie_auth_handler)
HANDLER(web_admin_transport_ticket_handler) HANDLER(web_admin_transport_upgrade_handler)
HANDLER(serial_settings_handler)
HANDLER(web_serial_settings_handler)
static esp_err_t route_error_handler(httpd_req_t *r, httpd_err_code_t c) { (void)r; (void)c; assert(0); return ESP_FAIL; }
static esp_err_t web_serial_transport_init(void) { assert(!locked); ++serial_inits; return serial_init_error; }
static esp_err_t web_cookie_auth_start(void) { assert(!locked); ++auth_starts; auth_live = auth_error == ESP_OK; return auth_error; }
@@ -118,7 +120,7 @@ static esp_err_t web_security_copy_tls_material(uint8_t *cert, size_t nc, size_t
static esp_err_t httpd_ssl_start(httpd_handle_t *server, const httpd_ssl_config_t *config) {
assert(!locked && auth_live && !ssl_live); ++ssl_starts;
assert(config->httpd.max_open_sockets == 6 && !config->httpd.lru_purge_enable);
assert(config->httpd.max_uri_handlers == 17 && config->port_secure == 443);
assert(config->httpd.max_uri_handlers == 19 && 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->servercert_len == 1 && config->servercert[0] == 1);
@@ -151,9 +153,17 @@ static esp_err_t web_httpd_register_optional_get(httpd_handle_t s, const httpd_u
assert(!strcmp(uri->uri, "/api/settings/serial"));
return httpd_register_uri_handler(s, uri);
}
static esp_err_t web_httpd_register_optional(httpd_handle_t s, const httpd_uri_t *uri) {
assert(s == SERVER && auth_live && ssl_live && !locked);
assert(!strcmp(uri->uri, "/api/settings/serial-operation") && uri->handler == web_serial_settings_handler);
if (++operation_calls == operation_fail_at) return ESP_ERR_NO_MEM;
registered[registered_count++] = uri;
return ESP_OK;
}
static esp_err_t httpd_unregister_uri_handler(httpd_handle_t s, const char *uri, int method) {
assert(!locked && s == SERVER && ssl_live && auth_live && serial_live);
assert(registration_calls == 18 && !strcmp(uri, "/api/admin/ws-ticket") && method == HTTP_POST);
assert((registration_calls == 18 && !strcmp(uri, "/api/admin/ws-ticket") && method == HTTP_POST) ||
(!strcmp(uri, "/api/settings/serial-operation") && method == HTTP_GET));
++unregister_calls;
for (unsigned i = 0; i < registered_count; ++i) {
if (!strcmp(registered[i]->uri, uri) && registered[i]->method == method) {
@@ -217,6 +227,7 @@ static void reset(void) {
serial_attaches = serial_detaches = admin_attaches = admin_detaches = admin_stoppeds = 0;
registration_calls = registration_fail_at = registered_count = unregister_calls = 0;
unregister_fail = settings_fail = false; settings_calls = 0; clear_events();
operation_calls = operation_fail_at = 0;
}
static void fresh_registration(void) { registration_calls = registered_count = 0; }
static void start(void) {
@@ -250,7 +261,7 @@ int main(void) {
}
puts("PASS optional admin init/attach failures do not disable M1 auth or serial attachment");
reset(); start(); assert(registered_count == 17 && registration_calls == 18 && settings_calls == 1);
reset(); start(); assert(registered_count == 19 && registration_calls == 18 && settings_calls == 1 && operation_calls == 2);
assert(route("/api/settings/serial")->handler == serial_settings_handler);
const httpd_uri_t *ticket = route("/api/admin/ws-ticket"), *ws = route("/ws/admin");
assert(ticket->method == HTTP_POST && ticket->handler == web_admin_transport_ticket_handler && !ticket->is_websocket);
@@ -302,7 +313,7 @@ int main(void) {
assert(s_serial_transport_attached && !s_admin_transport_owned && !admin_owned);
assert(!admin_inits && !admin_attaches && !auth_stops && !ssl_stops);
assert(!s_transitioning && s_last_error == ESP_OK && s_counters.starts == 1 && !s_counters.start_failures);
assert(registered_count == 15 && unregister_calls == failure - 17);
assert(registered_count == 17 && unregister_calls == failure - 17);
for (unsigned i = 0; i < registered_count; ++i)
assert(strcmp(registered[i]->uri, "/api/admin/ws-ticket") && strcmp(registered[i]->uri, "/ws/admin"));
assert(route("/ws/serial")->handler == websocket_handler);
@@ -311,13 +322,13 @@ int main(void) {
clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH"));
assert(!admin_detaches && !admin_stoppeds);
registration_fail_at = 0; fresh_registration(); start();
assert(registered_count == 17 && admin_attaches == 1 && s_counters.starts == 2);
assert(registered_count == 19 && admin_attaches == 1 && s_counters.starts == 2);
assert(web_server_stop() == ESP_OK && admin_stoppeds == 1);
}
puts("PASS optional positions 17..18 preserve M1, roll back ticket when needed and recover after stop/restart");
reset(); registration_fail_at = 18; unregister_fail = true;
assert(web_server_start() == ESP_OK && unregister_calls == 1 && registered_count == 16);
assert(web_server_start() == ESP_OK && unregister_calls == 1 && registered_count == 18);
assert(auth_live && ssl_live && serial_live && s_serial_transport_attached);
assert(!admin_inits && !admin_attaches && !admin_owned && !s_admin_transport_owned);
ticket = route("/api/admin/ws-ticket");
@@ -329,7 +340,7 @@ int main(void) {
clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH"));
assert(!admin_detaches && !admin_stoppeds);
unregister_fail = false; registration_fail_at = 0; fresh_registration(); start();
assert(registered_count == 17 && admin_attaches == 1 && web_server_stop() == ESP_OK);
assert(registered_count == 19 && admin_attaches == 1 && web_server_stop() == ESP_OK);
puts("PASS failed unregister retains only original ticket handler, no admin attachment, and permits restart");
reset(); registration_fail_at = 6; ssl_stop_error = ESP_FAIL;
@@ -351,13 +362,21 @@ int main(void) {
assert(web_server_stop() == ESP_ERR_INVALID_STATE && !auth_stops);
puts("PASS auth/start failure gates and invalid/transitioning lifecycle rejection");
reset(); settings_fail = true; start();
assert(settings_calls == 1 && registered_count == 16);
assert(settings_calls == 1 && registered_count == 18);
assert(auth_live && serial_live && admin_owned && web_server_stop() == ESP_OK);
settings_fail = false; fresh_registration(); start();
assert(route("/api/settings/serial")->handler == serial_settings_handler);
assert(web_server_stop() == ESP_OK);
puts("PASS optional Settings registration failure preserves auth and both transports; restart recovers");
puts("12 lifecycle groups passed (16 required fatal positions, 3 optional routes, plus failed unregister)");
for (unsigned failure = 1; failure <= 2; ++failure) {
reset(); operation_fail_at = failure; start();
assert(registered_count == 17 && operation_calls == failure && unregister_calls == failure - 1);
assert(auth_live && serial_live && admin_owned);
for (unsigned i = 0; i < registered_count; ++i) assert(strcmp(registered[i]->uri, "/api/settings/serial-operation"));
assert(web_server_stop() == ESP_OK);
}
puts("PASS optional Serial operation GET/POST failure never publishes a mutation-only route or disables transports");
puts("13 lifecycle groups passed (16 required fatal positions, 5 optional routes, plus failed unregister)");
return 0;
}
'''
+26
View File
@@ -44,6 +44,7 @@ esp_err_t httpd_ws_respond_server_handshake(httpd_req_t *, const char *);
admin = "--admin" in sys.argv
settings = "--settings" in sys.argv
serial_settings = "--serial-settings" in sys.argv
if admin:
HEADERS["esp_system.h"] = "#pragma once\nvoid esp_restart(void);\n"
HEADERS["esp_heap_caps.h"] = """#pragma once
@@ -113,7 +114,31 @@ with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory:
settings_source += function(config_source[config_source.index('const char *serial_config_' + name + '_to_string'):], 'serial_config_' + name + '_to_string') + '\n'
for name in ('set_common_headers', 'send_plain_error', 'authorize_or_respond', 'safe_string', 'serial_settings_handler'):
settings_source += function(server_source, name) + '\n'
# Exercise the serial projection of /api/status without doubling every
# unrelated subsystem. Copy its acquisition, format and arguments verbatim.
status = function(server_source, 'status_handler')
assert 'serial_service_get_config(' not in status, 'status must not use the blocking config getter'
assert 'serial_service_is_running(' not in status, 'status must use snapshot running state'
assert status.count('serial_service_get_snapshot(') == 1
acquisition = status[status.index(' bool serial_config_available ='):
status.index(' serial_service_get_counters(')]
serial_format = status[status.index(' " \\"serial\\":'):
status.index(' " \\"broker\\":')]
argument_start = status.index(' wifi_available ? (unsigned int)wifi.ap_client_count : 0U,')
argument_start = status.index('\n', argument_start) + 1
serial_arguments = status[argument_start:status.index(' broker_available ? "true"')].rstrip().removesuffix(',')
settings_source += '\nstatic int status_serial_projection(char *response, size_t capacity) {\n'
settings_source += ' serial_service_snapshot_t serial_snapshot = {0};\n'
settings_source += ' serial_service_counters_t serial_counters = {0};\n' + acquisition
settings_source += ' return snprintf(response, capacity,\n' + serial_format + ',\n' + serial_arguments + ');\n}\n'
(tmp / 'settings_production.h').write_text(settings_source)
if serial_settings:
config_source = (ROOT / 'src/serial_config.c').read_text()
names = ['serial_config_defaults', 'serial_config_validate']
names += ['serial_config_parse_' + name for name in ('data_bits', 'parity', 'stop_bits', 'flow_control', 'dtr_behavior')]
(tmp / 'serial_config_production.h').write_text('\n'.join(function(config_source, name) for name in names))
console_source = (ROOT / 'src/serial_console.c').read_text()
(tmp / 'serial_console_production.h').write_text('\n'.join(function(console_source, name) for name in ('parse_unsigned', 'set_parameter', 'command_serial')))
sources = [HERE / "test.c", tmp / "installed_httpd.c"]
sources += [ROOT / "src" / name for name in ["web_session_store.c", "web_auth_parse.c", "web_cookie_auth.c", "web_httpd_adapter.c"]]
if admin:
@@ -129,6 +154,7 @@ with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory:
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror", "-g", "-DHOST_OPENSSL",
*(["-DHOST_ADMIN"] if admin else []),
*(["-DHOST_SETTINGS"] if settings else []),
*(["-DHOST_SERIAL_SETTINGS"] if serial_settings else []),
"-I" + str(tmp), "-I" + str(ROOT / "src"), *map(str, sources), "-lcrypto",
"-o", str(tmp / "test")], check=True, timeout=30)
subprocess.run([str(tmp / "test")], check=True, timeout=20)
@@ -0,0 +1,264 @@
/* Real HTTP policy/store/parser/operation owner; deterministic serial/NVS/queue. */
#include "serial_service.h"
#include <errno.h>
#include <stdlib.h>
#define ESP_ERR_TIMEOUT 0x107
#include "serial_config_production.h"
#include "../../src/web_serial_settings.c"
static serial_config_t working, persisted;
static bool on_dispatcher, have_stored = true, queue_fail;
static unsigned api_calls, apply_calls, save_calls, load_calls, start_calls, stop_calls;
static unsigned apply_fail_at;
static esp_err_t get_error, save_error, load_error, start_error, stop_error;
static uint32_t queued_id;
static void (*apply_hook)(void);
esp_err_t admin_ssh_console_submit_serial_settings(uint32_t id) {
assert(!host_lock_depth && !on_dispatcher && id);
if (queue_fail) return ESP_ERR_TIMEOUT;
queued_id = id; return ESP_OK;
}
static void serial_api(void) { assert(on_dispatcher && !host_lock_depth); ++api_calls; }
esp_err_t serial_service_apply_config(const serial_config_t *config) {
serial_api(); ++apply_calls;
assert(serial_config_validate(config) == ESP_OK);
if (apply_hook) { void (*hook)(void) = apply_hook; apply_hook = NULL; hook(); }
if (apply_fail_at == apply_calls) return ESP_FAIL;
working = *config; return ESP_OK;
}
esp_err_t serial_service_get_config(serial_config_t *config) {
serial_api(); *config = working; return get_error;
}
esp_err_t serial_service_start(void) { serial_api(); ++start_calls; return start_error; }
esp_err_t serial_service_stop(void) { serial_api(); ++stop_calls; return stop_error; }
esp_err_t serial_config_save(const serial_config_t *config) {
serial_api(); ++save_calls;
if (save_error == ESP_OK) { persisted = *config; have_stored = true; }
return save_error;
}
esp_err_t serial_config_load(serial_config_t *config, bool *stored) {
serial_api(); ++load_calls; *stored = have_stored;
if (have_stored) *config = persisted; else serial_config_defaults(config);
return load_error;
}
esp_err_t serial_config_reset_storage(void) {
serial_config_t defaults; serial_config_defaults(&defaults); return serial_config_save(&defaults);
}
static void operation_begin(const issued_t *identity, const char *body) {
begin("/api/settings/serial-operation", body ? HTTP_POST : HTTP_GET, body);
same_origin();
if (body) add("Content-Type", "application/json");
if (identity) {
char cookies[100]; snprintf(cookies, sizeof(cookies), "__Host-sak-session=%s", identity->token);
add("Cookie", cookies);
if (body) add("X-CSRF-Token", identity->view.csrf);
}
}
static void operation_expect(const char *status) {
unsigned before = api_calls;
esp_err_t error = web_serial_settings_handler(&req);
assert(error == (send_fail || aux.remaining_len ? ESP_FAIL : ESP_OK));
if (strcmp(response_status, status)) fprintf(stderr, "Expected %s, got %s\n", status, response_status);
assert(!strcmp(response_status, status) && api_calls == before);
assert(strlen(output) < 96); zero(scratch, sizeof(scratch));
}
static void execute(void) {
assert(queued_id); on_dispatcher = true;
web_serial_settings_execute(queued_id); on_dispatcher = false;
}
static void submit(const issued_t *identity, const char *body) {
operation_begin(identity, body); operation_expect("202 Accepted");
assert(s_operation.state == PENDING && queued_id == s_operation.id);
}
static void action(const issued_t *identity, const char *name) {
char body[40]; snprintf(body, sizeof(body), "{\"action\":\"%s\"}", name);
submit(identity, body); execute();
}
static const char apply_body[] = "{\"action\":\"apply\",\"baud\":230400,\"data_bits\":\"7\",\"parity\":\"even\",\"stop_bits\":\"2\",\"flow\":\"rts-cts\",\"dtr\":\"on-connect\",\"rts_threshold\":96}";
static void revoke_during_apply(void) { web_session_store_invalidate(s_operation.session); }
static issued_t executing_identity;
static void request_during_apply(void) {
serial_operation_t executing = s_operation;
operation_begin(&executing_identity, "{\"action\":\"reset\"}");
operation_expect("503 Service Unavailable");
assert(!memcmp(&executing, &s_operation, sizeof(executing)));
operation_begin(&executing_identity, NULL); operation_expect("200 OK");
assert(strstr(output, "\"state\":\"pending\""));
}
static int show_status(void) { return 0; }
static int show_counters(void) { return 0; }
static void print_config(const serial_config_t *config) { (void)config; }
static void print_usage(void) {}
bool serial_service_is_running(void) { return false; }
void serial_service_clear_counters(void) {}
const char *esp_err_to_name(esp_err_t error) { (void)error; return "fake error"; }
#include "serial_console_production.h"
static void serial_settings_tests(void) {
auth_reset(); issued_t admin = mint(&alice), user = mint(&bob), other = mint(&alice);
receive_fragment = 64;
serial_config_defaults(&working); persisted = working;
operation_begin(NULL, "{\"action\":\"save\"}"); operation_expect("401 Unauthorized");
operation_begin(&user, "{\"action\":\"save\"}"); operation_expect("403 Forbidden");
operation_begin(&user, NULL); operation_expect("403 Forbidden");
for (unsigned mode = 0; mode < 9; ++mode) {
operation_begin(&admin, "{\"action\":\"save\"}");
if (mode == 0) req.content_len = aux.remaining_len = 257;
if (mode == 1) req.uri = "/api/settings/serial-operation?command=save";
if (mode == 2) req.method = HTTP_GET;
if (mode == 3) add("X-CSRF-Token", "duplicate");
if (mode == 4) add("Origin", "https://evil.example");
if (mode == 5) add("Transfer-Encoding", "chunked");
if (mode == 6) add("Content-Type", "text/plain");
if (mode == 7) { begin("/api/settings/serial-operation", HTTP_POST, "{\"action\":\"save\"}"); same_origin(); }
if (mode == 8) add("Sec-Fetch-Site", "cross-site");
unsigned before = s_next_id;
(void)web_serial_settings_handler(&req);
assert(strncmp(response_status, "4", 1) == 0 && s_next_id == before && !api_calls);
}
puts("PASS Serial mutation security: cookie/admin/current policy, duplicate headers, CSRF/Origin, body/query/method/framing bounds before admission");
const char *invalid[] = {"{}", "[]", "{\"action\":\"serial save\"}", "{\"action\":\"save\",\"action\":\"stop\"}",
"{\"action\":\"apply\"}", "{\"action\":\"save\",\"baud\":110}", "{\"action\":\"save\",}",
"{\"action\":\"save\"}x", "{\"action\":true}", "{\"action\":\"sa\\u0076e\"}", "{\"command\":\"save\"}",
"{\"baud\":1e3,\"action\":\"apply\"}", "{\"baud\":-1,\"action\":\"apply\"}", "{\"baud\":0110,\"action\":\"apply\"}",
"{\"baud\":42949672960,\"action\":\"apply\"}", "{\"baud\":1.1,\"action\":\"apply\"}"};
for (unsigned i = 0; i < sizeof(invalid) / sizeof(*invalid); ++i) {
operation_begin(&admin, invalid[i]); operation_expect("400 Bad Request");
}
serial_operation_t parsed;
assert(parse(apply_body, strlen(apply_body), &parsed));
char bad[256];
const char *fields[] = {"230400", "\"7\"", "\"even\"", "\"2\"", "\"rts-cts\"", "\"on-connect\"", "96"};
const char *replacements[] = {"109", "\"9\"", "\"mark\"", "\"1.5\"", "\"xon-xoff\"", "\"bad\"", "128"};
for (unsigned i = 0; i < 7; ++i) {
const char *at = strstr(apply_body, fields[i]); assert(at);
snprintf(bad, sizeof(bad), "%.*s%s%s", (int)(at - apply_body), apply_body, replacements[i], at + strlen(fields[i]));
operation_begin(&admin, bad); operation_expect("400 Bad Request");
}
for (size_t size = 0; size < strlen(apply_body); ++size) assert(!parse(apply_body, size, &parsed));
for (unsigned bits = 7; bits <= 8; ++bits)
for (unsigned parity = 0; parity < 3; ++parity)
for (unsigned stops = 1; stops <= 2; ++stops)
for (unsigned flow = 0; flow < 2; ++flow)
for (unsigned dtr = 0; dtr < 3; ++dtr)
for (unsigned baud = 0; baud < 2; ++baud)
for (unsigned threshold = 0; threshold < 2; ++threshold) {
const char *parities[] = {"none", "even", "odd"};
const char *flows[] = {"none", "rts-cts"};
const char *dtrs[] = {"inactive", "active", "on-connect"};
int size = snprintf(bad, sizeof(bad),
"{\"rts_threshold\":%u,\"dtr\":\"%s\",\"flow\":\"%s\",\"stop_bits\":\"%u\","
"\"parity\":\"%s\",\"data_bits\":\"%u\",\"baud\":%u,\"action\":\"apply\"}",
threshold ? 127U : 1U, dtrs[dtr], flows[flow], stops,
parities[parity], bits, baud ? 1000000U : 110U);
assert(size > 0 && (size_t)size < sizeof(bad) && parse(bad, (size_t)size, &parsed));
assert(parsed.config.baud_rate == (baud ? 1000000U : 110U));
assert(parsed.config.rts_threshold == (threshold ? 127U : 1U));
}
memcpy(bad, apply_body, sizeof(apply_body));
assert(!parse(bad, sizeof(apply_body), &parsed)); /* NUL is not JSON whitespace. */
char *control = strstr(bad, "on-connect"); assert(control); *control = '\1';
assert(!parse(bad, strlen(apply_body), &parsed));
receive_fragment = 1; operation_begin(&admin, apply_body); operation_expect("400 Bad Request"); assert(body_offset == 4);
receive_fragment = 64; recv_fail = true; operation_begin(&admin, apply_body); operation_expect("400 Bad Request"); recv_fail = false;
assert(!api_calls);
puts("PASS Serial JSON: exact seven-field Apply/action-only schema, enum/range rejection, truncation, unknown/duplicate/escaped/oversized numbers, four-read bound");
char full_body[257];
memset(full_body, ' ', sizeof(full_body) - 1);
memcpy(full_body, apply_body, strlen(apply_body)); full_body[256] = 0;
queue_fail = true; operation_begin(&admin, full_body); operation_expect("503 Service Unavailable"); queue_fail = false;
assert(body_offset == 256);
puts("PASS Serial boundaries: 288 valid framing/range combinations, action-last order, binary/control rejection, exact 256-byte/four-read body, rejected unread bodies close");
assert(s_operation.state == IDLE);
submit(&admin, apply_body); uint32_t first = queued_id;
operation_begin(&other, "{\"action\":\"stop\"}"); operation_expect("503 Service Unavailable"); assert(queued_id == first);
operation_begin(&other, NULL); operation_expect("200 OK"); assert(strstr(output, "\"state\":\"idle\""));
operation_begin(&admin, NULL); operation_expect("200 OK"); assert(strstr(output, "pending"));
execute(); assert(s_operation.state == OK && working.baud_rate == 230400 && persisted.baud_rate == 115200);
zero(&s_operation.principal, sizeof(s_operation.principal)); zero(&s_operation.config, sizeof(s_operation.config));
unsigned before = api_calls; execute(); assert(api_calls == before);
action(&admin, "save"); assert(persisted.baud_rate == 230400 && s_operation.state == OK);
action(&admin, "defaults"); assert(working.baud_rate == 115200 && persisted.baud_rate == 230400);
action(&admin, "load"); assert(working.baud_rate == 230400);
have_stored = false; action(&admin, "load"); assert(s_operation.state == LOADED_DEFAULTS && working.baud_rate == 115200);
action(&admin, "start"); action(&admin, "stop"); assert(start_calls == 1 && stop_calls == 1);
action(&admin, "reset"); assert(s_operation.state == OK && persisted.baud_rate == 115200);
puts("PASS Serial ownership: no serial/NVS on HTTPD, single pending slot, isolated results, replay fence, explicit apply/save/load/default/reset/start/stop semantics");
working.baud_rate = 460800; persisted.baud_rate = 230400; save_error = ESP_FAIL;
action(&admin, "reset"); assert(s_operation.state == FAILED && working.baud_rate == 460800 && persisted.baud_rate == 230400);
apply_fail_at = apply_calls + 2; action(&admin, "reset"); assert(s_operation.state == ROLLBACK_FAILED);
save_error = ESP_OK; apply_fail_at = apply_calls + 1; before = save_calls;
action(&admin, "reset"); assert(s_operation.state == FAILED && save_calls == before);
apply_fail_at = 0; get_error = ESP_FAIL; action(&admin, "save"); assert(s_operation.state == FAILED && save_calls == before); get_error = ESP_OK;
load_error = ESP_FAIL; before = apply_calls; action(&admin, "load"); assert(s_operation.state == FAILED && apply_calls == before); load_error = ESP_OK;
start_error = stop_error = ESP_FAIL; action(&admin, "start"); assert(s_operation.state == FAILED); action(&admin, "stop"); assert(s_operation.state == FAILED);
start_error = stop_error = ESP_OK;
puts("PASS Serial failure ordering: apply/get/load/save/lifecycle failures, reset commit failure rollback and explicit failed rollback");
for (unsigned i = 0; i < ACTION_COUNT; ++i) {
working.baud_rate = 460800; persisted.baud_rate = 230400; have_stored = true;
if (i == APPLY) { submit(&admin, apply_body); execute(); }
else action(&admin, s_actions[i]);
serial_config_t typed_working = working, typed_persisted = persisted;
working.baud_rate = 460800; persisted.baud_rate = 230400;
on_dispatcher = true;
if (i == APPLY) {
const char *names[] = {"baud", "data-bits", "parity", "stop-bits", "flow", "dtr", "rts-threshold"};
const char *values[] = {"230400", "7", "even", "2", "rts-cts", "on-connect", "96"};
for (unsigned j = 0; j < 7; ++j) assert(set_parameter(names[j], values[j]) == 0);
} else {
char *args[] = {"serial", (char *)s_actions[i]}; assert(command_serial(2, args) == 0);
}
on_dispatcher = false;
assert(!memcmp(&working, &typed_working, sizeof(working)) && !memcmp(&persisted, &typed_persisted, sizeof(persisted)));
}
puts("PASS Serial CLI comparison: exact canonical command/set handlers and typed actions produce matching working/persisted config (UART/NVS doubled)");
submit(&admin, apply_body);
serial_operation_t pending = s_operation;
before = api_calls;
on_dispatcher = true;
web_serial_settings_execute(0); web_serial_settings_execute(first);
on_dispatcher = false;
assert(api_calls == before && !memcmp(&pending, &s_operation, sizeof(pending)));
executing_identity = admin; apply_hook = request_during_apply; execute();
assert(s_operation.state == OK && api_calls == before + 1);
operation_begin(&other, NULL); operation_expect("200 OK"); assert(strstr(output, "\"state\":\"idle\""));
operation_begin(&admin, NULL); operation_expect("200 OK"); assert(strstr(output, "\"state\":\"ok\""));
puts("PASS Serial interleaving: obsolete/zero IDs cannot execute replacement, executing slot rejects reset and remains pollable, completed result isolated by session");
submit(&admin, apply_body); before = api_calls; now += 30000000LL; execute(); assert(s_operation.state == CANCELLED && api_calls == before);
submit(&admin, apply_body); web_session_store_invalidate(admin.view.id); execute(); assert(s_operation.state == CANCELLED && api_calls == before);
admin = mint(&alice); submit(&admin, apply_body); db_fail = true; execute(); db_fail = false; assert(s_operation.state == CANCELLED && api_calls == before);
submit(&other, apply_body); apply_hook = revoke_during_apply; execute(); assert(s_operation.state == OK); /* admitted work can finish */
operation_begin(&other, NULL); operation_expect("401 Unauthorized");
web_cookie_auth_stop(); assert(web_cookie_auth_start() == ESP_OK); admin = mint(&alice);
operation_begin(&admin, NULL); operation_expect("200 OK"); assert(strstr(output, "idle"));
submit(&admin, apply_body); web_cookie_auth_stop(); assert(web_cookie_auth_start() == ESP_OK); before = api_calls; execute(); assert(s_operation.state == CANCELLED && api_calls == before);
puts("PASS Serial currentness: queue deadline, session revocation, database failure, stop/restart IDs and admitted-work completion after revocation");
admin = mint(&alice); submit(&admin, apply_body); before = api_calls;
stale_user = alice.user_id; execute(); stale_user = 0;
assert(s_operation.state == CANCELLED && api_calls == before);
zero(&s_operation.principal, sizeof(s_operation.principal)); zero(&s_operation.config, sizeof(s_operation.config));
admin = mint(&alice); now = admin.view.expires_at_us - 1;
submit(&admin, apply_body);
now = admin.view.expires_at_us;
assert(now < s_operation.deadline); execute();
assert(s_operation.state == CANCELLED && api_calls == before);
puts("PASS Serial queued authorization: missed account revocation and absolute cookie expiry cancel without serial/NVS calls and wipe retained request data");
admin = mint(&alice); send_fail = true; submit(&admin, apply_body); send_fail = false; execute(); assert(s_operation.state == OK);
operation_begin(&admin, NULL); operation_expect("200 OK"); assert(strstr(output, "\"state\":\"ok\""));
for (unsigned failure = 1; failure <= 3; ++failure) {
operation_begin(&admin, NULL); server.config.max_resp_headers = failure - 1;
before = sends; assert(web_serial_settings_handler(&req) != ESP_OK && sends == before);
}
server.config.max_resp_headers = 8;
s_next_id = UINT32_MAX; operation_begin(&admin, apply_body); operation_expect("503 Service Unavailable");
puts("PASS Serial result transport: lost acknowledgement does not cancel/replay, bounded headers/response, no ID wrap");
}
+51 -1
View File
@@ -12,6 +12,7 @@ static atomic_bool s_running;
static serial_config_t s_config;
static int s_state_mutex;
static unsigned serial_takes;
static bool change_after_snapshot;
static web_server_counters_t s_counters;
static unsigned allocations, allocation_fail_at, frees;
static httpd_uri_t *routes[2];
@@ -28,7 +29,13 @@ static int xSemaphoreTake(int mutex, unsigned wait) {
if (serial_busy) return 0;
serial_locked = true; return pdTRUE;
}
static void xSemaphoreGive(int mutex) { (void)mutex; assert(serial_locked); serial_locked = false; }
static void xSemaphoreGive(int mutex) {
(void)mutex; assert(serial_locked); serial_locked = false;
if (change_after_snapshot) {
s_running = !s_running;
s_config.baud_rate = 110;
}
}
static void increment_counter(uint64_t *counter) { ++*counter; }
esp_err_t httpd_resp_send(httpd_req_t *r, const char *body, int length) {
assert(!serial_locked && length >= 0 && length < 256 && (size_t)length == strlen(body));
@@ -54,6 +61,38 @@ static void settings_expect(const char *status) {
assert(strlen(output) < 128 && !body_offset);
zero(scratch, sizeof(scratch));
}
static void status_snapshot_tests(void) {
char response[256];
serial_config_t saved_config = s_config;
bool saved_running = s_running;
for (unsigned running = 0; running < 2; ++running) {
s_config = saved_config; s_running = running;
unsigned before = serial_takes;
change_after_snapshot = true;
int length = status_serial_projection(response, sizeof(response));
change_after_snapshot = false;
assert(length > 0 && (size_t)length < sizeof(response));
assert(serial_takes == before + 1 && !serial_locked);
assert(s_running == !running && s_config.baud_rate == 110);
assert(strstr(response, running
? "\"running\":true,\"config_available\":true,\"baud\":230400"
: "\"running\":false,\"config_available\":true,\"baud\":230400"));
}
for (unsigned unavailable = 0; unavailable < 2; ++unavailable)
for (unsigned running = 0; running < 2; ++running) {
s_running = running; s_config = saved_config;
serial_busy = unavailable == 0; s_initialized = unavailable == 0;
unsigned before = serial_takes;
int length = status_serial_projection(response, sizeof(response));
assert(length > 0 && (size_t)length < sizeof(response));
assert(serial_takes == before + (unavailable == 0) && !serial_locked);
assert(strstr(response, "\"running\":null,\"config_available\":false,\"baud\":0"));
assert(strstr(response, "\"data_bits\":\"unknown\""));
}
s_initialized = true; serial_busy = false;
s_config = saved_config; s_running = saved_running;
puts("PASS Status serial projection: same-snapshot running/config despite post-unlock changes, busy/uninitialized => null for either live state, zero-wait acquisition and no blocking getter");
}
static void settings_tests(void) {
httpd_uri_t existing = {.uri = "/", .method = HTTP_GET, .handler = serial_settings_handler};
httpd_uri_t route = {.uri = "/api/settings/serial", .method = HTTP_GET, .handler = serial_settings_handler};
@@ -84,6 +123,16 @@ static void settings_tests(void) {
}
assert(web_httpd_register_optional_get(NULL, &route) == ESP_ERR_INVALID_ARG);
assert(web_httpd_register_optional_get(&server, NULL) == ESP_ERR_INVALID_ARG);
other = route; other.method = HTTP_POST;
for (unsigned failure = 1; failure <= 2; ++failure) {
allocations = frees = 0; allocation_fail_at = failure;
assert(web_httpd_register_optional(&server, &other) == ESP_ERR_NO_MEM && !routes[1]);
assert(allocations == failure && frees == failure - 1);
}
allocation_fail_at = 0;
assert(web_httpd_register_optional(&server, &other) == ESP_OK);
assert(web_httpd_register_optional(&server, &other) == ESP_ERR_INVALID_STATE);
assert(httpd_unregister_uri_handler(&server, other.uri, HTTP_POST) == ESP_OK && !routes[1]);
puts("PASS Settings registration: both allocation failures leave table intact, duplicate/full/shape bounds, installed IDF unregister frees successful ownership");
auth_reset(); issued_t admin = mint(&alice), user = mint(&bob);
s_initialized = true; s_running = true;
@@ -101,6 +150,7 @@ static void settings_tests(void) {
assert(serial_service_get_snapshot(&value) == ESP_OK && value.running);
assert(!memcmp(&value.config, &s_config, sizeof(s_config)) && !serial_locked);
puts("PASS Settings snapshot: exact nonblocking production body, failure clearing and lock-consistent copy");
status_snapshot_tests();
settings_begin(NULL); settings_expect("401 Unauthorized");
settings_begin(&user); settings_expect("403 Forbidden");
+8 -1
View File
@@ -18,6 +18,7 @@ static size_t body_offset;
static unsigned password_calls, cookie_count, sends, upgrades;
static unsigned fail_header, setter_calls;
static bool send_fail, recv_fail;
static size_t receive_fragment = 7;
static void (*password_hook)(void);
static char response_status[48];
static struct httpd_req_aux aux;
@@ -46,7 +47,7 @@ esp_err_t httpd_resp_sendstr(httpd_req_t *r, const char *body) {
}
int httpd_req_recv(httpd_req_t *r, char *out, size_t size) {
(void)r; if (recv_fail) return -1;
if (size > 7) size = 7; /* Fragment every login body. */
if (size > receive_fragment) size = receive_fragment;
memcpy(out, request_body + body_offset, size); body_offset += size;
aux.remaining_len -= size; return (int)size;
}
@@ -122,6 +123,9 @@ static void auth_reset(void) {
#ifdef HOST_SETTINGS
#include "settings_test.c"
#endif
#ifdef HOST_SERIAL_SETTINGS
#include "serial_settings_test.c"
#endif
int main(void) {
assert(store_tests() == 0); auth_reset();
@@ -272,6 +276,9 @@ int main(void) {
#endif
#ifdef HOST_SETTINGS
settings_tests();
#endif
#ifdef HOST_SERIAL_SETTINGS
serial_settings_tests();
#endif
return 0;
}
+51 -2
View File
@@ -48,11 +48,60 @@ Coverage:
- Read-only admin Settings/Serial: 10 selection cycles preserve both sockets/IDs,
drain hidden output and block terminal input; exact eight-field/256-byte schema,
explicit refresh, error/timeout containment, late cancellation/restore/identity,
and concurrent serial reconnect without superseding admission. **21 Node groups total.**
and concurrent serial reconnect without superseding admission.
- 8D.9 typed Serial actions: draft validation, Reset-only confirmation, bounded
JSON/CSRF, explicit working/persisted effects, automatic bounded result checks,
failure/timeout containment, session fencing and preserved sockets/writer identity.
- Lost-acknowledgement and replaced-result uncertainty survives repeated result
checks, failed reads, refresh and navigation; a newly acknowledged explicit
submission starts a new result context.
- Immediate completion on the first GET, pending then completion, 10-attempt
exhaustion and manual recovery, 15-second overall abort during fetch/body reads,
delayed timers/replies, automatic read errors, visible stale snapshots during
refresh and after refresh failure for every terminal outcome, late refresh
cancellation, no routine confirmations and Reset cancellation. Navigation,
pagehide/restore, logout, expiry and changed identity cancel checks without
automatic resumption.
- Repeated current Settings selection is a no-op during submission, between and
during result checks, and during completion refresh: requests, timers, visible
values/control state, final outcome and socket/writer identity remain intact.
**35 Node groups total.**
## Automatic result-check budget
After a valid POST acknowledgement, the UI waits **1,000 ms** before the first
result GET and between completed pending-result checks. It makes **at most 10
GET attempts** and uses an independent **15,000 ms overall deadline**, measured
with the monotonic browser clock from acknowledgement. Each attempt first
revalidates the session; that time is included in the deadline. There is only
one automatic check in flight. Delayed timer callbacks and replies also check
this deadline. Expiry actively aborts the in-flight request and releases the UI
for manual recovery; late completions cannot update the view.
The first limit reached stops automatic checking. A read error also stops it.
The budget does not cancel backend work and is not a server execution deadline.
**POST is never automatically retried.** Lost acknowledgement requires explicit
Check Result recovery; manual checks do not restart automatic polling. Exhausted
or cancelled polling never resumes on navigation or bfcache restoration.
Every known terminal result, including failure/cancellation, triggers one working
snapshot refresh while retaining the operation outcome and any uncertainty
warning. Snapshot refresh is outside the auto-check budget and retains the
existing 15-second per-request bound (session validation and snapshot GET are
separate requests). Settings remain visible but conflicting controls are disabled
during work; old snapshots are explicitly stale during pending/uncertain work or
a failed refresh. A successful refresh replaces the browser draft. Only Reset
asks for confirmation, specifically because it overwrites saved configuration.
Tests use a deterministic clock and individually fired timer callbacks, including
callbacks invoked after cancellation and fetch/body doubles that ignore abort.
These deliberately exercise fences beyond normal browser cancellation behavior.
## Integration and known gaps
This covers 8D.3 session behavior, the 8D.6 selector and 8D.8 Settings. The renderer
This covers 8D.3 session behavior, the 8D.6 selector, 8D.8 Settings and the 8D.9
Serial UI. Operation responses are fetch doubles, not end-to-end execution of
`web_serial_settings.c`, dispatcher work, serial reconfiguration or NVS persistence. The renderer
still relies on its caller to authenticate resources; protected asset failures
must be 401, never a redirect to HTML served as JavaScript. No Basic fallback is
implemented here. Existing 8D.5 server authorization/protocols are unchanged.
+343 -5
View File
@@ -13,9 +13,10 @@ const deferred = () => { let resolve; const promise = new Promise(r => { resolve
const tick = async () => { for (let i = 0; i < 6; ++i) await new Promise(r => setImmediate(r)); };
function browser({onlyLoader = false, withLoader = false, role = 'user'} = {}) {
const nodes = {}, events = {}, calls = [], redirects = [], timers = new Map(), sockets = [], terminals = [];
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': [], '/api/settings/serial': []};
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': [], '/api/settings/serial': [], '/api/settings/serial-operation': []};
const fits = [];
let serial = 0;
let serial = 0, now = Date.now();
class Clock extends Date { static now() { return now; } }
const on = (key, fn) => { if (!(events[key] ||= []).includes(fn)) events[key].push(fn); };
const emit = (key, event = {}) => { for (const fn of events[key] || []) fn(event); };
const timeout = (fn, ms, interval = false) => { timers.set(++serial, {fn, ms, interval}); return serial; };
@@ -32,7 +33,7 @@ function browser({onlyLoader = false, withLoader = false, role = 'user'} = {}) {
loadAddon() {} open() {} focus() {} resize(cols, rows) { this.cols = cols; this.rows = rows; } onData(fn) { this.input = fn; }
write(bytes, callback) { this.writes.push([...bytes]); if (this.holdWrites) (this.pending ||= []).push(callback); else callback?.(); }
}
const window = {addEventListener: on, removeEventListener(k, fn) { events[k] = (events[k] || []).filter(f => f !== fn); },
const window = {confirm: () => true, addEventListener: on, removeEventListener(k, fn) { events[k] = (events[k] || []).filter(f => f !== fn); },
setTimeout: timeout, clearTimeout: id => timers.delete(id),
setInterval: (fn, ms) => timeout(fn, ms, true), clearInterval: id => timers.delete(id),
requestAnimationFrame: fn => timeout(fn, -1), cancelAnimationFrame: id => timers.delete(id),
@@ -46,7 +47,7 @@ function browser({onlyLoader = false, withLoader = false, role = 'user'} = {}) {
constructor() { this.measurements = []; this.calls = 0; fits.push(this); }
proposeDimensions() { ++this.calls; return this.measurements.length ? this.measurements.shift() : {cols: 80, rows: 24}; }
}},
TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, AbortController, URL, Date, WebSocket: Socket,
TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, AbortController, URL, Date: Clock, performance: {now: () => now}, WebSocket: Socket,
fetch: async (url, options) => {
// Apply the Origin regression guard to every mutation, including logout.
assert.ok(Object.hasOwn(queues, url));
@@ -69,7 +70,7 @@ function browser({onlyLoader = false, withLoader = false, role = 'user'} = {}) {
const [id, t] = match; if (!t.interval) timers.delete(id); t.fn();
};
return {nodes, calls, redirects, timers, sockets, terminals, queues, fits, events, emit, start, fire,
click: id => nodes[id].click(), window};
click: id => nodes[id].click(), elapse: ms => { now += ms; }, window};
}
async function connected() { const b = browser(); b.start(); await tick(); assert.equal(b.sockets.length, 1); return b; }
let passed = 0;
@@ -458,5 +459,342 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
assert.match(c.nodes['settings-detail'].textContent, /Refresh to retry/);
c.click('refresh-settings'); await tick(); assert.equal(c.nodes['settings-values'].hidden, false);
});
await test('Serial typed actions: draft, confirmation, CSRF, explicit results and working/persisted semantics', async () => {
const b = browser({role: 'admin'}); b.start(); await tick(); b.sockets[0].emit('open');
b.click('select-settings'); await tick();
const path = '/api/settings/serial-operation';
assert.equal(b.nodes['edit-baud'].value, '230400');
const baseline = b.calls.length;
b.nodes['edit-baud'].value = '460800'; assert.equal(b.calls.length, baseline);
b.window.confirm = () => false; b.click('serial-reset'); await tick(); assert.equal(b.calls.length, baseline);
b.window.confirm = () => true;
for (const [i, action] of ['apply', 'save', 'load', 'defaults', 'reset', 'start', 'stop'].entries()) {
if (i) { b.click('refresh-settings'); await tick(); }
b.queues[path].push(json({id: i + 1, action, state: 'pending'}));
b.click('serial-' + action); await tick();
const post = b.calls.filter(c => c.url === path && c.method === 'POST').at(-1);
assert.equal(post.headers['X-CSRF-Token'], token); assert.equal(post.headers['Content-Type'], 'application/json');
assert.ok(Buffer.byteLength(post.body) <= 256);
const payload = JSON.parse(post.body); assert.equal(payload.action, action);
if (action === 'apply') {
assert.equal(payload.baud, 460800); assert.equal(payload.rts_threshold, 96); assert.equal(Object.keys(payload).length, 8);
} else assert.deepEqual(payload, {action});
assert.ok(b.nodes['serial-apply'].disabled && b.nodes['serial-result'].disabled);
assert.ok(!b.nodes['serial-edit'].hidden && !b.nodes['settings-values'].hidden);
assert.match(b.nodes['settings-detail'].textContent, /stale/);
const count = b.calls.length; b.click('serial-save'); await tick(); assert.equal(b.calls.length, count);
b.queues[path].push(json({id: i + 1, action, state: 'ok'})); b.fire(1000); await tick();
assert.ok(!b.nodes['serial-apply'].disabled && !b.nodes['serial-edit'].hidden);
assert.match(b.nodes['serial-operation-detail'].textContent, /completed.*RAM.*NVS/);
}
assert.equal(b.sockets.length, 1); assert.ok(!b.sockets[0].closed && !b.sockets[0].sent.length);
});
await test('Serial validation and capacity failures never auto-retry or transmit command strings', async () => {
const b = browser({role: 'admin'}); b.start(); await tick(); b.click('select-settings'); await tick();
const path = '/api/settings/serial-operation';
for (const [key, value] of [['baud', '0'], ['baud', '1000001'], ['baud', '1e3'], ['baud', '-1'], ['parity', 'mark'], ['rts_threshold', '128']]) {
b.click('refresh-settings'); await tick(); b.nodes['edit-' + key].value = value;
const count = b.calls.length; b.click('serial-apply'); await tick(); assert.equal(b.calls.length, count);
}
b.click('refresh-settings'); await tick();
for (const status of [400, 403, 429, 503]) {
b.queues[path].push(failure(status)); b.click('serial-save'); await tick();
assert.match(b.nodes['serial-operation-detail'].textContent, /Check Result/);
const count = b.calls.length; await tick(); b.click('serial-save'); await tick(); assert.equal(b.calls.length, count);
assert.ok(!b.nodes['serial-operation-detail'].textContent.includes('SECRET'));
b.queues[path].push(json({id: 0, action: 'none', state: 'idle'})); b.click('serial-result'); await tick();
b.click('refresh-settings'); await tick();
}
assert.equal(b.calls.filter(c => c.url === path && c.method === 'POST').length, 4);
b.queues[path].push(json({id: 1, action: 'save', state: 'pending'})); b.click('serial-save'); await tick();
b.queues[path].push(json({id: 1, action: 'save', state: 'ok'})); b.fire(1000); await tick();
b.click('refresh-settings'); await tick();
b.queues[path].push(() => { throw new Error('lost'); }); b.click('serial-reset'); await tick();
b.queues[path].push(json({id: 1, action: 'save', state: 'ok'})); b.click('serial-result'); await tick();
assert.match(b.nodes['serial-operation-detail'].textContent, /acknowledgement was lost.*earlier operation/);
assert.ok(b.sockets.every(s => !s.closed));
});
await test('Serial navigation fences delayed acknowledgement without losing sockets, lease or uncertain-work gate', async () => {
const b = browser({role: 'admin'}); b.start(); await tick(); const serial = b.sockets[0]; serial.emit('open');
serial.emit('message', {data: JSON.stringify({type: 'hello', clientId: 8, writerId: 8, role: 'writer'})});
b.click('select-admin'); b.click('admin-toggle'); await tick(); b.sockets[1].emit('open');
b.click('select-settings'); await tick(); const d = deferred(), path = '/api/settings/serial-operation';
b.queues[path].push(d.promise); b.click('serial-reset'); await tick();
const post = b.calls.find(c => c.url === path); b.click('select-serial'); assert.ok(post.signal.aborted);
d.resolve(json({id: 44, action: 'reset', state: 'pending'})); await tick();
b.click('select-settings'); await tick(); assert.ok(b.nodes['serial-reset'].disabled);
b.queues[path].push(json({id: 44, action: 'reset', state: 'ok'})); b.click('serial-result'); await tick();
assert.match(b.nodes['serial-operation-detail'].textContent, /completed/);
assert.equal(b.sockets.length, 2); assert.ok(b.sockets.every(s => !s.closed));
assert.equal(b.nodes['client-id'].textContent, '8'); assert.equal(b.nodes['writer-id'].textContent, '8');
assert.deepEqual(serial.sent, []);
});
await test('Serial result bounds, failure explanations, timeout and no result replay', async () => {
const b = browser({role: 'admin'}); b.start(); await tick(); b.click('select-settings'); await tick();
const path = '/api/settings/serial-operation';
for (const state of ['loaded_defaults', 'failed', 'rollback_failed', 'cancelled', 'pending']) {
b.queues[path].push(json({id: 42, action: 'reset', state})); b.click('serial-result'); await tick();
assert.ok(!b.nodes['serial-operation-detail'].textContent.includes('unknown. Check'));
}
for (const response of [new Response(' '.repeat(97)), json({id: 0, action: 'save', state: 'ok'}),
json({id: 42, action: 'save', state: 'ok', extra: 1}), json({id: 42, action: '<img>', state: 'ok'}),
json({id: -1, action: 'none', state: 'idle'}), new Response(Uint8Array.of(255))]) {
b.queues[path].push(response); b.click('serial-result'); await tick();
assert.match(b.nodes['serial-operation-detail'].textContent, /outcome unknown/);
}
b.queues[path].push(o => new Promise((_, reject) => o.signal.addEventListener('abort', () => reject(new Error('timeout')))));
b.click('serial-result'); await tick(); b.fire(15000); await tick();
assert.match(b.nodes['serial-operation-detail'].textContent, /No automatic retry/);
assert.equal(b.calls.filter(c => c.url === path && c.method === 'POST').length, 0);
});
await test('Serial mutation security: current-session identity, 401 and pagehide cancel work safely', async () => {
for (const mode of ['identity', '401', 'pagehide']) {
const b = browser({role: 'admin'}); b.start(); await tick(); b.click('select-settings'); await tick();
const path = '/api/settings/serial-operation', d = deferred();
if (mode === 'identity') b.queues['/api/session'].push(session({role: 'admin', username: 'replacement'}));
else b.queues[path].push(mode === '401' ? failure(401) : d.promise);
b.click('serial-save'); await tick();
if (mode === 'identity') { assert.deepEqual(b.redirects, ['/']); assert.equal(b.calls.filter(c => c.url === path).length, 0); }
if (mode === '401') assert.deepEqual(b.redirects, ['/login']);
if (mode === 'pagehide') {
b.emit('pagehide'); const text = b.nodes['serial-operation-detail'].textContent;
d.resolve(json({id: 42, action: 'save', state: 'pending'})); await tick();
assert.equal(b.nodes['serial-operation-detail'].textContent, text);
}
assert.ok(b.sockets.every(s => s.closed) && b.nodes['serial-settings'].hidden);
}
});
await test('Serial uncertain outcomes survive repeated result reads, refresh and navigation', async () => {
for (const lostAck of [true, false]) {
const b = await adminBrowser(), path = '/api/settings/serial-operation';
b.click('select-settings'); await tick();
b.queues[path].push(lostAck ? () => { throw new Error('lost'); } : json({id: 41, action: 'save', state: 'pending'}));
b.click('serial-save'); await tick();
const warning = lostAck ? /acknowledgement was lost/ : /Previous result was replaced.*unknown/;
for (let i = 0; i < 2; ++i) {
b.queues[path].push(json({id: 42, action: 'reset', state: 'ok'}));
if (!lostAck && i === 0) b.fire(1000); else b.click('serial-result');
await tick();
assert.match(b.nodes['serial-operation-detail'].textContent, warning);
}
b.queues[path].push(failure(503)); b.click('serial-result'); await tick();
assert.match(b.nodes['serial-operation-detail'].textContent, warning);
assert.match(b.nodes['serial-operation-detail'].textContent, /No automatic retry/);
b.click('refresh-settings'); await tick();
assert.match(b.nodes['serial-operation-detail'].textContent, warning);
b.click('select-serial'); b.click('select-settings'); await tick();
assert.match(b.nodes['serial-operation-detail'].textContent, warning);
b.queues[path].push(json({id: 42, action: 'reset', state: 'ok'}));
b.click('serial-result'); await tick();
assert.match(b.nodes['serial-operation-detail'].textContent, warning);
b.click('refresh-settings'); await tick();
b.queues[path].push(json({id: 43, action: 'save', state: 'pending'}));
b.click('serial-save'); await tick();
assert.doesNotMatch(b.nodes['serial-operation-detail'].textContent, warning);
assert.equal(b.calls.filter(c => c.url === path && c.method === 'POST').length, 2);
assert.equal(b.sockets.length, 2); assert.ok(b.sockets.every(s => !s.closed));
assert.ok(b.sockets.every(s => !s.sent.length));
}
});
await test('Automatic checks: pending then completion refreshes working config, retains outcome and isolates sockets', async () => {
const b = await adminBrowser(), path = '/api/settings/serial-operation';
b.click('select-settings'); await tick();
const before = b.calls.filter(c => c.url === '/api/settings/serial').length;
b.queues[path].push(json({id: 50, action: 'apply', state: 'pending'}));
b.nodes['edit-baud'].value = '460800'; b.click('serial-apply');
assert.match(b.nodes['serial-operation-detail'].textContent, /Applying/); await tick();
for (const state of ['pending', 'pending', 'ok']) {
assert.ok(b.nodes['edit-baud'].disabled && b.nodes['serial-stop'].disabled);
assert.ok(!b.nodes['settings-values'].hidden && !b.nodes['serial-edit'].hidden);
b.queues[path].push(json({id: 50, action: 'apply', state}));
if (state === 'ok') b.queues['/api/settings/serial'].push(json({...serialSettings(), baud: 460800}));
b.elapse(1000); b.fire(1000); await tick();
}
assert.equal(b.calls.filter(c => c.url === path && c.method === 'GET').length, 3);
assert.equal(b.calls.filter(c => c.url === path && c.method === 'POST').length, 1);
assert.equal(b.calls.filter(c => c.url === '/api/settings/serial').length, before + 1);
assert.equal(b.nodes['setting-baud'].textContent, '460800');
assert.match(b.nodes['serial-operation-detail'].textContent, /completed/);
assert.doesNotMatch(b.nodes['settings-detail'].textContent, /stale/);
assert.ok(!b.nodes['edit-baud'].disabled && !b.nodes['serial-apply'].disabled);
assert.ok(![...b.timers.values()].some(t => t.ms === 1000 || t.ms === 15000));
assert.equal(b.sockets.length, 2); assert.ok(b.sockets.every(s => !s.closed && !s.sent.length));
});
await test('Repeated current Settings selection preserves submission, polling and completion refresh', async () => {
const b = await adminBrowser(), path = '/api/settings/serial-operation';
b.click('select-settings'); await tick(); b.nodes['edit-baud'].value = '460800';
const repeatedSelection = async request => {
const count = b.calls.length, timers = [...b.timers];
const view = () => Object.fromEntries(Object.entries(b.nodes).map(([id, node]) =>
[id, [node.hidden, node.disabled, node.value, node.textContent, node['aria-pressed']]]));
const before = view();
for (let i = 0; i < 3; ++i) { b.click('select-settings'); await tick(); }
assert.equal(b.calls.length, count);
assert.deepEqual([...b.timers], timers);
assert.deepEqual(view(), before);
if (request) assert.ok(!request.signal.aborted);
assert.ok(!b.nodes['serial-settings'].hidden && !b.nodes['settings-values'].hidden && !b.nodes['serial-edit'].hidden);
};
const post = deferred(); b.queues[path].push(post.promise);
b.click('serial-apply'); await tick();
assert.match(b.nodes['serial-operation-detail'].textContent, /Applying/);
await repeatedSelection(b.calls.filter(c => c.url === path).at(-1));
post.resolve(json({id: 57, action: 'apply', state: 'pending'})); await tick();
await repeatedSelection();
const pending = deferred(); b.queues[path].push(pending.promise);
b.fire(1000); await tick();
await repeatedSelection(b.calls.filter(c => c.url === path).at(-1));
pending.resolve(json({id: 57, action: 'apply', state: 'pending'})); await tick();
await repeatedSelection();
const refresh = deferred(); b.queues['/api/settings/serial'].push(refresh.promise);
b.queues[path].push(json({id: 57, action: 'apply', state: 'ok'}));
b.fire(1000); await tick();
assert.match(b.nodes['serial-operation-detail'].textContent, /completed/);
assert.match(b.nodes['settings-detail'].textContent, /Reading.*stale/);
await repeatedSelection(b.calls.filter(c => c.url === '/api/settings/serial').at(-1));
refresh.resolve(json({...serialSettings(), baud: 460800})); await tick();
assert.equal(b.nodes['setting-baud'].textContent, '460800');
assert.equal(b.nodes['edit-baud'].value, '460800');
assert.ok(!b.nodes['serial-apply'].disabled);
assert.match(b.nodes['serial-operation-detail'].textContent, /completed/);
assert.doesNotMatch(b.nodes['settings-detail'].textContent, /stale/);
await repeatedSelection();
assert.equal(b.calls.filter(c => c.url === path && c.method === 'POST').length, 1);
assert.equal(b.calls.filter(c => c.url === path && c.method === 'GET').length, 2);
assert.equal(b.calls.filter(c => c.url === '/api/settings/serial').length, 2);
assert.ok(![...b.timers.values()].some(t => t.ms === 1000 || t.ms === 15000));
assert.equal(b.sockets.length, 2); assert.ok(b.sockets.every(s => !s.closed && !s.sent.length));
assert.equal(b.nodes['client-id'].textContent, '8'); assert.equal(b.nodes['writer-id'].textContent, '8');
});
await test('Ten automatic GET attempts exhaust budget; manual recovery completes without POST retry', async () => {
const b = await adminBrowser(), path = '/api/settings/serial-operation';
b.click('select-settings'); await tick();
b.queues[path].push(json({id: 51, action: 'save', state: 'pending'})); b.click('serial-save'); await tick();
for (let i = 0; i < 10; ++i) {
b.queues[path].push(json({id: 51, action: 'save', state: 'pending'}));
b.elapse(1000); b.fire(1000); await tick();
}
assert.equal(b.calls.filter(c => c.url === path && c.method === 'GET').length, 10);
assert.ok(![...b.timers.values()].some(t => t.ms === 1000 || t.ms === 15000));
assert.match(b.nodes['serial-operation-detail'].textContent, /Automatic checking stopped.*uncertain.*Check Result/);
assert.ok(b.nodes['serial-save'].disabled && !b.nodes['serial-result'].disabled);
assert.match(b.nodes['settings-detail'].textContent, /stale/);
b.queues[path].push(json({id: 51, action: 'save', state: 'ok'})); b.click('serial-result'); await tick();
assert.match(b.nodes['serial-operation-detail'].textContent, /completed/);
assert.ok(!b.nodes['serial-save'].disabled);
assert.equal(b.calls.filter(c => c.url === path && c.method === 'POST').length, 1);
});
await test('15s overall deadline aborts slow checks and fences late bodies and delayed timers', async () => {
for (const mode of ['fetch', 'body', 'delayed-timer', 'late-response']) {
const b = await adminBrowser(), path = '/api/settings/serial-operation';
b.click('select-settings'); await tick();
b.queues[path].push(json({id: 52, action: 'stop', state: 'pending'})); b.click('serial-stop'); await tick();
const d = deferred(); let stream;
if (mode !== 'delayed-timer') {
b.queues[path].push(mode === 'body' ? new Response(new ReadableStream({start(c) { stream = c; }})) : d.promise);
b.elapse(1000); b.fire(1000); await tick(); b.elapse(14000);
if (mode === 'late-response') { d.resolve(json({id: 52, action: 'stop', state: 'ok'})); await tick(); }
else b.fire(15000);
assert.ok(b.calls.filter(c => c.url === path).at(-1).signal.aborted);
} else { b.elapse(15000); b.fire(1000); }
await tick();
const text = b.nodes['serial-operation-detail'].textContent;
assert.match(text, /Automatic checking stopped/);
assert.ok(!b.nodes['serial-result'].disabled);
b.queues[path].push(json({id: 52, action: 'stop', state: 'ok'})); b.click('serial-result'); await tick();
const recovered = b.nodes['serial-operation-detail'].textContent;
if (stream) { stream.enqueue(new TextEncoder().encode(JSON.stringify({id: 52, action: 'stop', state: 'failed'}))); stream.close(); }
else d.resolve(json({id: 52, action: 'stop', state: 'failed'}));
await tick(); assert.equal(b.nodes['serial-operation-detail'].textContent, recovered);
assert.match(recovered, /completed/);
assert.equal(b.calls.filter(c => c.url === path && c.method === 'POST').length, 1);
}
});
await test('Known terminal outcomes always refresh; failed refresh preserves visible stale snapshot and outcome', async () => {
for (const state of ['ok', 'failed', 'rollback_failed', 'cancelled', 'loaded_defaults']) {
const b = await adminBrowser(), path = '/api/settings/serial-operation';
b.click('select-settings'); await tick();
b.queues[path].push(json({id: 53, action: 'reset', state: 'pending'})); b.click('serial-reset'); await tick();
const d = deferred(); b.queues['/api/settings/serial'].push(d.promise);
b.queues[path].push(json({id: 53, action: 'reset', state})); b.fire(1000); await tick();
const outcome = b.nodes['serial-operation-detail'].textContent;
assert.ok(b.nodes['edit-baud'].disabled && !b.nodes['serial-edit'].hidden && !b.nodes['settings-values'].hidden);
assert.match(b.nodes['settings-detail'].textContent, /stale/);
d.resolve(failure(503)); await tick();
assert.equal(b.nodes['serial-operation-detail'].textContent, outcome);
assert.equal(b.nodes['setting-baud'].textContent, '230400');
assert.ok(!b.nodes['serial-edit'].hidden && !b.nodes['settings-values'].hidden && !b.nodes['refresh-settings'].disabled);
assert.match(b.nodes['settings-detail'].textContent, /stale.*Refresh/);
b.click('refresh-settings'); await tick();
assert.equal(b.nodes['serial-operation-detail'].textContent, outcome);
assert.doesNotMatch(b.nodes['settings-detail'].textContent, /stale/);
}
});
await test('Automatic timers and in-flight checks cancel on navigation, pagehide, logout and identity change', async () => {
for (const mode of ['navigation', 'pagehide', 'logout', 'identity', 'expiry']) for (const inFlight of [false, true]) {
const b = await adminBrowser(), path = '/api/settings/serial-operation';
b.click('select-settings'); await tick();
b.queues[path].push(json({id: 54, action: 'start', state: 'pending'})); b.click('serial-start'); await tick();
const callbacks = [...b.timers.values()].filter(t => t.ms === 1000 || t.ms === 15000).map(t => t.fn);
const d = deferred();
if (inFlight) { b.queues[path].push(d.promise); b.fire(1000); await tick(); }
if (mode === 'navigation') b.click('select-serial');
if (mode === 'pagehide') b.emit('pagehide');
if (mode === 'expiry') b.window.sakSessionExpired();
if (mode === 'logout') { b.queues['/api/logout'].push(new Response(null, {status: 204})); b.click('sign-out'); }
if (mode === 'identity') {
b.queues['/api/session'].push(session({role: 'admin', username: 'replacement'}));
b.click('connection-toggle'); b.click('connection-toggle');
}
await tick();
const text = b.nodes['serial-operation-detail'].textContent, count = b.calls.filter(c => c.url === path).length;
if (inFlight) assert.ok(b.calls.filter(c => c.url === path).at(-1).signal.aborted);
for (const callback of callbacks) callback();
d.resolve(json({id: 54, action: 'start', state: 'ok'})); await tick();
assert.equal(b.nodes['serial-operation-detail'].textContent, text);
assert.equal(b.calls.filter(c => c.url === path).length, count);
assert.ok(![...b.timers.values()].some(t => t.ms === 1000 || t.ms === 15000));
if (mode === 'navigation') { b.click('select-settings'); await tick(); assert.equal(b.calls.filter(c => c.url === path).length, count); }
if (mode === 'pagehide') { b.emit('pageshow', {persisted: true}); await tick(); assert.equal(b.calls.filter(c => c.url === path).length, count); }
}
});
await test('Automatic read errors stop checking; cancelled completion refresh cannot overwrite a newer view', async () => {
const path = '/api/settings/serial-operation';
for (const response of [failure(503), new Response(' '.repeat(97)), () => { throw new Error('network'); }]) {
const b = await adminBrowser(); b.click('select-settings'); await tick();
b.queues[path].push(json({id: 55, action: 'save', state: 'pending'}), response);
b.click('serial-save'); await tick(); b.fire(1000); await tick();
assert.match(b.nodes['serial-operation-detail'].textContent, /Check Result.*No automatic retry/);
assert.match(b.nodes['settings-detail'].textContent, /stale/);
assert.ok(!b.nodes['serial-result'].disabled && b.nodes['serial-save'].disabled);
assert.ok(![...b.timers.values()].some(t => t.ms === 1000 || t.ms === 15000));
assert.equal(b.calls.filter(c => c.url === path && c.method === 'POST').length, 1);
}
const b = await adminBrowser(); b.click('select-settings'); await tick();
const d = deferred(); b.queues['/api/settings/serial'].push(d.promise);
b.queues[path].push(json({id: 56, action: 'load', state: 'pending'}), json({id: 56, action: 'load', state: 'ok'}));
b.click('serial-load'); await tick(); b.fire(1000); await tick();
const outcome = b.nodes['serial-operation-detail'].textContent;
const read = b.calls.filter(c => c.url === '/api/settings/serial').at(-1);
b.click('select-serial'); assert.ok(read.signal.aborted);
b.click('select-settings'); await tick();
d.resolve(json({...serialSettings(), baud: 110})); await tick();
assert.equal(b.nodes['setting-baud'].textContent, '230400');
assert.equal(b.nodes['serial-operation-detail'].textContent, outcome);
});
await test('Routine actions never confirm; Reset cancellation has no request or state change', async () => {
const b = await adminBrowser(), path = '/api/settings/serial-operation'; b.click('select-settings'); await tick();
const confirms = []; b.window.confirm = message => { confirms.push(message); return false; };
for (const [i, action] of ['apply', 'start', 'stop', 'load', 'defaults', 'save'].entries()) {
b.queues[path].push(json({id: i + 1, action, state: 'pending'}), json({id: i + 1, action, state: 'ok'}));
b.click('serial-' + action); await tick(); b.fire(1000); await tick();
}
assert.deepEqual(confirms, []);
const count = b.calls.length, text = b.nodes['serial-operation-detail'].textContent;
b.click('serial-reset'); await tick(); assert.equal(b.calls.length, count);
assert.equal(b.nodes['serial-operation-detail'].textContent, text);
assert.equal(confirms.length, 1); assert.match(confirms[0], /overwrites saved NVS configuration/);
assert.equal(b.calls.filter(c => c.url === path && c.method === 'POST').length, 6);
});
console.log(`PASS ${passed} browser behavior groups (production C-rendered JS)`);
})().catch(error => { console.error(error); process.exitCode = 1; });