diff --git a/CLAUDE.md b/CLAUDE.md index 12e539d..69c62ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ pio run -e -t upload # build + flash pio run # build every environment ``` -Common envs: `nrf52840custom`, `esp32-s3-N16R8`, `esp32-s3-N8R8`, `esp32-c3-N16`, `esp32-c6-N4`. CI (`.github/workflows/main.yaml`) builds **all eleven** environments on every push — keep them all green. +Common envs: `nrf52840custom`, `esp32-s3-N16R8`, `esp32-s3-N8R8`, `esp32-c3-N16`, `esp32-c6-N4`. CI (`.github/workflows/main.yaml`) builds every environment in `.github/firmware-targets.json` on every push — **12** of them — keep them all green. Note `platformio.ini`'s `default_envs` lists only 11: `esp32-wrover-e-N4R8` ships but is NOT in it, so a bare `pio run` silently skips the target most likely to catch a broken `#ifndef OPENDISPLAY_HAS_WIFI` path. Build it explicitly (`pio run -e esp32-wrover-e-N4R8`) before claiming a clean sweep. Factory provisioning: `OPENDISPLAY_FACTORY_CONFIG_HEX="..." pio run -e ` (or `tools/provision_firmware.py`). `scripts/factory_config_gen.py` runs as a pre-build step. diff --git a/docs/CONNECTION_POLICY.md b/docs/CONNECTION_POLICY.md new file mode 100644 index 0000000..7846298 --- /dev/null +++ b/docs/CONNECTION_POLICY.md @@ -0,0 +1,1018 @@ +# Connection Policy — OpenDisplay Firmware + +**Status:** normative ruleset. This document defines *what must be true*; it does not +schedule the work. Implementation is staged in +[`PLAN_FREEZE_HARDENING_2026-07-31.md`](PLAN_FREEZE_HARDENING_2026-07-31.md), whose +Phase 3 must conform to this document where the two disagree — this one wins. + +Written against the tree at `fix/nonce-replay-window`. Every statement about current +behaviour is cited to `file:line` so a reviewer can re-check rather than trust. + +No wire-protocol change: every rule here is enforced at the transport/HCI layer or in +firmware-local state. No new opcode, no new response code, no config-schema field. + +**Hard constraint — ONE command queue.** There is exactly one RX command ring and one +TX ring, shared by all transports, and this policy must never introduce a per-connection +one. The RX ring is `PIPE_MAX_W + 2` slots ([command_queue.h:63](../src/command_queue.h)) +— 18 or 34 depending on `PIPE_SMALL_DRAM_WINDOW` ([structs.h:46-54](../src/structs.h)) — +at `OD_BLE_MAX_FRAME` = 256 B each, so ~4.7 KB or ~8.8 KB. Replicating it across three +NimBLE connection slots would cost 14–26 KB on a device whose zlib window is 512 bytes. +Not a trade worth discussing. + +This is not a constraint the policy merely tolerates; it is one the policy *enforces*. +R3 requirement 1 drops a non-owner's write at the callback, before it reaches the ring, +so only the owner's frames ever enter it — there is never a second client's traffic to +separate, and therefore never a reason to partition. Callback-side filtering and the +single queue are the same decision seen from two sides: without the filter you would be +pushed toward per-connection buffering to keep streams apart. Requirement 6 adds four +bytes of *per-frame* metadata to each slot — the writer's identity word — which is +still one ring holding each frame once: identity travels with the frame instead of +being reconstructed from a captured boundary. (An earlier revision said this +constraint "keeps `bleRxQueueDiscardTo(rxBoundary)` working unchanged"; requirement 6 +**supersedes** that flush outright — dispatch-side identity filtering replaces it.) + +Where this document calls for per-connection *state* (R2's instance identity, R3's +event delivery), that state is **metadata only** — an epoch, a liveness word, a +disconnect reason; on the order of 8 bytes per slot. Nothing that holds frames is ever +replicated per connection. (Requirement 6 additionally tags each *frame* with a +4-byte identity word — per-frame metadata inside the one ring, not per-connection +state.) + +> **Revision note.** This document was reviewed against the tree after its first +> draft; that review found four defects that are corrected below and are called out +> where they land, because each is a trap an implementer would otherwise re-enter: +> the generation counter was allocated at the wrong moment (R2), refusal isolation +> needed far more than handle-bearing events (R3), the owner was released before the +> link was actually down (R3a), and the refresh BUSY-wait is *not* bounded on the +> FastEPD path (R5). + +> **Second revision note (2026-07-31, external review).** A further adversarial review +> found three defects that made the ruleset unimplementable as written, corrected in +> place: the owner token was specified as loop-task-only state while R3's filtering +> needs to read it on the stack-callback task (fixed: the one-word CAS token, R2); a +> firmware-initiated drop was implicitly BLE-only while R6's teardown can fire on a +> LAN owner (fixed: transport dispatch, R3a); and the R3a wait polled the aggregate +> connection count, which never reaches zero while a refused contender is attached +> (fixed: the per-handle instance table is the predicate). + +> **Third revision note (2026-07-31, same review, second batch).** Three further +> defects shared one root cause — **queued frames are anonymous** — and are corrected +> together by one mechanism, R3 requirement 6 (frame identity): the abort drained TX +> but not RX, and the departing owner could keep writing during its own teardown; the +> instance table could lose a departed owner's RX boundary to handle reuse before the +> loop scanned it; and the dispatcher's "from the owner" test compared transport only, +> so a delayed frame from a dead instance could stamp the new owner's activity clock +> and execute in its session. The frame tag supersedes the RX-boundary mechanism +> entirely. + +> **Fourth revision note (2026-07-31, closing the review).** The remaining findings, +> corrected in place: table 7a modelled only one arrival at a time — rows 9–10 and +> the admission-decided-once rule close it; the deep-sleep abort's rationale claimed +> state survives sleep — RAM in one draft, hardware in the next, both false — and is +> corrected at 7e row 3, where the abort now stands on teardown uniformity at a +> mid-session exit; and the auth-abuse `FE` "delivery" is restated as +> best-effort in the plan, since an empty TX ring proves stack acceptance of an +> unacknowledged notification, not receipt. + +--- + +## Definitions + +**Connection instance** — one physical link, from the stack's connect callback to its +matching disconnect. Identified by `(transport, handle, epoch)`; see R2. + +**Admitted** — the connection instance currently holding the slot, i.e. the owner. + +**Refused** — a connection instance the firmware has decided not to admit. It may be +physically established for a short time while being torn down. It is never the owner. + +**Owner state** — `NONE` or `ACTIVE` (admitted and serviceable). There is no +intermediate "dropping" state: a firmware-initiated drop waits synchronously for the +link to go down before releasing. See R3a. One further state exists on exactly one +path: **`TERMINAL`**, entered by `linkMarkTerminal()` in the deep-sleep sequence +(7e row 3) — admission permanently gated until wake reloads RAM. It is a one-way +gate, not a lifecycle state: nothing transitions out of it. + +**Inbound command** — a frame from the owner that reaches the dispatcher and is +recognised as a command. Not merely bytes; not merely a queued buffer. See R4. + +--- + +## The rules + +### R1 — One admitted client, globally + +**At most one *admitted* connection may exist across all transports at any time.** BLE +and LAN are not independent slots. A device serving a BLE client has no LAN capacity, +and the reverse. + +**Phrased in terms of admission, not physical links, because the physical form is +unachievable on ESP32.** NimBLE establishes a second central's link *before* it calls +`onConnect` ([ble_transport_esp32.cpp:81-93](../src/ble_transport_esp32.cpp)); there is +no pre-connection filter in the server API. So a transient second *physical* link +necessarily exists while it is being refused. R1 constrains what is *serviceable*; R3 +constrains what that transient link can touch, which is nothing. + +*Today R1 is false in every direction.* BLE and LAN can both be live simultaneously; +there is no connection-level arbitration, only per-*transfer* ownership (`sessionOrigin`, +stamped at each transfer START, [display_service.cpp:2159,2200,2712](../src/display_service.cpp)). +On ESP32 the single-transport case also fails: `CONFIG_BT_NIMBLE_MAX_CONNECTIONS = 3` +is baked into the precompiled NimBLE framework and cannot be lowered by a `-D` +override, and `onConnect` performs no count check — a second central's handle simply +overwrites the scalar `s_connHandle` (`:87`). + +### R2 — Every connection instance carries a unique identity + +**Identity is a triple, allocated per *physical connection*:** + +``` +(transport, handle, epoch) +``` + +- `transport` ∈ {`OWNER_BLE`, `OWNER_LAN`}. +- `handle` distinguishes simultaneous links on one transport (BLE conn handle; LAN + uses 0, being single-socket by construction). +- `epoch` is a monotonically increasing counter making the identity unique *over time*. + +> **The epoch is allocated in the connect callback, for every connection instance — +> admitted or not. It is emphatically NOT allocated on successful claim.** The first +> draft of this document said "incremented on every successful claim," which is +> self-defeating: a *refused* contender never claims, so it would carry no epoch, and +> table 7a row 4 — a refused contender that reused the incumbent's handle — could not +> be distinguished from the incumbent at all. Allocation must happen before the +> admission decision, because the identity is what the admission decision is *made +> on*. On admission the owner token copies the instance's already-allocated epoch. + +**Why an epoch is needed at all.** BLE connection handles are small integers the stack +reuses: NimBLE allocates from 0 upward, so a client that disconnects and reconnects can +be handed the *same* handle. Any deferred operation carrying a stale handle — a queued +disconnect event, a pending abort, a write filter test — can otherwise match a +different, newer session and act on it. This firmware defers work by design: +`serviceBleDisconnectCleanup` can run tens of seconds late when `loop()` was blocked in +a refresh, a hazard the code already documents at +[main.cpp:398-403](../src/main.cpp). The epoch turns "same handle" into "same +connection instance," which is what every deferred consumer actually needs. + +**Required properties:** + +- **Comparison is on the full triple.** `handle` alone is never sufficient. +- **ESP32 needs per-live-handle instance state, not a scalar.** While NimBLE permits + three links, the single `s_connHandle` ([ble_transport_esp32.cpp:87](../src/ble_transport_esp32.cpp)) + cannot represent them. A small fixed array indexed by handle is sufficient. +- **Publication must be atomic — and liveness lives in the identity word.** The triple + is written on a stack-callback task and read on the loop task. A multi-field + `volatile` struct is not an atomic snapshot; publish a single word (packed + handle+epoch, all-zero = empty) with release/acquire ordering, or guard with the + `__atomic_*` discipline the RX ring already uses + ([command_queue.cpp:62,92](../src/command_queue.cpp)). An entry's liveness must be + that same word — release-stored at connect, cleared at disconnect — never a separate + `state` field that could race the identity; R3a's wait predicate depends on reading + identity and liveness in one atomic load. The one side field (`reason`) is consumed + only after the identity word reads as down. +- **Ownership must be readable — and claimable — from the callback task.** R3's + write/subscribe filtering runs in stack callbacks, before any loop pass has had a + chance to decide anything; R7d makes the earliest transport hook the authoritative + arbitration point. A loop-task-only owner variable therefore cannot work — a prior + draft specified one alongside callback-side filtering, which is a contradiction: at + the moment a contender's `onWrite` fires, a loop-side token gives the filter nothing + to compare against, and there is no rule for the unowned window before first + admission. **The owner token is a single 32-bit word** — packed + `transport(2) | handle(14) | epoch(16)`, all-zero meaning unowned — claimed with one + `__atomic_compare_exchange` at the earliest transport hook (the BLE connect callback, + on the host task; the LAN accept, on the loop task), read with one atomic load from + any task, and released (CAS back to zero) only on the loop task, after R3a's wait. + CAS success *is* admission; CAS failure is what marks the instance a contender, which + the loop-side scan then refuses (R3). This closes the unowned window: the host task + processes a peer's connect before any of its writes, so by the time a first client's + first write reaches `onWrite`, the word already names its owner. In the connect + callback the order is: allocate the epoch, publish the instance-table entry, then + CAS — a successful claim never names an instance the loop cannot yet see. The epoch + counter itself is `__atomic_fetch_add`, since BLE allocates on the host task and LAN + on the loop task. One further reserved encoding, **`OWNER_TERMINAL`** — transport + code 0b11, reserved word `0xC0000000` in the `[31:30] transport | [29:16] handle | + [15:0] epoch` layout: `linkMarkTerminal()` exchanges the word to it unconditionally + and **returns the displaced owner identity** (possibly none), which is what the + terminal caller hands the abort to act for — after the exchange a fresh read of the + word yields terminal, not the departing owner. Claims succeed only against the + all-zero word, so admission is impossible from that point until a reset or wake + reloads RAM. `linkRelease` matches the *full* identity and never accepts the + terminal word as an argument, so the abort's release — called with the displaced + identity — is naturally inert and nothing can CAS the gate back to zero. This is + what lets a terminal transition run the ordinary abort unmodified (7e row 3). +- **Scope is one boot.** Uniqueness across reset is not required and is not claimed: + no deferred RAM state survives a reset. +- **Wrap.** The epoch is **16 bits — a deliberate narrowing**, because the one-word + token above must stay lock-free and neither Cortex-M4 nor the ESP32 ISAs have a + lock-free 64-bit CAS; HCI connection handles are spec-bounded at 0x0EFF (12 bits), + so `2 | 14 | 16` fits with headroom. The normative invariant is that no outstanding + event may survive a full counter cycle. The argument for it is **conditional, and + the condition is stated rather than hidden**: for a stale identity to be + *misconsumed*, the loop must resume and consume it, so the churn window that matters + is a blocking window that later **completes**. Epochs churn only at link-layer + connection rate — tens of milliseconds per instance, on the host task — so a full + 2^16 cycle needs on the order of half an hour of *continuous* connect churn inside + one completing block; no bounded refresh approaches that. The one unbounded block in + the tree, R5's FastEPD refresh, does not break the invariant the way it first + appears to: a refresh that never completes means the loop never runs again, so the + outstanding event is never consumed at all and a collision has no consumer to + mislead. If this margin is ever doubted, the mitigation is an allocator that skips + epochs still present in the instance table — not a wider word, which the lock-free + constraint forbids. Epoch 0 is never allocated (`linkNextEpoch` re-draws when the + fetch-add yields 0, so wrap cannot mint it), reserving the all-zero word for + "unowned." + +### R3 — A contender is always refused, and refusal is inert + +**While the slot is held, an incoming connection is refused** — before establishment +where the stack allows it, otherwise by immediate disconnection. Admission never evicts +an incumbent; reclaiming a held slot is exclusively the job of R4. + +**Refusal must not perturb device state.** Refusing must not run `abortToKnownState`, +raise `s_disconnectCleanupPending`, call `linkRelease`, touch the encryption session, +touch transfer state, or alter panel power. The incumbent must be unable to observe +that a contender arrived. + +> **Handle-bearing events are necessary but nowhere near sufficient.** The first draft +> stopped at "ignore the refused contender's disconnect event." Review of the ESP32 +> callbacks showed that a contender perturbs shared state *before any loop-side +> decision runs*. All of the following are live defects in the current tree. + +On ESP32, every one of these is global scalar state that any central can move: + +| Shared state | Site | What a contender does to the incumbent | +|---|---|---| +| `s_notifySubscribed` | [esp32:81-93](../src/ble_transport_esp32.cpp) (connect), [:129](../src/ble_transport_esp32.cpp) (subscribe, `(void)connInfo`) | Clears/overwrites the incumbent's apparent notify-readiness, stalling its TX | +| RX ring | [:135](../src/ble_transport_esp32.cpp) `onWrite`, `(void)connInfo` | Injects commands into the incumbent's stream; can fill the ring and drop incumbent frames | +| TX / notify | [:269-277](../src/ble_transport_esp32.cpp) | **Leaks incumbent responses to the contender** — see below | +| `s_connHandle` | [:87](../src/ble_transport_esp32.cpp) | Overwritten, so link tuning and any future disconnect target the wrong link | + +**The notify leak is the sharpest of these and is present today.** `BleTransport::notify` +calls `s_txCharacteristic->notify(data, len)` — the two-argument overload. NimBLE's +signature is `notify(value, length, connHandle = BLE_HS_CONN_HANDLE_NONE)`, documented +as "or `BLE_HS_CONN_HANDLE_NONE` to send the notification to **all subscribed +clients**." So a second central that connects and subscribes receives every response +the incumbent is sent, including authentication traffic — with no policy decision +having been made, and before `loop()` runs at all. + +**Therefore R3 requires, at the callback boundary:** + +1. **Per-link write filtering.** Drop a non-owner's write in `onWrite` before it + reaches the RX ring. +2. **Per-link subscribe filtering.** A non-owner's `onSubscribe` must not move the + owner's notify state; subscription state must be per-instance. +3. **Handle-targeted notification.** `notify()` must pass the owner's conn handle, so + a subscribed non-owner receives nothing. This is a one-argument change and it + closes a live leak independent of the rest of this policy. +4. **Identity-bearing disconnect events**, with every consumer ignoring an event whose + identity does not match the owner. Today `takeDisconnectedEvent` + ([ble_transport.h:81](../src/ble_transport.h)) carries a reason and an RX boundary + but no handle, so this is a new transport requirement — *additional* to the + handle-bearing connect event already planned. +5. **Connection state must survive lost edges — via a table, not a queue.** See below. +6. **Frame identity — every queued frame carries its writer's instance identity, and + the dispatcher re-checks it against the owner word before executing.** See below; + this requirement retires the RX-boundary mechanism. + +**How the callbacks know the owner.** Requirements 1 and 2 run on the stack's host +task, before any loop pass — during a refresh, up to ~16 s before one. They read the +one-word owner token (R2) with a single `__ATOMIC_ACQUIRE` load and compare it against +their own instance's `(transport, handle, epoch)`; requirement 3's `notify()` reads the +same word on the loop task for the target handle. There is no unowned ambiguity to +special-case, because the claim itself happens in the connect callback (R2's CAS) and +the host task processes a peer's connect before any of its writes — a contender is +exactly an instance whose connect-time CAS failed, and its writes fail the comparison +from its first frame. None of the filters touches any other loop-side state. + +#### Requirement 5 in detail: the instance table + +Connect and disconnect events are coalescing booleans today +([ble_transport_esp32.cpp:33-34](../src/ble_transport_esp32.cpp), +[:341-354](../src/ble_transport_esp32.cpp)), and the header records the weakness: "a +second same-type event arriving inside the check-then-clear window is lost" +([ble_transport.h:66-71](../src/ble_transport.h)). The side-band data — +`s_disconnectReason`, `s_rxBoundaryAtDisconnect`, `s_connHandle` — is single-slot too, +so each event overwrites the last. + +Today that is tolerable because `serviceBleEvents()` decides nothing per-connection: a +connect means "reset `rebootFlag`, update MSD, tune the link," a disconnect means +"flush the RX ring to the boundary, raise the cleanup flag" +([main.cpp:461-500](../src/main.cpp)). Under this policy each event drives an +*admission decision about a specific instance*, so a lost event is a lost decision: + +- **Lost connect → an unrefused contender.** Two centrals connect while `loop()` is + blocked in a refresh; the flag is set twice and read once. One is refused; the other + is connected, never evaluated, and invisible to the loop. +- **Lost disconnect → the slot held by a ghost.** Owner disconnects, then a contender + connects and disconnects, all within one refresh block. The flag coalesces and the + side-band identity is the *last* writer's. The loop sees a disconnect that does not + match the owner, treats it as inert (7b row 3), and never releases the owner. Every + new client is refused until the idle timeout reclaims the slot — a device-wide + outage of one full timeout. + +**The mechanism is a fixed per-handle instance table, not an event queue.** Sized by +the connection cap: 3 on every ESP32 target here (`CONFIG_BT_NIMBLE_MAX_CONNECTIONS` +is 3 in the precompiled `sdkconfig.h` for S3/C3/C6, and absent for classic ESP32 so +NimBLE's own `#ifndef` default of 3 applies), 1 on nRF. Each entry holds +`(handle, epoch, reason)` — metadata only, ~8 bytes, never frames (see the +one-command-queue constraint above). There is no separate `state` field: liveness *is* +the packed `(handle, epoch)` identity word, per R2's publication rule — all-zero means +empty, so an entry cannot present a live identity with a stale state or vice versa. +There is no `rxBoundary` field either — requirement 6 retires the boundary mechanism, +which is what lets entries be overwritten freely on churn. + +Callbacks write their handle's entry. **The loop does not consume a stream of edges; it +scans the table and compares it against its own notion of the owner.** That inverts the +problem and dissolves the overflow question entirely: + +- **It cannot overflow.** State is bounded by the connection cap, not by event rate. + Contender churn overwrites entries for handles that are already gone. There is no + eviction policy to specify, because nothing is ever queued. +- **Lost edges stop mattering.** A contender that connects and disconnects wholly + within a refresh block leaves no entry — correct, since there is nothing left to + refuse. +- **Owner release is a comparison, not an event.** If the owner's `(handle, epoch)` is + no longer live in the table, the owner is gone, however many edges were missed. This + makes 7b rows 4 and 7 (stale epoch, duplicate disconnect) inert for free rather than + by explicit rule. +- **Ghosts stay visible.** Any live entry that is not the owner is a contender still + needing refusal, and it remains visible until refused — so a missed refusal + self-corrects on the next pass instead of leaking a connection slot. + +*Search the table by handle rather than indexing by it.* NimBLE allocates handles from +0 upward in practice, so direct indexing usually works, but a 3-entry linear search +costs the same at this size and cannot be broken by a stack change that hands out +sparse handles. + +nRF needs only requirement 4 of the *callback-filtering* set in practice — +`Bluefruit.begin(1, 0)` configures the SoftDevice for a single peripheral link +([ble_transport_nrf.cpp:164](../src/ble_transport_nrf.cpp)), so cross-central injection +is unreachable at the link layer. Its write callback also discards the handle it is +given ([ble_transport_nrf.cpp:148](../src/ble_transport_nrf.cpp)), which is latent +rather than live. Requirement 6, however, applies to nRF in full: it is +transport-agnostic, because even a single-link target queues frames that can outlive +their session across a disconnect/reconnect pair inside one blocked-loop window. + +#### Requirement 6 in detail: tagged frames, and the end of the RX boundary + +Today a queued frame is anonymous — `CommandQueueItem` is `{data, len, pending}` +([command_queue.h:72-76](../src/command_queue.h)) — and the disconnect path compensates +with a *boundary*: the callback captures the ring head at link-down +([ble_transport_esp32.cpp:105](../src/ble_transport_esp32.cpp)), and the loop later +discards up to it ([main.cpp:480-489](../src/main.cpp), +`bleRxQueueDiscardTo`, [command_queue.h:112](../src/command_queue.h)). Review found +three defects that are all this one anonymity seen from different angles: + +- **The teardown window.** R6's abort runs while the owner token is still held (release + is its last step, after R3a's wait), so the departing owner's writes keep passing the + requirement-1 filter and entering the ring *during* its own teardown — after any + flush the abort performs. If a new owner is then admitted, those frames dispatch into + the new session. +- **The boundary is losable.** The boundary lives in the departing instance's table + entry (or today's single slot). If the stack reissues the handle to a newcomer before + the loop scans — reachable inside one refresh block — the live entry overwrites the + dead one and the boundary is gone, with the stale frames still queued. +- **Dispatch identity was transport-only.** `g_commandOrigin` says BLE-or-LAN, nothing + more, so a delayed frame from a dead BLE instance is indistinguishable at dispatch + from the new BLE owner: it stamps the new owner's R4 activity clock and executes in + its session. + +**The mechanism:** `onWrite` stamps each frame with the packed identity word of the +writing instance — the same word it already loaded for the requirement-1 filter, so +the stamp costs nothing new — and the dispatcher executes a frame **only if its tag +still equals the current owner word** (one atomic load and compare). A mismatched +frame is dropped and counted, never parsed. The invariant: *a frame dispatches iff +its instance was the owner both when it arrived and when it dispatches.* + +Consequences, each replacing a patch with a property: + +- **The RX-boundary mechanism is retired**: the capture at link-down, the `rxBoundary` + side-band slot, `takeDisconnectedEvent`'s boundary out-param and + `bleRxQueueDiscardTo` all go. Stale frames self-discard at dispatch, one compare + each, however many edges or table overwrites were missed in between. +- **The table-overwrite hazard dissolves.** With no boundary to preserve, handle reuse + before the loop scans loses only the departed instance's disconnect `reason` — a log + line, not correctness. No tombstones, no versioned slots. +- **The teardown window closes.** The abort resets both rings outright — sound + because requirement 1 guarantees every frame in them *passed the owner check when it + was written* — and a frame the departing owner writes *after* that reset, during the + R3a wait, carries the departing tag and fails the dispatch check once the token is + released. This is the same construction that makes an expired R3a wait harmless, and + it is what finally discharges the rejected `DROPPING` state's residual job. +- **R4's "from the current owner" becomes exact**: tag equals owner word — full + instance identity, not transport. + +**The ring-reset contract (SPSC-safe).** The RX ring is single-producer / +single-consumer: the callback task owns the head, the loop task owns the tail +([command_queue.cpp:23-24](../src/command_queue.cpp), +[command_queue.h:78](../src/command_queue.h)). `bleRxQueueReset()` is therefore +**consumer-side discard only**: acquire-load the head, release-store that snapshot +into the tail, and write neither the head nor any slot. A conventional reset that +wrote both indices or cleared slot contents would race a producer mid-copy — the push +copies payload before publishing the head with a release-store +([command_queue.cpp:92](../src/command_queue.cpp)). A push in flight either published +before the snapshot (discarded with the rest) or after it (survives, carrying the +departing owner's tag, and is dropped at dispatch) — which is exactly why the reset +needs no stronger guarantee than the tag already provides. Two corollaries: + +- **Tag publication order:** the tag is written into the slot *before* the + release-store that publishes the head, exactly like `data` and `len`, or the + consumer's acquire load cannot be guaranteed to see it. +- **No reset while a peek is outstanding.** The consumer holds a pointer into the + current slot across dispatch — the dispatcher decrypts in place and only then + advances the tail ([command_queue.h:78-94](../src/command_queue.h)). Every + *returning* abort caller is loop-side, after the pass's RX consumption; the one + in-dispatch caller, deep sleep, never returns to the peeked slot, which is why it is + safe. Any future abort caller that runs inside dispatch and returns must consume the + current frame first. + +LAN frames do not traverse the BLE ring: the single socket is parsed and dispatched on +the loop task within a pass, its buffer dies with the session (`tcpReceiveBufferPos = +0` in the close seam), and dispatch receives the LAN owner's identity word directly. +The tag is subject to R2's 16-bit epoch wrap argument, trivially: frames live in the +ring for seconds, not the half-hour a collision requires. + +### R3a — A firmware-initiated drop waits for the link to go down + +**The drop is synchronous: the seam requests termination, then waits — cooperatively +and with a bound — until the link is actually down, before the abort releases the +slot.** Expiry of the bound is an early exit, not a failure: the release then proceeds +anyway and the stale link is inert (see below). "Synchronous" means the release *waits +for* link-down; it does not mean link-down unconditionally precedes it. + +> This corrects a first-draft error in two stages. The first draft released the owner +> in the same step as *requesting* the disconnect. The correction introduced a +> `DROPPING` owner state and a cross-pass state machine. That was then investigated +> against the tree and found to be more machinery than the problem needs — see below. + +A BLE disconnect is asynchronous. `NimBLEServer::disconnect()` returning true means +termination was *requested* — it returns true even for `BLE_HS_ENOTCONN`/`EALREADY` +(`NimBLEServer.cpp:321-332`). Releasing the token at request time would let a new +connection be admitted while the old link is still physically up. + +**Why synchronous, and why it needs no `DROPPING` state.** Three properties of the +current tree make the simple form correct: + +- **Link-down is per-handle pollable, without consuming the event.** The disconnect + callback writes the departing instance's table entry (R3 requirement 5) at the + moment the link drops, so the wait's predicate is "the owner's `(handle, epoch)` + entry is no longer live" — a scan of the instance table. **The aggregate + `connectedCount()` must NOT be the predicate**: it is the stack's total peer count + on both targets ([ble_transport_esp32.cpp:257-259](../src/ble_transport_esp32.cpp), + [ble_transport_nrf.cpp:235-239](../src/ble_transport_nrf.cpp)), and R1 explicitly + permits a refused contender to be transiently attached — dropping the owner then + moves the count 2→1, never to 0, and the wait sits out its full bound on a link + that is already down. The disconnect *event* stays queued for `serviceBleEvents()` + to consume on its normal path — the wait neither consumes nor reorders it. (What + survives of that path is the event flow, flag and reason; its RX-boundary capture is + retired by R3 requirement 6.) +- **The wait ticks on a short plain `delay()`, not `idleDelay()`.** A first draft + named `idleDelay()` the right primitive for its early-out on `ble.eventPending()` + ([main.cpp:749](../src/main.cpp)). That early-out is exactly wrong here: the + predicate is table state, not event arrival, and mid-teardown events are + deliberately left unconsumed — so once any event is pending (the owner's own + disconnect, or an unserviced contender's connect), every `idleDelay()` call returns + immediately and the wait degrades into a busy spin for its remaining bound. A plain + `delay(2)` tick services *neither* RX nor transport events — the safety property + actually wanted — and costs a few milliseconds of latency against a bound sized in + tens of them. The abort is loop-task-only and already deferred while + `epdRefreshInProgress` ([main.cpp:389](../src/main.cpp)), so the wait can never land + inside a refresh. +- **R2's epoch already provides what `DROPPING` was providing.** If the bounded wait + expires and the slot is released with the old link still up, that link is inert *by + construction*: its writes are filtered as non-owner (R3 requirement 1) and its late + disconnect is inert on stale epoch (table 7b rows 4 and 9). A stale link cannot reach + the new session. `DROPPING` was belt-and-braces over a guarantee R2 already makes. + +**Transport dispatch.** A firmware-initiated drop acts on the *owner's* transport, +which the token records — R6's teardown can fire on a LAN owner (the transfer watchdog +is origin-agnostic), and dropping a BLE handle there would leave the owning socket +alive while its token is released, violating R1. `OWNER_BLE` drops through the seam +with the bounded wait above; `OWNER_LAN` stops the TLS context and closes the client +socket, which is synchronous — the wait, and the asynchrony problem this rule exists +for, are BLE-only. + +**Timing.** An alive peer terminates within a few connection intervals — tens of +milliseconds; the firmware requests no interval, so the central's negotiated value +applies. A peer that is already gone would cost the remainder of the supervision +timeout, but such a peer is reaped by the link layer at ~4–6 s, far short of the 120 s +idle timeout, so that case is effectively unreachable at the drop site. + +**The bound does not disappear — it relocates**, from "how long before we force-release +a wedged token" to "how long we wait before proceeding anyway." It is far less +load-bearing in the second form: expiry is not a failure needing recovery, just an +early exit into an abort that was going to run regardless. Sizing it is an open +question; it wants to cover a few connection intervals with margin, not a supervision +timeout. + +**Phase 4 composes with this rather than fighting it.** The auth-abuse drop already +requires a bounded cooperative wait before disconnecting, to deliver its final `FE`. +Both are the same shape: cooperative wait, bounded, proceed on expiry. + +### R4 — Each transport enforces an idle timeout, ungated by transfer state + +**Idle** is defined as, and only as: + +``` +idle := no inbound command from the owner on the owning transport + AND no refresh in progress +``` + +**The idle timeout is NOT gated on a transfer being in progress.** This is deliberate +and is the rule's whole point: a client that goes silent *during an image upload* is +exactly the case that wedges the device today, and a `!transferActive()` gate would +exempt it. An in-flight transfer confers no protection; only inbound traffic does. + +Consequences, stated because they are the cost of the rule: + +- A silent client mid-upload **is dropped** and its partial transfer discarded by R6's + abort. Partial upload state is never preserved across a drop. +- Any client whose legitimate inter-command gap can exceed the timeout will be dropped + mid-transfer. + +**Default: `OD_BLE_IDLE_TIMEOUT_MS = 120000` (120 s).** Set deliberately generous +because this rule made the direction of that error worse: while the drop was gated on +`!transferActive()` a short timeout only killed idle sessions, but with the gate gone +a short timeout kills legitimate *uploads*. The cost of being generous is bounded and +lands on one case only — a returning client waits up to 120 s if a stale-but-*alive* +incumbent holds the slot. A client that is genuinely gone is reaped by the link layer +in ~4–6 s (the firmware sets no supervision timeout, so the central's negotiated value +applies), so the lockout never applies to a crashed or out-of-range peer. + +**What counts as activity.** A frame must reach the dispatcher, be **recognised as +a command from the current owner**, and be **accepted** — i.e. past the +authentication gate wherever there is one — where "from the current owner" is full instance +identity, not transport: the frame's R3-requirement-6 tag must equal the owner word. A +transport-level test is insufficient, because a delayed frame from a dead BLE instance +is indistinguishable from the new BLE owner by transport alone and would stamp the new +owner's clock. (In practice the dispatch tag check has already dropped such a frame +before the stamp is reached; the stamp's own identity test is one redundant compare.) + +> The first draft said "successfully queued or parsed," and pointed at +> `bleRxQueuePush()`'s success path. That is wrong and self-contradictory: the queue +> accepts any non-empty payload within the size cap +> ([command_queue.cpp:50-93](../src/command_queue.cpp)) — including a one-byte +> malformed frame or an unknown opcode, which the dispatcher only rejects later +> ([communication.cpp:544,754](../src/communication.cpp)). Stamping on queue success +> leaves a garbage flooder able to hold the slot indefinitely, which is precisely the +> failure the rule exists to prevent. + +Two clocks in the tree are unusable as-is and must be fixed rather than reused: + +- `pollActivity` stamps `lastActivityMs` whenever `connCount > 0` + ([main.cpp:366](../src/main.cpp)) — a live-but-silent link never ages. +- LAN stamps `lastLanActivityMs` on `got > 0`, i.e. any bytes read + ([wifi_service.cpp:946](../src/wifi_service.cpp)) — a flooder holds the slot with + garbage. + +**The clock must not run during a refresh.** `epdRefreshInProgress` brackets a +*blocking* call on the loop task ([display_service.cpp:2446-2467](../src/display_service.cpp), +[:3358-3368](../src/display_service.cpp)): `loop()` does not execute for the refresh's +duration, but wall-clock time passes. A naive `millis() - lastRx` accrues the whole +refresh and can drop an actively engaged client the moment `loop()` resumes. + +Implementation requirements for the exclusion: + +- **A loop-side edge detector cannot see the edge** — both transitions happen inside + the blocking handler. The re-stamp must be invoked *at* the transition, via a single + `endRefresh()` helper that both refresh sites call, not by polling the flag. +- **Re-stamp the current owner's clock only**, and only if the same instance identity + still owns the slot. ("Both transports" is harmless under a perfect R1 but hides the + identity requirement, and R1 is exactly what is being built.) +- Re-stamping can only ever *delay* a drop, never cause a spurious one. + +**Ordering constraint.** On LAN, inbound bytes may be sitting in the socket when the +deadline is evaluated. The timeout check must run **after** the transport has had its +chance to parse this pass, or an active LAN client is dropped with its command already +in the buffer. BLE avoids this by stamping from callback context on arrival; LAN must +parse first. See R7d. + +**Baseline.** The idle window is measured from the later of admission and last inbound +command, so a freshly admitted client gets a full window before its first command. On +LAN, admission is TCP accept, which starts a **provisional** window; successful TLS +handshake completion **restarts** it, since handshake traffic is not a command and the +client should get its full window from the point it can actually issue one. Both halves +matter: without the provisional accept-time window a stalled handshake would never be +reclaimed, and without the restart at completion a slow handshake would eat into the +client's first-command window. + +**Per transport.** Each transport enforces its own timer and constant. LAN already has +one (`OD_LAN_READ_TIMEOUT_S` = 30 s, [wifi_service.cpp:952](../src/wifi_service.cpp)), +already ungated by transfer state, so LAN needs only the recognised-command stamping +and the refresh exclusion. BLE has no idle drop at all and needs the whole mechanism. + +### R5 — A stuck refresh is a separate problem with a separate watchdog + +R4 excludes refresh from idleness, so a refresh that never completes is **not** caught +by the idle timeout. That exposure is handled by a **refresh watchdog**, deliberately +*not* part of this policy but named here so it is tracked rather than assumed away. + +Scoping it honestly, from the code: + +- On the **`bb_epaper` polling path only**, the BUSY wait is bounded: + `waitforrefresh(60)` loops `timeout * 100` times at 10 ms and then returns failure + ([display_service.cpp:803-831](../src/display_service.cpp)). +- **On the FastEPD path there is no bound at all.** `waitforrefresh()` delegates + immediately to `fastepd_wait_refresh()` (`:805`), which ignores its timeout argument + outright — `(void)timeout_sec; return !s_init_failed;` + ([display_fastepd.cpp:277-280](../src/display_fastepd.cpp)). The real blocking lives + inside `fullUpdate()`/`fastUpdate()`, above that call and unbounded. + + > The first draft claimed the BUSY wait was bounded at 60 s generally. It is not. + > On FastEPD targets the naive "panel never signals done" case is fully exposed. + +- The residual exposure elsewhere is the driver call itself — `bbepRefresh()`, + `fastepd_direct_refresh()`, `fastepd_partial_refresh()` — plus any SPI-level stall + inside it, none bounded by the poll loop above it. +- **No loop-serviced watchdog can observe any of this**, because `loop()` is blocked + for the refresh's entire duration. The watchdog needs an independent timebase: a + hardware watchdog fed from `loop()`, a timer ISR, or a separate task. +- **The supervisor must recover from a safe context.** A timer ISR can *observe* a + stuck refresh but must not run panel/SPI teardown from interrupt context; the + realistic recovery is an MCU reset. +- There is no refresh start timestamp in the tree; the watchdog must add one. + +### R6 — Every non-refused disconnect calls `abortToKnownState()` + +**Any disconnect of the current owner — on any transport, for any reason, whether +client-initiated, link-layer, or firmware-initiated — runs `abortToKnownState()`**, +leaving the device ready for a new connection. + +`abortToKnownState()` must leave, at minimum: no active direct-write, partial, pipe or +chunked-config transfer; touch resumed; encryption session cleared; RX and TX rings +drained of the departed session's traffic; the owner token released — except for the +terminal caller (7e row 3), where the word was exchanged to `TERMINAL` before the +abort and the release, called with the displaced identity, is deliberately inert: +there the postcondition is "the slot is not claimable", which the gate satisfies. Both rings are +drained by outright reset — sound because R3 requirement 1 means every frame in them +passed the owner check when it was written — and a frame the departing owner writes +*after* the reset, during R3a's wait, is covered by requirement 6's dispatch tag +check rather than by re-flushing. The RX reset must honour requirement 6's SPSC +contract: consumer-side discard only. + +**Buzzer and LED are explicitly NOT stopped.** They are user-facing *effects*, not +session state. Firing a buzz and immediately dropping the link is a normal pattern — +command, then disconnect to save power — and truncating it defeats the command. A +playing melody cannot corrupt or confuse a later connection the way a half-open pipe +session, a suspended touch input or a live crypto session can, and both are bounded +and self-terminating: the buzzer's `outer` repeat count is a `uint8_t` coerced to at +least 1, with playback stopping at `rep >= outer` +([buzzer_control.cpp:215-217,288-291](../src/buzzer_control.cpp)); the LED runs a +stepped pattern to completion ([device_control.cpp:530-541](../src/device_control.cpp)). +Since this policy fires the abort far more often than a plain disconnect once did, +stopping them would be a correspondingly more visible regression. A WARM (post-refresh keep-alive) panel **survives** — the abort tears down +only a mid-transfer `PWR_ACTIVE` session, preserving the existing ACTIVE-only-teardown +invariant ([main.cpp:411-415](../src/main.cpp)). It runs on the loop task, is +idempotent, and is deferred while `epdRefreshInProgress` +([main.cpp:389](../src/main.cpp)). + +**Exceptions — R6 does not apply to:** + +1. **A refused contender** (R3). It was never the owner; its disconnect is inert. +2. **Terminal transitions**, where "ready for a new connection" is meaningless because + the MCU is about to reset, sleep, or lose power. These paths disconnect the link + and then leave, so no loop pass will ever service the event: + - nRF DFU: `Bluefruit.disconnect()`, `delay(100)`, `sd_softdevice_disable()`, jump + to bootloader ([device_control.cpp:847-866](../src/device_control.cpp)). + - ESP32 DFU/reboot: BLE teardown then immediate restart + ([device_control.cpp:880](../src/device_control.cpp)). + - Power-latch off ([device_control.cpp:942](../src/device_control.cpp)) — power can + be physically removed before any teardown. + + For these, either accept the exception as stated, or call + `abortToKnownState(dropLink=false)` **synchronously before** the teardown/jump/sleep. + Choose per path; the exception is the default. What is *not* acceptable is the first + draft's unqualified "every disconnect," which these paths simply falsify. + +### R7 — Permutation tables + +Normative. Any combination not listed is a specification gap, not implementer's +discretion. + +#### 7a — Admission + +"Refuse" means: disconnect/close the contender and change nothing else (R3). + +| # | Owner state | Incoming | Action | Owner after | Abort? | +|---|---|---|---|---|---| +| 1 | `NONE` | BLE connect `(h, e)` | Admit; `claim(BLE, h, e)` | `ACTIVE BLE(h,e)` | no | +| 2 | `NONE` | LAN accept | Admit; `claim(LAN, 0, e)` at **TCP accept** | `ACTIVE LAN(0,e)` | no | +| 3 | `ACTIVE BLE(h1,e1)` | BLE connect `(h2, e2)` | **Refuse** `h2` | unchanged | no | +| 4 | `ACTIVE BLE(h1,e1)` | BLE connect `(h1, e2)` — handle reused after a stale link | **Refuse** — epoch differs, so this is a new instance despite the matching handle (R2) | unchanged | no | +| 5 | `ACTIVE BLE(h1,e1)` | LAN accept | **Refuse**: `incoming.stop()` | unchanged | no | +| 6 | `ACTIVE LAN(0,e1)` | BLE connect `(h,e)` | **Refuse** `h` | unchanged | no | +| 7 | `ACTIVE LAN(0,e1)` | LAN accept | **Refuse**: `incoming.stop()` | unchanged | no | +| 8 | `ACTIVE`, drop in flight | any | **Refuse** — the slot is still held until the synchronous wait completes (R3a) | unchanged | no | +| 9 | `NONE` | two or more connects/accepts race the free slot | The claim CAS serializes them: exactly one wins whatever tasks they arrive on; every loser is a contender, refused | `ACTIVE` (the CAS winner) | no | +| 10 | `NONE` (just released) | a previously refused contender, link still up | **Stays refused** — admission is decided once, at the instance's own connect hook, and never revisited | `NONE` until a new instance connects | no | +| 11 | `TERMINAL` (7e row 3) | any connect/accept | **Refuse** — the claim CAS fails against the terminal word; the stack is about to go down | `TERMINAL` | no | + +**Admission is decided exactly once per instance, at its connect hook — and never +re-evaluated.** Rows 9 and 10 close a gap review found in this table, which modelled +only one arrival at a time. The rule is the mechanical consequence of the CAS design +made normative: an instance attempts the claim exactly once, in its own connect +callback or accept, and there is no code path that retries it later. Three cases +follow without further rules: + +- **Racing arrivals need no tiebreak.** Two connects during a blocked refresh, or a + BLE connect racing a LAN accept, are serialized by the word itself — the CAS winner + is the owner regardless of which task got there first or what order the loop later + scans the table (row 9). "BLE before LAN" was never a winner rule; see R7d. +- **A freed slot is claimable only by instances that connect after the free.** A + contender that arrived while the slot was held lost its one CAS and is being + disconnected; it cannot inherit the slot however long its physical link lingers + (row 10). The client behind it simply reconnects, and its *new* instance claims. + This keeps R3 absolute — "a contender is always refused" has no asterisk for slots + that free up later — and it is what makes the loop's refusal scan order-free and + idempotent: re-refusing a doomed entry is always correct, admitting one never + happens there. +- **The table scan never admits.** The loop's only admission-adjacent job is refusal + of CAS losers (R7d step 2); every claim happens at a hook. An implementation that + admits from the scan — e.g. "slot is free and this entry looks live, claim it for + them" — violates this table even when it happens to pick the right instance. + +Row 7 is a **behaviour change**: LAN accept is unconditional last-in-wins today +([wifi_service.cpp:869-877](../src/wifi_service.cpp)). It matters more than the BLE +rows because LAN-TLS bypasses app-layer auth by design, so today any host on the +network can displace an in-flight push by opening a socket, with no credentials. + +**LAN claims at TCP accept, before the TLS handshake.** The handshake is driven +incrementally across later loop passes ([wifi_service.cpp:905](../src/wifi_service.cpp)), +so deferring the claim until it completes would leave the slot free for a BLE connect +or a second socket in the meantime. Consequently: + +- A second accept *during* the handshake is refused (rows 5/7 apply). +- **TLS handshake failure is an owner disconnect** — it runs R6's abort and releases. +- Handshake traffic is **not** activity for R4; the idle baseline starts at handshake + completion. +- **A handshake deadline is deferred, not required.** An earlier draft required the + handshake carry its own bounded deadline. Deferred: a socket stuck mid-handshake is + already reclaimed by `OD_LAN_READ_TIMEOUT_S`, because the idle baseline does not + start until the handshake completes, so a handshake that never finishes leaves the + clock at its accept-time stamp and the existing 30 s drop fires. A dedicated + deadline would only tighten that window, which is not worth a second tunable until + something shows 30 s is too slow. + +Rows 3–8 are what make R1 true on ESP32, where the link layer will not. nRF enforces +rows 3–4 at the link layer via `Bluefruit.begin(1, 0)` +([ble_transport_nrf.cpp:164](../src/ble_transport_nrf.cpp)); firmware must still +implement them so behaviour is identical across targets and so rows 5–6 work at all. + +#### 7b — Disconnect + +**Generic rule, which the rows below instantiate:** *for any owner state, a disconnect +whose full instance identity does not match the owner is inert.* + +| # | Owner | Disconnect identity | Action | Owner after | Abort? | +|---|---|---|---|---|---| +| 1 | `ACTIVE BLE(h1,e1)` | matches | `abortToKnownState(dropLink=false)`, whose **final** step releases | `NONE` | **yes** | +| 2 | `ACTIVE LAN(0,e1)` | matches | `abortToKnownState(dropLink=false)`, whose **final** step releases | `NONE` | **yes** | +| 3 | any `ACTIVE` | refused contender `(BLE, h2, ·)` | Inert (R3) | unchanged | no | +| 4 | `ACTIVE BLE(h1,e1)` | `(BLE, h1, e0)` — stale epoch | Inert — late event from a prior instance (R2) | unchanged | no | +| 5 | `ACTIVE LAN` | any BLE identity | Inert (cross-transport) | unchanged | no | +| 6 | `ACTIVE BLE` | any LAN identity | Inert (cross-transport) | unchanged | no | +| 7 | any | duplicate of an already-consumed identity | Inert (idempotent) | unchanged | no | +| 8 | `NONE` | any | No-op | `NONE` | no | +| 9 | `NONE` | the link a timed-out synchronous drop left up (R3a) | Inert — already released and aborted; the identity is stale by then | `NONE` | no | + +`dropLink=false` throughout: the link is already gone. Row 4 is the ABA case the epoch +exists to catch, reachable in practice because a disconnect event can be serviced tens +of seconds late when `loop()` was blocked in a refresh, by which time the handle may +have been reissued. + +Rows 3–7 all collapse to the generic rule above; they are enumerated because each was a +distinct hazard before the epoch made them uniform. Note there is no row for a +firmware-initiated drop completing: R3a's synchronous wait means the abort and release +have already happened inside the drop, so the event that follows is just row 9. + +#### 7c — Idle timeout + +| # | Owner | Refresh in progress | Inbound silence | Action | Abort? | +|---|---|---|---|---|---| +| 1 | `ACTIVE` | no | `>` timeout | Drop via the owner's transport (BLE: the seam + R3a wait; LAN: synchronous socket close), then abort and release — all within the one pass | **yes** | +| 2 | `ACTIVE` | no | `≤` timeout | Nothing | no | +| 3 | `ACTIVE` | **yes** | any | Nothing — not idle by definition (R4); `endRefresh()` re-stamps the owner's clock | no | +| 4 | `NONE` | any | n/a | Nothing — no timer runs without an owner | no | + +Row 1 applies **whether or not a transfer is in flight** (R4). + +#### 7d — Within-pass ordering + +**Normative, because without it two conforming implementations pick different +winners.** The current loop order is `serviceBleEvents()` → BLE RX → deferred +disconnect cleanup → LAN accept/read ([main.cpp:624](../src/main.cpp)), and connect and +disconnect flags are consumed connect-first regardless of actual arrival order +([main.cpp:461-471](../src/main.cpp)) — which is exactly the ambiguity this section +removes. + +Within one loop pass, evaluate in this order: + +1. **Owner disconnects** (7b) — the abort first, whose *final* step releases, so a + slot freed this pass is available to an admission decision in the same pass. + Release is never before the abort: a claim CAS can succeed the instant the word is + zeroed, and an abort still running after that (its ring resets included) would then + tear down the *new* session's state. +2. **Contender refusal, and the LAN accept** (7a). Admission itself is the hook-side + CAS (R2): for BLE it already happened — or failed — in the connect callback, so + this step only *refuses* live instances whose CAS failed. The LAN accept runs here + because the loop is its earliest hook, and its claim is the same CAS. No loop-side + rule picks a winner between transports; the word does. +3. **Inbound traffic**, which stamps the activity clock. +4. **Idle timeout** (7c) — last, so traffic parsed in step 3 counts. This is what + satisfies R4's ordering constraint for LAN. + +**The authoritative arbitration point is the earliest transport hook — the BLE connect +callback and the LAN accept — not the loop.** Fixed loop ordering cannot reconstruct +true cross-transport arrival order (a BLE connect during a refresh and a LAN socket +queued in the listen backlog are not comparable by the time `loop()` resumes), and it +must not be relied on for correctness. It resolves *ties within a pass* only; the claim +itself must be atomic at the callback — mechanically, the one-word owner CAS of R2. +Where the two disagree, the callback wins. + +#### 7e — Terminal transitions + +Per R6 exception 2. "Sync abort" = call `abortToKnownState(dropLink=false)` +synchronously before the transition. + +| # | Transition | Link handling | R6 abort | +|---|---|---|---| +| 1 | nRF DFU entry | `Bluefruit.disconnect()` + 100 ms, then SoftDevice disable | Exempt (or sync abort) — MCU jumps to bootloader | +| 2 | ESP32 DFU / reboot | BLE teardown, immediate restart | Exempt — MCU resets | +| 3 | Deep sleep (forced or idle) | `linkMarkTerminal()`, then sync abort, then `ble.end()` | **Sync abort — required, not exempt.** Not because state survives sleep (it does not; see below) but because sleep is a *mid-session exit* whose path otherwise hand-rolls a private teardown subset that drifts from the real one. The terminal gate must precede the abort — see the ordering trap below | +| 4 | Power-latch off | Power removed | Exempt — nothing survives | + +**Row 3 is resolved: deep sleep calls `abortToKnownState()`, and the reason is +teardown uniformity at a mid-session exit — not surviving state.** + +> **Corrected rationale (review, twice).** An earlier revision justified this row +> with "deep sleep is not a reset; touch-suspend, panel power and the owner token all +> survive it." That is false: ESP32 deep-sleep wake re-enters `setup()` and reloads +> RAM from the image — only `RTC_DATA_ATTR` state survives, as the boot code itself +> records ([main.cpp:129-150](../src/main.cpp)) — so the owner token, the +> touch-suspend counter and every transfer flag are rebuilt clean on wake. A second +> attempt justified it with lingering *hardware* state instead; also wrong against +> the tree: the sleep path already forces the panel rail off unconditionally before +> sleeping ([main.cpp:812](../src/main.cpp), `epdSessionForceOff()` at +> [display_service.cpp:420](../src/display_service.cpp)), touch is re-initialised on +> wake ([main.cpp:238](../src/main.cpp)), and deep-sleep pad hold is enabled only for +> the power-latch pin ([power_latch.cpp:59](../src/power_latch.cpp)). +> +> The real reason the abort is required: **deep sleep is a mid-session exit** — +> forced sleep bypasses the live-link guard ([main.cpp:789](../src/main.cpp)) and the +> path does not arbitrate a LAN owner, so it can begin with a transfer in flight — +> and its path already hand-rolls a private teardown (panel force-off, advertising +> stop, stack end, and now effect silencing). Without the abort, that private subset +> must be kept in sync with the real teardown forever, and every session resource +> added later must be added in both places — the same drift hazard that made the +> transfer watchdog an abort caller. Routing the session half through +> `abortToKnownState()` first makes sleep's teardown identical to every other +> session end by construction. + +The division of labour is exact, and mirrors the buzzer/LED rule: the abort runs +first and does *session* teardown only (ACTIVE-only panel handling, effects +untouched); the sleep path then does its own *sleep* quiescing — panel force-off +**including WARM** (no panel stays powered through sleep, which is why +`epdSessionForceOff()` stays in the sleep path and must never move into the abort) +and buzzer/LED silencing. The two compose; neither substitutes for the other. + +**One ordering trap, found by review: gate admission *before* the abort.** The +abort's final step releases the token, deep sleep passes `dropLink=false`, and the +owner's link can still be physically up — so between the release and `ble.end()`, a +connect on the host task could win the freed word, and the new owner would then be +destroyed by the stack teardown with no abort ever run for it. The deep-sleep path +therefore calls **`linkMarkTerminal()`** (R2) — an unconditional atomic exchange of +the owner word to the reserved `OWNER_TERMINAL` encoding, returning the displaced +owner identity — *before* `abortToKnownState()`, and hands that displaced identity to +the abort (after the exchange, reading the word yields terminal, not the departing +owner, so the abort must not re-derive it). Claims fail against any nonzero word, so +admission is impossible from that point; the abort's release, called with the +displaced identity, finds the word not matching and is naturally inert; wake reloads +RAM and the word starts clean. This also amends the plan's `dropLink=false` invariant: false means "no drop +wanted from the abort", satisfied either because the link is already gone or because +the stack is about to be torn down behind a terminal gate. + +*Interaction with the buzzer/LED carve-out (R6).* The abort deliberately leaves buzzer +and LED running, and deep sleep cuts the clocks they depend on — so at this one +transition the "let the effect finish" rationale cannot hold, because the effect +*cannot* finish. + +**Resolved: deep sleep silences both.** Of the two consistent options — make sleep +*wait* for a playing effect via the `workInFlight` gate ([main.cpp:694-699](../src/main.cpp)), +or *cut* the effect on the way down — this policy takes the second. Sleep is not delayed +by a playing effect; the effect is stopped immediately before the MCU sleeps. + +The deciding argument is hardware state, not policy symmetry. `enterDeepSleep` runs +`ble.stopAdvertising()` / `delay(200)` / `ble.end()` / `delay(100)` and then +`armButtonWakeSources()` and `powerLatchHoldForSleep()` +([main.cpp:806-836](../src/main.cpp)) — all outside `loop()`, so `buzzerService()` never +ticks during it. A tone left sounding is therefore *not* a melody finishing gracefully: +it is a driven pin held through the teardown and then into sleep, sounding continuously +and drawing current until the next wake. Letting sleep wait would merely delay that; +stopping is the only outcome that leaves the pins in the state sleep expects. + +**Three scoping rules this must not be over-generalised into:** + +1. **It lives in the deep-sleep path, never in `abortToKnownState`.** R6's carve-out is + unchanged: an idle, auth-abuse or watchdog drop still leaves a melody playing. +2. **It applies to deep sleep only, not to every terminal transition.** In particular + **power-latch off (row 4) deliberately *plays* a chirp on the way down** — + `passiveBuzzerPowerOffAlert()` is called immediately before `powerLatchTriggerOff()` + ([device_control.cpp:83](../src/device_control.cpp)). A blanket "silence at every + terminal transition" would delete that alert. +3. **Deep sleep is ESP32-only** (`enterDeepSleep` sits inside `#ifdef TARGET_ESP32`, + [main.cpp:757](../src/main.cpp)), so this adds no nRF obligation. + +*Implementation note.* Both stop routines exist but are file-static — +`buzzer_stop_internal()` ([buzzer_control.cpp:147](../src/buzzer_control.cpp)) and +`led_stop_internal(bool clear_mode)` ([device_control.cpp:347](../src/device_control.cpp)) — +so this needs two thin public wrappers. They are *sleep* APIs, not session-teardown APIs, +and nothing in the abort may call them. + +--- + +## What this changes + +Relative to the tree: + +1. No connection-level arbitration exists; it must be built (R1, R2). +2. ESP32 admits up to three centrals with no check, and a contender can corrupt the + incumbent's subscribe state, inject into its RX ring, and **receive its + notifications** (R3). +3. LAN accept evicts rather than refuses (R3, 7a row 7). +4. No BLE idle drop exists; LAN's exists but stamps on raw bytes and runs through + refreshes (R4). +5. Disconnect events carry no identity, and connect/disconnect events coalesce (R3). +6. **No *BLE* disconnect path clears the encryption session**, and LAN's clearing is + conditional and divergent — cleared at [wifi_service.cpp:798](../src/wifi_service.cpp) + only when `wifiClient.connected()`, and again on replacement at `:874`. Several + teardown paths are open-coded and drift-prone (R6). +7. No refresh start timestamp and no independent timebase exist for R5; the FastEPD + refresh path has no timeout bound whatsoever. +8. Queued frames carry no instance identity + ([command_queue.h:72-76](../src/command_queue.h)); the disconnect path compensates + with a captured RX boundary ([main.cpp:480-489](../src/main.cpp)), which R3 + requirement 6 replaces with per-frame tags. + +**Relative to `PLAN_FREEZE_HARDENING_2026-07-31.md`: reconciled.** That plan was revised +against this document and now schedules it rather than diverging from it; its +"Conformance" table maps each rule to the phase that builds it. The four points this +document previously superseded have been discharged in the plan — the `!transferActive()` +gate removed (R4), the owner token given a per-instance epoch (R2), the callback boundary +extended to identity-bearing *disconnect* events plus subscribe/notify filtering and the +instance table (R3), and the drop made synchronous (R3a). + +Two rules remain unscheduled and are named as such in the plan rather than absorbed: +**R5** (refresh watchdog) is out of scope and recorded under its residual risk, with the +unbounded FastEPD path called out; **R7e** (terminal transitions) is covered only for +deep sleep, which the plan adds to its abort invocation set — the other three rows keep +the exemption R6 grants them. + +This ordering does not change: where the two disagree, this document still wins. + +## Open questions + +- **The R3a wait bound** — the one unset number in this policy, and the least + load-bearing. It is a wait bound rather than a recovery deadline: expiry is an early + exit into an abort that runs regardless, and R2's epoch makes the stale link inert. + Wants to cover a few connection intervals with margin — tens to low hundreds of + milliseconds — not a supervision timeout. Everything else here is settled; this can be + picked at implementation time without reopening the policy. + +**Settled — the timeout values.** Both second-denominated timeouts are decided, and +neither is gated on a measurement before implementation: + +- **BLE, `OD_BLE_IDLE_TIMEOUT_MS` = 120 s.** A chosen value, not a measured one, set + deliberately generous because R4 inverted the direction of the error: with the transfer + gate gone, erring short costs a legitimate upload rather than a stale session. The + accepted cost is a returning client waiting up to 120 s behind a stale-but-*alive* + incumbent; a client that is genuinely gone is reaped by the link layer at ~4–6 s, so + the lockout never applies to it. +- **LAN, `OD_LAN_READ_TIMEOUT_S` = 30 s, unchanged.** It already satisfies R4's substance + — it is ungated by transfer state — so R4 changes only its *semantics*: stamp on a + recognised command rather than on raw bytes read, and exclude refresh. Its *value* is a + client-visible wire-header contract and is out of bounds here regardless. + +The asymmetry between them is deliberate and follows from where each is allowed to live. +What remains is drift detection in `py-opendisplay`'s CI, not verification of the numbers +— a client change that pushed legitimate inter-command silence toward either value would +fail there. + +**Resolved — deep sleep vs the buzzer/LED carve-out** (7e row 3). Deep sleep **silences +both**, in the deep-sleep path and never in the abort; sleep is not delayed by a playing +effect. The rationale is hardware state — `buzzerService()` does not tick during +`enterDeepSleep`, so a tone left on sounds continuously into sleep rather than finishing +— and the scoping (abort unchanged, power-latch off keeps its chirp, ESP32-only) is +recorded at 7e row 3. + +**Resolved since the first draft — event delivery.** The first draft required +"non-coalescing event delivery" and left the queue's overflow behaviour as an open +question. There is no queue: R3 requirement 5 now specifies a fixed per-handle instance +table that the loop scans, so there is nothing to overflow and no eviction policy to +decide. See also the one-command-queue constraint at the top of this document. + +**Resolved since the first draft:** `checkTransferTimeouts()` **does** route through +`abortToKnownState(dropLink=true)`. R6 governs disconnects and the watchdog is not one, +so this is an extension of R6's *teardown* to a non-disconnect trigger rather than a +consequence of it: there is one teardown routine and the watchdog uses it. The +rationale, the three behaviour changes it brings, and the one branch deliberately left +out (the orphaned-pipe invariant repair) are recorded in the freeze-hardening plan's +invocation set. diff --git a/docs/PLAN_FREEZE_HARDENING_2026-07-31.md b/docs/PLAN_FREEZE_HARDENING_2026-07-31.md new file mode 100644 index 0000000..5467fef --- /dev/null +++ b/docs/PLAN_FREEZE_HARDENING_2026-07-31.md @@ -0,0 +1,1650 @@ +# Freeze-Hardening the OpenDisplay Firmware — 2026-07-31 + +A self-contained four-phase plan for the BLE e-paper firmware, written from the code +as it stands on `fix/nonce-replay-window` (last code commit `9ca1d8f`, rebased onto the +squashed `main` at `aae5bdf`; every commit after it on this branch is docs-only, so the +citations below still describe the tree). + +Every claim below was verified by direct reading of the current tree and is cited to +`file:line` so a reviewer can re-check rather than trust. The loop/BLE unification +(PR `#132`) and the nonce rewrite (this branch) both landed recently and changed the +shape of several subsystems, so nothing here is taken on inherited assumption — the +ground truth is re-established from scratch below. + +## Conformance with `CONNECTION_POLICY.md` + +[`CONNECTION_POLICY.md`](CONNECTION_POLICY.md) is the **normative** ruleset for +connection behaviour: it defines what must be true. This plan **schedules** it — when +each rule is built, on which mechanism, and how it is verified. Where the two disagree +the policy wins, and this revision exists to remove the disagreements: the policy's +"supersedes" list is discharged below rather than left as a standing conflict. + +| Policy rule | Lands in | What this revision changed | +|---|---|---| +| **R1** one admitted client, globally | Phase 3 | unchanged in substance | +| **R2** identity is `(transport, handle, epoch)` | Phase 2 | the owner token gains an **epoch**, allocated in the connect callback for *every* instance; it was a `(transport, handle)` pair | +| **R3** a contender is refused, and refusal is inert | Phase 2 (mechanism) + Phase 3 (policy) | adds the **per-handle instance table**, **identity-bearing disconnect** events, per-link **subscribe** filtering and **handle-targeted notify** — the plan previously had only handle-bearing *connect* events and write filtering | +| **R3a** a firmware-initiated drop waits for link-down | Phase 2 | the seam **waits synchronously** for the link to go down before the abort releases; the plan previously released at request time | +| **R4** idle timeout, ungated by transfer state | Phase 2 (clock) + Phase 3 (policy) | the clock stamps a **recognised command from the current owner**, not any queued frame, and is re-stamped by a single `endRefresh()` helper | +| **R5** refresh watchdog | **out of scope**, named | recorded under [residual risk](#residual-risk-honest-list); the FastEPD refresh path has no bound at all, which the plan did not previously say | +| **R6** abort on every non-refused disconnect | Phase 2 | the invocation set gains **deep sleep** (R7e row 3) and states R6's exceptions | +| **R7d** within-pass ordering | Phase 3 | new: the loop order is normative, not incidental | + +Two rules cost nothing to schedule because they are already satisfied: R6's buzzer/LED +carve-out and its WARM-panel survival are the design `abortToKnownState` already had, +and R4's no-transfer-gate was reconciled in the previous revision. + +> **Revision 2026-07-31b (external review).** An adversarial review of this plan and +> the policy found three defects that would have surfaced mid-implementation, corrected +> in both documents: (1) the owner token was loop-task-only while callback-side write +> filtering needed to read it on the host task — the token is now a single atomic word +> claimed by CAS at the earliest transport hook, and the epoch narrows to 16 bits so +> the word stays lock-free; (2) `abortToKnownState` step 10 dropped a BLE handle +> unconditionally, which is wrong for a LAN owner (the transfer watchdog is +> origin-agnostic) — the drop now dispatches on the owner's transport; (3) +> `bleDropAndWait()` polled the aggregate `connectedCount()`, which never reaches zero +> while a refused contender is attached — the predicate is now the owner's +> instance-table entry, ticked on a plain bounded delay rather than `idleDelay()`, +> whose event early-out degrades into a busy spin mid-teardown. + +> **Revision 2026-07-31c (same review, second batch).** Three further findings shared +> one root cause — queued frames are anonymous — and are fixed together by +> CONNECTION_POLICY R3 requirement 6: every queued frame now carries its writer's +> packed instance-identity word, stamped in `onWrite` from the same owner-word load +> the write filter already does, and the dispatcher executes a frame only if its tag +> still equals the owner word. That one mechanism closes the teardown window (the +> departing owner writing during its own abort), dissolves the boundary-lost-to- +> handle-reuse hazard, and makes the activity clock's "from the owner" test true +> instance identity instead of transport-only. It **retires the RX-boundary +> mechanism** — `s_rxBoundaryAtDisconnect`, `takeDisconnectedEvent`'s boundary +> out-param, and `bleRxQueueDiscardTo` all go — and the abort's step 9 now resets +> both rings, not just TX. + +> **Revision 2026-07-31d (closing the review).** The remaining findings, corrected in +> both documents: 7a gains rows 9–10 and the admission-decided-once rule (racing +> arrivals are serialized by the claim CAS; a lingering refused contender never +> inherits a freed slot); the deep-sleep abort's rationale is rewritten — neither RAM +> nor hardware state survives in a way that needs it, so the abort stands on teardown +> uniformity at a mid-session exit; the auth-abuse `FE` is best-effort (stack +> acceptance plus a bounded negotiated-interval dwell, not guaranteed receipt); and +> three miscited lines are fixed +> (`sessionOrigin` stamps, dispatcher rejection sites, the Bluefruit `disconnect()` +> signature). + +## Phase map + +| # | Phase | Depends on | State today | +|---|---|---|---| +| 1 | Nonce / replay correctness | — | **Shipped** on this branch (`e2e95cd`…`19335e6`) | +| 2 | BLE-HAL foundation: link-drop seam (with the R3a wait), instance identity + owner token, the instance table, callback-side filtering, frame identity tags, activity clock, abort-to-known-state — **plus contender refusal, moved here from Phase 3** | — | **Implemented** on `feat/phase2-ble-hal-foundation` (`dbec776`, `bb7ad1d`); landed, not closed | +| 3 | Idle drop + the remaining exclusivity policy | Phase 2 | **Implemented** on `feat/phase3-exclusivity-idle-drop`; landed, not closed | +| 4 | Auth-abuse disconnect | Phase 2, Phase 3 | **Implemented** on `feat/phase4-auth-abuse-disconnect`; landed, not closed | + +> **Refusal moved from Phase 3 to Phase 2 during implementation.** Phase 2 is not +> safely shippable without it, so the split as originally drawn was wrong rather +> than merely inconvenient. Admission is decided once per instance and never +> revisited (7a row 10), so a client that reconnects into a still-held slot — the +> ordinary case when `loop()` was blocked in a refresh — becomes a permanent +> contender; on nRF it occupies the only peripheral link and the device stops +> accepting anyone until that client happens to leave. The two alternatives were +> both worse and both were tried: releasing the token in the disconnect callback +> admits a new owner while the departed session's transfer, crypto and TX ring are +> still live, and skipping the refusal scan while the slot is unowned leaves a +> decided loser attached forever. +> +> What stayed in Phase 3: the idle timeout and every other path that reclaims a +> *held* slot. Refusal only makes the "decided once" rule true; it never evicts. +> LAN accept also became refuse-not-evict here for the same reason (its eviction +> path could strand the token until reboot). + +**Phase order note.** Phase 2 is the foundational layer: every transport/HAL +*mechanism* the later phases stand on — the portable `disconnect()`, connection instance +identity and the owner token, the instance table with callback-side write/subscribe/notify +filtering, the activity clock, and the shared abort routine. Phase 3 is **policy** on top of those +mechanisms (when to refuse and when to drop); Phase 4 is the auth-abuse policy. Phase 2 +lands first because 3 and 4 both call into it — building the foundation last (as an +earlier draft did, with exclusivity as Phase 2) created a dependency cycle, since the +idle drop calls the abort routine. + +**Two cross-phase deliverables** thread through Phases 2–4 and are specified once +here rather than repeated: + +- **Threshold discipline — at the point of use, not in a new header.** Every tunable + this plan introduces (the R3a link-down wait bound, the idle-drop timeout, the + auth-abuse count and its flush deadline) is a compile-time `#ifndef`-guarded + `#define` **in the file that consumes it**, each carrying a comment naming the + *client behaviour it assumes*. No threshold is a wire/config field, so none touches + the hard constraint. + + The four differ in how load-bearing they are, and the comments should say so. + `OD_BLE_IDLE_TIMEOUT_MS` carries the most weight (it is the sole reclaim path, and + under R4 it can end a live upload); `OD_BLE_LINK_DOWN_WAIT_MS` carries the least — + per CONNECTION_POLICY R3a its expiry is not a failure needing recovery, just an + early exit into an abort that runs regardless. + + This follows the repo's existing convention rather than inventing one. The model is + [wifi_service.cpp:470-472](../src/wifi_service.cpp): + + ```c + #ifndef OD_LAN_ROAM_RSSI_THRESHOLD + #define OD_LAN_ROAM_RSSI_THRESHOLD (-75) /* dBm; valid range -100..10 */ + #endif + ``` + + and likewise `OD_TINFL_DICT_SIZE`, `OD_CHARGER_FLAG_*`, `OD_LOG_LEVEL` + ([od_log.h:16-18](../src/od_log.h)); `TRANSFER_WATCHDOG_MS` is a plain `static const` + in [display_service.cpp:582](../src/display_service.cpp). There is no central + tunables header in this repo and this plan does not add one. + + *An earlier draft specified a `src/session_policy.h` collecting all four.* It was + cut. It would have been the only file of its kind, and it groups by **type** + ("these are all thresholds") rather than by dependency: the four are consumed by two + unrelated subsystems — the idle drop by the loop-side policy helpers, auth-abuse by + `communication.cpp` — so the header buys a new include edge shared by two callers + that need nothing else from each other. The goal behind it was that the assumptions + be legible rather than bare numbers; that is served by the mandatory + client-behaviour comment, which reads *better* next to the code that acts on it, and + by the client-side CI assertions below. If a shared home is ever genuinely needed, + `structs.h` is the existing common hub. + + **They do not go in the BLE transport headers either.** These are policy, and + Phase 2 is mechanisms-only by construction. This is settled precedent here, in the + same direction: [ble_transport.h:89-93](../src/ble_transport.h) records that the + loop-serviced deferred-work flags were *moved out* of the transport because they + "encode application policy, not link state, so exporting them from the transport + seam was backwards." The same reasoning puts the activity clock beside the owner + token rather than in the transport; deciding how long is too long belongs to the + loop-side policy code that Phase 3 adds. +- **A companion HIL test per phase**, under `tests/`, following the existing + `tests/serial_stall_test.py` pattern (pytest driving a real board through + `py-opendisplay`). These *are* the Verification sections — versioned with the + code, not prose. See [Verification model](#verification-model) below. + +## Hard constraint — NO wire protocol change + +`include/opendisplay_protocol.h` must not change, and no new opcode or response +code may be added. Verified for every phase below: dropping a link, refusing a +connection, and idle teardown are all HCI-level (a disconnect *reason* byte, not +an app-protocol field); `RESP_AUTH_REQUIRED` already exists and is used in its +documented meaning. If any phase turns out to need a wire change it stops and the +change goes through `../opendisplay-protocol` first. + +--- + +## What the current code actually does (ground truth) + +Established by direct reading of the tree, 2026-07-31. These are the facts the +phases build on; each is cited so a reviewer can re-check rather than trust. + +### Connection model is asymmetric and, on ESP32, unguarded + +- **nRF** caps at one central in hardware: `Bluefruit.begin(1, 0)` + ([ble_transport_nrf.cpp:164](../src/ble_transport_nrf.cpp)). The SoftDevice + refuses a second central at the link layer. Advertising re-arms itself + (`restartOnDisconnect(true)`, `:210`). +- **ESP32** allows **three** centrals: `CONFIG_BT_NIMBLE_MAX_CONNECTIONS = 3` is + baked into the precompiled NimBLE framework and a `-D` override is inert (the + precompiled `sdkconfig.h` wins). `onConnect` + ([ble_transport_esp32.cpp:81-93](../src/ble_transport_esp32.cpp)) does **no** + count check and **no** rejection; a second central's handle simply **overwrites** + the single scalar `s_connHandle` (`:87`), and its writes land in the same RX ring + undistinguished. This is a live multi-central exposure, not a hypothetical. +- **Every piece of per-link state on ESP32 is a global scalar any central can move**, + which is why CONNECTION_POLICY R3 needs six requirements at the callback and + dispatch boundary rather than one. Besides `s_connHandle`: `s_notifySubscribed` is set by whichever central + subscribed last (`onSubscribe` discards its `connInfo`, `:129`), and `onWrite` + discards its `connInfo` too (`:135`), so a contender's frames enter the incumbent's + RX ring. +- **Notifications go to every subscribed client — a live leak, present today.** + `BleTransport::notify` calls `s_txCharacteristic->notify(data, len)` + ([ble_transport_esp32.cpp:269-277](../src/ble_transport_esp32.cpp)), the two-argument + overload. NimBLE's third parameter defaults to `BLE_HS_CONN_HANDLE_NONE`, documented + as "send the notification to **all subscribed clients**." A second central that + connects and subscribes therefore receives every response the incumbent is sent, + including authentication traffic, before `loop()` runs at all and with no policy + decision having been made. Fixing it is a one-argument change (Phase 2). +- **Connect and disconnect events coalesce**, and their side-band data is single-slot. + Both are plain `volatile bool` + ([ble_transport_esp32.cpp:33-34](../src/ble_transport_esp32.cpp)) and the header + records the weakness itself: "a second same-type event arriving inside the + check-then-clear window is lost" ([ble_transport.h:66-71](../src/ble_transport.h)). + `s_disconnectReason`, `s_rxBoundaryAtDisconnect` and `s_connHandle` are each one + slot, so each event overwrites the last. Harmless today — `serviceBleEvents()` + decides nothing per-connection ([main.cpp:461-500](../src/main.cpp)) — and a + correctness problem the moment each event drives an admission decision. +- **LAN** is single-client, last-in-wins: a second TCP accept evicts the first + ([wifi_service.cpp:871-877](../src/wifi_service.cpp)). +- **BLE and LAN can both be live at once.** There is no connection-level + arbitration. The only ownership is per-*transfer*: `sessionOrigin`, stamped at + transfer START ([display_service.cpp:2159,2200,2712](../src/display_service.cpp)), + enforced per-frame by `frameOwnsSession()` and per-disconnect by + `serviceBleDisconnectCleanup()`. + +### No application code can drop a BLE link through the transport + +- `BleTransport` ([ble_transport.h](../src/ble_transport.h)) exposes **no** + `disconnect()`. `end()` is a full-controller teardown, and a no-op on nRF. +- ESP32 captures the conn handle (`s_connHandle`, `ble_transport_esp32.cpp:87`) + but **never calls** `NimBLEServer::disconnect()`. The capability is one line + away and unused. +- nRF has exactly one host-initiated disconnect in the whole firmware — + `Bluefruit.disconnect(Bluefruit.connHandle())` + ([device_control.cpp:857](../src/device_control.cpp)), inside DFU entry, reaching + past the abstraction into Bluefruit directly. **Bluefruit's public `disconnect()` + takes only a handle and always sends reason 0x13 — there is no reason argument to + honour** — a fact the seam design + below has to respect. + +### No stall detection reaches a hung `loop()` + +- nRF has **no watchdog at all** ("every fault handler is `b .`", + [od_log.h:40](../src/od_log.h)). +- ESP32's `loop()` is **not** subscribed to the task WDT: Arduino leaves + `loopTaskWDTEnabled = false` and nothing here calls `esp_task_wdt_add()` for the + loop task, so `loop()` is unsupervised. (Whatever `CONFIG_FREERTOS_WATCHDOG_TIMEOUT_S` + is set to is immaterial — no framework code arms a loop watchdog from it.) +- The only wall-clock teardown is `checkTransferTimeouts()` + ([display_service.cpp:584-638](../src/display_service.cpp)), and it measures total + elapsed from transfer **START** — 15 minutes (`TRANSFER_WATCHDOG_MS = 900000`). It + is a total-duration bound, **not** a stall/inactivity timeout: a transfer that + stalls at minute 1 is still not torn down until minute 15, and a slow-but- + progressing transfer is cut off at 15 minutes regardless of progress. +- **The refresh BUSY-wait is bounded on one path only.** On `bb_epaper`, + `waitforrefresh(60)` loops `timeout * 100` times at 10 ms and then fails + ([display_service.cpp:803-831](../src/display_service.cpp)). **On FastEPD there is no + bound whatsoever**: `waitforrefresh()` delegates to `fastepd_wait_refresh()` (`:805`), + which ignores its timeout argument outright — `(void)timeout_sec; return + !s_init_failed;` ([display_fastepd.cpp:277-280](../src/display_fastepd.cpp)) — and the + real blocking lives above that call, inside `fullUpdate()`/`fastUpdate()`. This is + CONNECTION_POLICY R5's exposure, and it is out of scope here; see residual risk. + +### An idle connected client is never dropped + +- `pollActivity()` stamps `lastActivityMs` whenever `connCount > 0` + ([main.cpp:366](../src/main.cpp)) — a live link is treated as activity in + itself. So a client that connects, authenticates, and goes silent holds the + device out of its idle path **forever**. +- `session_timeout_seconds` ([encryption.cpp:254-265](../src/encryption.cpp)) + measures from session START not last activity, clears the *session* but **not** + the *link*, and is only evaluated when a command arrives — so it never fires on + a silent client. It defaults to 0 (disabled). +- There is **no** BLE idle link-drop. LAN has one (`OD_LAN_READ_TIMEOUT_S = 30`, + [wifi_service.cpp:952](../src/wifi_service.cpp)); BLE has no equivalent. + +### State with no disconnect-time reset (Phase 2 surface) + +Confirmed missing or open-coded, i.e. what an abort must newly cover: + +- `encryptionSession` — **not** cleared on BLE disconnect. Crypto state survives a + link drop. `clearEncryptionSession()` runs on session-timeout-at-command, a new + auth, config reload ([communication.cpp:66](../src/communication.cpp)), and LAN + teardown ([wifi_service.cpp:798,874](../src/wifi_service.cpp)) — but no BLE + disconnect path is among them. +- `chunkedWriteState` (config chunked upload, + [config_parser.h:47](../src/config_parser.h)) — **no reset function**; cleared + only by open-coded inline assignments in `communication.cpp`, untouched by + disconnect and by the watchdogs. +- The response TX ring — **no** flush/discard primitive (only `bleRxQueueDiscardTo` + exists, RX side). +- The RX ring is **anonymous** — `CommandQueueItem` is `{data, len, pending}` + ([command_queue.h:72-76](../src/command_queue.h)); nothing records which link a frame + came from. The disconnect path compensates with a boundary captured at link-down + ([ble_transport_esp32.cpp:105](../src/ble_transport_esp32.cpp)) and discarded to on + the loop ([main.cpp:480-489](../src/main.cpp)) — a mechanism Phase 2 retires + (requirement 6 below). +- `directWriteTouchSuspended` — reset only *inside* `cleanupDirectWriteState()`, so + a teardown routed through the partial path can leave touch suspended. +- Buzzer and LED — serviced each loop pass, and **no session-teardown stop API exists. + The abort deliberately does not add one**; see the carve-out in `abortToKnownState` + below. Both are bounded and self-terminating — the buzzer's `outer` repeat count is a + `uint8_t` coerced to at least 1 and playback calls `buzzer_stop_internal()` at + `rep >= outer` ([buzzer_control.cpp:215-217,288-291](../src/buzzer_control.cpp)); the + LED runs a stepped pattern to completion + ([device_control.cpp:530-541](../src/device_control.cpp)). Neither can run forever, so + neither is state a later connection can inherit. + + **The stop routines themselves exist but are file-static**, which matters for the one + caller that does need them: `buzzer_stop_internal()` + ([buzzer_control.cpp:147](../src/buzzer_control.cpp)) and `led_stop_internal(bool + clear_mode)` ([device_control.cpp:347](../src/device_control.cpp)). Deep sleep must + silence both (7e row 3), so Phase 2 adds two thin **public wrappers** — sleep APIs, not + teardown APIs. Nothing in the abort may call them. + +--- + +## Phase 1 — Nonce / replay correctness ✅ SHIPPED + +Shipped on this branch (`e2e95cd`…`19335e6`), recorded here for completeness. What +landed: + +- The AES-CCM anti-replay state moved from a 512 B ring of raw counter values to a + 32 B sliding bitmap (`src/nonce_window.h`, a dependency-free pure state machine). +- Check split from commit: `nonceCheck()` decides and writes nothing; `nonceCommit()` + runs only *after* the CCM tag verifies. So packet loss is no longer counted as + tampering, and an unauthenticated peer cannot advance replay state. +- The forward-distance cap was **removed** and comparison made numeric, not modular: + a counter ahead of `last_seen` is accepted at any distance (the tag is the gate), + which fixed a cliff where a forward gap past the cap stranded the session + unrecoverably. A consumed counter is still never re-accepted (`last_seen` only + moves up; below it, bitmap-caught or rejected on width). + +**Verified:** host suite 47,445 checks under `-Werror`+ASan/UBSan (and 1,635 +failures against the pre-change code, proving the tests discriminate); +`nrf52840custom`, `esp32-c3-N16`, `esp32-N4` build. +**Not verified:** the entire hardware matrix. + +Nothing in Phase 1 is reopened here. One carry-forward: an **auth-abuse disconnect** +was prototyped alongside the nonce work on a separate branch +(`feat/nonce-replay-and-auth-guard`) but is **not** on this branch, and is redesigned +fresh as Phase 4. + +--- + +## Phase 2 — BLE-HAL foundation (mechanisms) + +**Goal:** every transport/HAL *mechanism* the later phases build on — the portable +link-drop seam and its R3a wait, connection instance identity and the owner token, the +instance table with callback-side write/subscribe/notify filtering, the activity clock, +and the idempotent abort-to-known-state routine. No *policy* lives here (Phase 3 decides +when to refuse and when to drop); Phase 2 only makes each action possible and each fact +observable. + +**Phase 2 already closes two live holes on its own**, before any Phase 3 policy exists: +the notify leak (a second central receiving the incumbent's responses) and command +injection into the incumbent's RX ring. Both are callback-side filtering, and neither +waits on an admission decision. If Phase 3 slips, these should still land. + +### The link-drop seam — `BleTransport::disconnect(uint16_t handle)` + +Add to the abstraction ([ble_transport.h](../src/ble_transport.h)) and implement per +target. It takes an explicit **handle**, not just "the current connection", because +Phase 3's admission needs to drop a *specific* link; pass the current handle for the +common case. + +- **ESP32:** `s_server->disconnect(handle, BLE_ERR_REM_USER_CONN_TERM)`. Return the + call's bool; log WARN on failure. Note the library already treats "the link is + gone" as success — `NimBLEServer::disconnect` returns `true` for `BLE_HS_ENOTCONN`, + `BLE_HS_EALREADY` and `UNK_CONN_ID` (`NimBLEServer.cpp:321-332`), so a WARN here + means a genuine failure, not a benign race with a client that left first. +- **nRF:** `Bluefruit.disconnect(handle)`. Lift the pattern from + [device_control.cpp:857](../src/device_control.cpp) but keep + `restartOnDisconnect(true)` (unlike DFU, which disables it). + +**Reason fixed at 0x13, and the seam hard-codes it.** A host-initiated disconnect +must use a Core-Spec-legal `HCI_Disconnect` reason. `BLE_ERR_REM_USER_CONN_TERM` +(**0x13**) is legal; `BLE_ERR_CONN_LIMIT` (0x09) is **not**, and the controller +silently rejects it (0x12) — the gatecrasher stays connected while the code looks +like it worked. The stacks are asymmetric, and both were read rather than assumed: + +- NimBLE takes a reason and *defaults it to 0x13* — + `disconnect(uint16_t connHandle, uint8_t reason = BLE_ERR_REM_USER_CONN_TERM)` + (`NimBLEServer.h:66`), forwarded to `ble_gap_terminate`. +- Bluefruit takes **only a handle** — `AdafruitBluefruit::disconnect(uint16_t conn_hdl)` + (`bluefruit.h:171`) delegates to `BLEConnection::disconnect(void)`, which calls + `sd_ble_gap_disconnect(_conn_hdl, BLE_HCI_REMOTE_USER_TERMINATED_CONNECTION)` + (`BLEConnection.cpp:206`). There is no reason parameter to pass, let alone one to + honour. + +So the seam exposes no `reason` parameter: 0x13 is the only value this plan wants, +the value NimBLE already defaults to, and the only value nRF can send. Both stacks +do take a **handle**, which is what the seam's signature carries. + +**Also fix the inbound reason, which currently lies (ESP32).** Not a new feature — +a correctness fix to what is already logged. `s_disconnectReason` is a `uint8_t` +([ble_transport_esp32.cpp:35](../src/ble_transport_esp32.cpp)) assigned from +NimBLE's `int reason` with a truncating cast (`:99`). NimBLE uses two ranges: HCI +reasons wrapped as `BLE_HS_ERR_HCI_BASE + code` (`0x200 + code`), and host-layer +`BLE_HS_E*` codes in `1..31`. The cast keeps only the low byte, so an HCI reason +survives by luck (`0x213 & 0xFF == 0x13`) while `BLE_HS_ENOTCONN` (7) truncates to +`0x07` and reads back as the unrelated HCI "memory capacity exceeded". The log at +[main.cpp:472](../src/main.cpp) then prints it as decimal `%u`, so the two collide +on screen as well as in storage. nRF is unaffected — it stores a raw HCI `uint8_t` +from the SoftDevice with no wrapping ([ble_transport_nrf.cpp:38,135](../src/ble_transport_nrf.cpp)). + +Fix: widen `s_disconnectReason` and `takeDisconnectedEvent`'s reason out-param to +`uint16_t` ([ble_transport.h:81](../src/ble_transport.h), one caller at +[main.cpp:471](../src/main.cpp)), drop the cast, and log `0x%03X` so a wrapped HCI +reason (`0x213`) and a host reason (`0x007`) are visibly distinct. No enum, no +classifier — just stop discarding half the value. (The *other* out-param, `rxBoundary`, +is retired outright by requirement 6 — the boundary mechanism it fed is superseded by +frame tags.) + +*Deferred, deliberately:* normalizing the inbound reason into an `OdDiscReason` +enum (`SUCCESS / REMOTE / LOCAL / TIMEOUT / MIC_FAILURE / OTHER`). Nothing in +Phases 2–4 branches on *why* a link dropped — the abort runs the same teardown +regardless, and a self-initiated drop is identified by its `*DropPending` flag, not +by reading the reason back. The classifier would feed a log line and nothing else. +The likely first real consumer is MIC-failure handling (0x3D signals encryption +desync); when that lands it is a small header and a `switch`, and the `uint16_t` +raw value preserved here is exactly its input, so nothing above has to be redone. + +All disconnect calls are made from the **loop task** (a `serviceBleLinkDrop` hook, or +inline in the loop-serviced helpers), never a stack callback — a callback that severs +its own link mid-dispatch is exactly the class of bug `#132` removed. + +#### The drop waits for link-down (CONNECTION_POLICY R3a) + +**`disconnect()` requests termination; it does not perform it.** `NimBLEServer::disconnect()` +returns true even for `BLE_HS_ENOTCONN`/`BLE_HS_EALREADY` (`NimBLEServer.cpp:321-332`), +so a true return means "requested," not "down." An earlier draft of this plan released +the owner token at request time, which would let a new connection be admitted while the +old link was still physically up. R3a supersedes that: the drop is **synchronous** — the +seam requests termination, then waits cooperatively and with a bound until the link is +actually down, and only then does the abort release the slot. + +So the seam is not the bare call but a small helper beside it: + +``` +bool bleDropAndWait(uint16_t handle); // request + bounded cooperative wait; true if down +``` + +Three properties of the current tree make this the simple form it looks like, and R3a +records the investigation that rejected a cross-pass `DROPPING` state as more machinery +than the problem needs: + +- **Link-down is per-handle pollable without consuming the event.** The disconnect + callback writes the departing instance's table entry (requirement 5 below) at the + moment the link drops, so the wait's predicate is "the owner's `(handle, epoch)` + entry is no longer live" — a scan of the instance table. **The aggregate + `connectedCount()` must NOT be the predicate**: it is the stack's total peer count + on both targets — `s_server->getConnectedCount()` + ([ble_transport_esp32.cpp:257-259](../src/ble_transport_esp32.cpp)), + `Bluefruit.connected()` ([ble_transport_nrf.cpp:235-239](../src/ble_transport_nrf.cpp)) — + and R1 explicitly permits a refused contender to be transiently attached, so dropping + the owner moves the count 2→1, never to 0, and the wait would sit out its full bound + on a link that is already down. The disconnect *event* stays queued for + `serviceBleEvents()` ([main.cpp:461-500](../src/main.cpp)) to consume on its normal + path — the wait neither consumes nor reorders it. (What survives of that path is the + event flow, flag and reason; its RX-boundary capture is retired by requirement 6.) +- **The wait ticks on a short plain `delay()`, not `idleDelay()`.** An earlier draft + named `idleDelay()` the right primitive for its early-out on `ble.eventPending()` + ([main.cpp:749](../src/main.cpp)). That early-out is exactly wrong here: the + predicate is table state, not event arrival, and mid-teardown events are deliberately + left unconsumed — so once any event is pending (the owner's own disconnect, or an + unserviced contender's connect), every `idleDelay()` call returns immediately and the + wait degrades into a busy spin for its remaining bound. A plain `delay(2)` tick + services *neither* RX nor transport events — the safety property actually wanted — + and costs a few milliseconds of latency against a bound sized in tens of them. +- **The epoch makes an expired wait harmless.** If the bound expires with the old link + still up, that link is inert by construction: its writes are filtered as non-owner, + and its late disconnect is inert on stale epoch (7b rows 4 and 9). This is why the + bound is the least load-bearing threshold in the plan. + +*The wait can never land inside a refresh.* Every caller is loop-task-only and already +deferred while `epdRefreshInProgress` ([main.cpp:389](../src/main.cpp)). + +*Timing.* An alive peer terminates within a few connection intervals — tens of ms; the +firmware requests no interval, so the central's negotiated value applies. A peer already +gone is reaped by the link layer at ~4–6 s. So `OD_BLE_LINK_DOWN_WAIT_MS` wants to cover +a few connection intervals with margin — tens to low hundreds of ms — not a supervision +timeout. It is deliberately *not* sized against the 120 s idle timeout. + +### Connection instance identity and the owner token + +A tiny arbiter, one new translation unit (`src/link_owner.h/.cpp`) or folded into +`communication.cpp`. **Identity is the triple `(transport, handle, epoch)`**, per +CONNECTION_POLICY R2 — an earlier draft of this plan used a `(transport, handle)` pair, +which R2 supersedes: + +``` +enum LinkOwner { OWNER_NONE, OWNER_BLE, OWNER_LAN, OWNER_TERMINAL }; +struct LinkId { LinkOwner who; uint16_t handle; uint16_t epoch; }; + +// The token itself is ONE 32-bit word: [31:30] transport | [29:16] handle | [15:0] epoch. +// All-zero == unowned; epoch 0 is never allocated; 0xC0000000 (transport 0b11, +// handle 0, epoch 0) == OWNER_TERMINAL, the deep-sleep admission gate. +uint16_t linkNextEpoch(void); // __atomic_fetch_add; connect callback, EVERY instance +bool linkClaim(LinkId id); // one CAS on the word; safe from stack callbacks +void linkRelease(LinkId id); // CAS holder -> NONE; loop task only, after R3a's + // wait; full-identity match, so it can never zero + // the terminal word (and never accepts it as id) +LinkId linkMarkTerminal(void); // atomic exchange -> OWNER_TERMINAL, returning the + // DISPLACED owner identity (possibly NONE) — the + // identity the terminal caller hands the abort; + // deep-sleep path only, BEFORE the abort (R7e row 3) +LinkId linkOwnerId(void); // one atomic load; callable from ANY task +bool linkIsOwner(LinkId id); // full-triple comparison; handle alone is never enough +``` + +The token is **connection-level**: at most one transport-and-link owns the session at +a time. `OWNER_LAN` uses handle 0 (single TCP client by construction); `OWNER_BLE` +carries the conn handle, which the arbiter records — authoritative "who owns the link", +separate from the transport's `s_connHandle` scalar that the newest connect overwrites. + +**Why the epoch, and where it is allocated.** BLE conn handles are small integers the +stack reuses — NimBLE allocates from 0 upward, so a client that disconnects and +reconnects can be handed the *same* handle. This firmware defers work by design, and +`serviceBleDisconnectCleanup` can run tens of seconds late when `loop()` was blocked in +a refresh, a hazard the code already documents at +[main.cpp:398-403](../src/main.cpp) — so a deferred operation carrying a stale handle can +otherwise match a newer session and act on it. The epoch turns "same handle" into "same +connection instance," which is what every deferred consumer actually needs. + +**The epoch is allocated in the connect callback, for every connection instance, +admitted or not** — never on successful claim. This is the trap R2 calls out explicitly: +a *refused* contender never claims, so on claim-time allocation it would carry no epoch, +and 7a row 4 (a contender reusing the incumbent's handle after a stale link) could not be +distinguished from the incumbent at all. Allocation must precede the admission decision, +because the identity is what the decision is *made on*. On admission the token copies the +instance's already-allocated epoch. + +Scope is one boot — no deferred RAM state survives a reset, so cross-reset uniqueness is +neither required nor claimed. The epoch is **16 bits, a deliberate narrowing** (an +earlier draft had `uint32_t`): the one-word token below must stay lock-free, and neither +Cortex-M4 nor the ESP32 ISAs have a lock-free 64-bit CAS, so the triple packs as +`transport(2) | handle(14) | epoch(16)` — HCI conn handles are spec-bounded at 0x0EFF, +so 14 bits holds them with headroom. The invariant that justifies the width (per R2's +wrap rule, where the full conditional argument lives): no outstanding event may survive +a full counter cycle. Epochs churn at link-layer connection rate — tens of ms per +instance — so a full 2^16 cycle needs about half an hour of continuous connect churn +inside a single blocking window that later *completes*; a hung refresh that never +completes (R5's gap) never resumes the loop, so nothing is ever consumed there and a +collision has no consumer to mislead. `linkNextEpoch` re-draws when the fetch-add +yields 0, so wrap cannot mint the reserved unowned encoding. + +**The token is one atomic word, claimed at the earliest transport hook — NOT a +loop-task-only global.** An earlier draft made the token plain loop-side state (the +`g_commandOrigin` argument, [communication.cpp:30-36](../src/communication.cpp)) while +separately requiring callback-side write filtering and an atomic claim at the callback +(R7d). Those are incompatible: `onWrite` fires on the NimBLE host task before any +loop-side admission has run — during a refresh, up to ~16 s before one — so a loop-only +token gives the filter nothing to compare against, and leaves no rule for the unowned +window before first admission. Per R2 the resolution is that the token *is* the +published word: + +- **Claim is a compare-and-swap on the word**, executed at the earliest transport + hook — the BLE connect callback (host task) and the LAN accept (loop task). CAS + success *is* admission; failure marks the instance a contender, which Phase 3's + loop-side scan refuses. This makes R7d's "the claim itself must be atomic at the + callback" a mechanism rather than an aspiration, and it closes the unowned window: + the host task processes a peer's connect before any of its writes, so by the time + the first client's first write reaches `onWrite`, the word already names it owner. +- **The filters read the word with one `__ATOMIC_ACQUIRE` load**: `onWrite` and + `onSubscribe` compare their instance's identity against it on the host task; + `notify()` reads it on the loop task for the target handle. +- **Release stays loop-task-only** (CAS holder → NONE), strictly after the R3a wait. +- **Order in the connect callback:** allocate the epoch, publish the table entry, then + CAS — so a successful claim never names an instance the loop cannot yet see. + +`linkNextEpoch` is `__atomic_fetch_add` — BLE allocates on the host task and LAN on the +loop task, so a plain increment would race the two. The *instance identity* in the +table follows the same publication rule as before: written on the stack callback task, +read on the loop task, atomics discipline per the instance table below. + +Phase 2 establishes only the mechanism and the baseline: the first BLE connect claims +`OWNER_BLE` with its handle and epoch; disconnect releases it (wired into `abort` below). +Deciding what to do with a *second* contender is Phase 3 policy (it refuses; see +[the governing decision](#the-governing-decision-admission-never-evicts)). + +### The instance table and callback-side filtering + +CONNECTION_POLICY R3 requires **six** things at the callback and dispatch boundary. An +earlier draft of this plan had two of them (a handle-bearing connect event, write +filtering) and treated the rest as absent problems; the policy's review of the ESP32 +callbacks found that a contender perturbs shared state *before any loop-side decision +runs*, and the review after that found queued frames outlive their session — so all +six are Phase 2 mechanisms. In table form, against the ground truth above: + +| # | Requirement | Site today | Why it cannot wait for Phase 3 | +|---|---|---|---| +| 1 | **Per-link write filtering** — drop a non-owner's write before the RX ring | `onWrite`, `(void)connInfo` ([:135](../src/ble_transport_esp32.cpp)) | loop-side refusal has not run yet during a ~16 s refresh block; a gatecrasher can inject a full transfer's worth of commands | +| 2 | **Per-link subscribe filtering** — subscription state per instance | `onSubscribe`, `(void)connInfo` ([:129](../src/ble_transport_esp32.cpp)) | a contender's subscribe clears/overwrites the incumbent's apparent notify-readiness, stalling its TX | +| 3 | **Handle-targeted notify** — pass the owner's conn handle | `notify(data, len)` ([:269-277](../src/ble_transport_esp32.cpp)) | closes a **live leak** of the incumbent's responses, auth traffic included | +| 4 | **Identity-bearing disconnect events** | `takeDisconnectedEvent` carries reason + RX boundary, no handle ([ble_transport.h:81](../src/ble_transport.h)) | every consumer must ignore an event whose identity is not the owner's (7b) | +| 5 | **State that survives lost edges** — the instance table | coalescing `volatile bool` pair ([:33-34](../src/ble_transport_esp32.cpp)) | under this policy a lost event is a lost *admission decision* | +| 6 | **Frame identity** — tags on queued frames, re-checked at dispatch | `CommandQueueItem` is `{data, len, pending}`, no identity ([command_queue.h:72-76](../src/command_queue.h)) | a delayed frame from a dead instance is indistinguishable from the new owner at dispatch; a boundary flush cannot save it once the table slot was reused | + +Requirement 3 is worth calling out separately: it is a **one-argument change that closes +a live leak independent of the rest of this plan**, and it is the cheapest item in +Phases 2–4 by a wide margin. It should not wait behind the table. + +Requirements 1–3 all read the same fact — who owns the slot: the write and subscribe +filters on the host task, `notify()` on the loop task. The one-word owner token above +is what makes that read legal from both — a single atomic load, compared against the +callback's own instance identity. None of the filters touches any other loop-side +state. + +The connect event still becomes identity-bearing (handle **and** epoch, per R2), so +loop() can act on a specific newcomer — but under requirement 5 it is a hint, not the +mechanism. + +#### Requirement 5: a fixed per-handle instance table, not an event queue + +Today's coalescing is tolerable because `serviceBleEvents()` decides nothing +per-connection: a connect means "reset `rebootFlag`, update MSD, tune the link," a +disconnect means "flush the RX ring to the boundary, raise the cleanup flag" +([main.cpp:461-500](../src/main.cpp)). Under this policy each event drives an admission +decision about a specific instance, so a lost event is a lost decision — two concrete +failures, both reachable inside one refresh block: + +- **Lost connect → an unrefused contender.** Two centrals connect while `loop()` is + blocked; the flag is set twice and read once. One is refused; the other is connected, + never evaluated, and invisible to the loop. +- **Lost disconnect → the slot held by a ghost.** Owner disconnects, then a contender + connects and disconnects, all within one block. The flag coalesces and the side-band + identity is the *last* writer's. The loop sees a disconnect that does not match the + owner, treats it as inert, and never releases — every new client refused until the + idle timeout reclaims the slot. A device-wide outage of one full timeout. + +**The fix is a table the loop scans, not a queue it drains.** Sized by the connection +cap: 3 on every ESP32 target here, 1 on nRF. Each entry holds `(handle, epoch, +reason)` — **metadata only, ~8 bytes, never frames**, which is what keeps it inside +the one-command-queue constraint. There is no separate `state` field: liveness *is* +the packed `(handle, epoch)` identity word (all-zero = empty), per the publication +rule below. There is no `rxBoundary` field either — requirement 6 retires the boundary +mechanism, which is what lets entries be overwritten freely on churn. Callbacks write their own handle's entry; the +loop compares the table against its own notion of the owner. That inversion dissolves the +overflow question rather than answering it: + +- **It cannot overflow.** State is bounded by the connection cap, not by event rate. + Contender churn overwrites entries for handles already gone. No eviction policy to + specify, because nothing is queued. +- **Lost edges stop mattering.** A contender that connects and disconnects wholly within + a refresh block leaves no entry — correct, since there is nothing left to refuse. +- **Owner release is a comparison, not an event.** If the owner's `(handle, epoch)` is no + longer live in the table, the owner is gone, however many edges were missed. +- **Ghosts stay visible.** Any live entry that is not the owner is a contender still + needing refusal, so a missed refusal self-corrects on the next pass instead of leaking + a slot. + +*Search by handle; do not index by it.* NimBLE allocates from 0 upward in practice, so +direct indexing usually works, but a 3-entry linear search costs the same and cannot be +broken by a stack change that hands out sparse handles. + +**Publication must be atomic — and liveness is part of the identity word.** Entries are +written on the NimBLE host task and read on the loop task. A multi-field `volatile` +struct is not an atomic snapshot — and `volatile` is not an inter-task tool in C++ +regardless. Each entry's packed `(handle, epoch)` word doubles as its liveness: all-zero +means empty, a release-store publishes it at connect, and the disconnect callback +clears it with another release-store — never a separate `state` flag that could race +the identity. The R3a wait's acquire load therefore sees identity and liveness in one +shot, which is what makes "the owner's entry is no longer live" a sound predicate. The +one side field (`reason`) follows the `__atomic_*` discipline the RX ring in this repo +already uses ([command_queue.cpp:62,92](../src/command_queue.cpp)) and is consumed only +after the identity word says down. + +**nRF needs only requirement 4 of the filtering set in practice.** `Bluefruit.begin(1, 0)` +([ble_transport_nrf.cpp:164](../src/ble_transport_nrf.cpp)) configures the SoftDevice for +a single peripheral link, so cross-central injection is unreachable at the link layer; +its one-entry table is degenerate. Its write callback also discards the handle it is +given ([ble_transport_nrf.cpp:148](../src/ble_transport_nrf.cpp)) — latent, not live, but +fix it with the ESP32 filter so the two targets read the same. Requirement 6 applies to +nRF in full, though: a single-link target still queues frames that can outlive their +session across a disconnect/reconnect pair inside one refresh block. + +#### Requirement 6: tagged frames retire the RX boundary + +CONNECTION_POLICY R3 requirement 6, scheduled here. The policy carries the full +rationale (three review findings, one root cause: anonymous frames); this is the +implementation shape: + +- **`bleRxQueuePush` gains a `uint32_t tag` parameter**, and `CommandQueueItem` gains + the field ([command_queue.h:72-76](../src/command_queue.h)) — four bytes × 18–34 + slots is 72–136 B, per-frame metadata in the one ring, not a second ring. `onWrite` + passes the packed identity word it already loaded for the requirement-1 filter, so + the stamp is free; the nRF write callback does the same. The tag is written into + the slot **before** the release-store that publishes the head + ([command_queue.cpp:92](../src/command_queue.cpp)), exactly like `data` and `len`, + or the consumer's acquire load is not guaranteed to see it. +- **Dispatch checks the tag before parsing.** `serviceBleRx()` pops `(frame, tag)`, + drops the frame (counted, logged at debug) if `tag != linkOwnerWord()`, and otherwise + publishes it to the dispatcher as `g_commandInstance` beside `g_commandOrigin` + ([communication.cpp:30-36](../src/communication.cpp)) — loop-task-only single-writer, + the same argument as `g_commandOrigin` itself. LAN dispatch sets `g_commandInstance` + to the LAN owner's word directly; LAN frames never traverse the BLE ring. +- **Retired outright:** `s_rxBoundaryAtDisconnect` + ([ble_transport_esp32.cpp:40,105](../src/ble_transport_esp32.cpp)), the `rxBoundary` + out-param of `takeDisconnectedEvent` + ([ble_transport_esp32.cpp:347-351](../src/ble_transport_esp32.cpp), consumer at + [main.cpp:470-489](../src/main.cpp)), and `bleRxQueueDiscardTo` + ([command_queue.h:112](../src/command_queue.h)). The disconnect consumer keeps + identity and reason; stale frames self-discard at dispatch instead of being flushed + to a boundary that a table overwrite could lose. + +### The activity clock — a recognised command from the owner + +Phase 3's idle drop needs to know how long the owner has been *silent*. Today's +`lastActivityMs` cannot serve: `connCount > 0` re-stamps it every pass +([main.cpp:366](../src/main.cpp)), so a live-but-quiet link never ages. LAN's +`lastLanActivityMs` cannot serve either: it stamps on `got > 0`, i.e. any bytes read +([wifi_service.cpp:946](../src/wifi_service.cpp)), so a flooder holds the slot with +garbage. + +**Activity is defined by CONNECTION_POLICY R4, and only by it:** + +``` +idle := no inbound command from the owner on the owning transport + AND no refresh in progress +``` + +A frame counts only if it reaches the dispatcher and is **recognised as a command from +the current owner**. + +> **This supersedes an earlier draft of this section, which stamped at RX intake** — +> `bleRxQueuePush()`'s success path ([command_queue.cpp:50](../src/command_queue.cpp)) — +> and argued that stamping only queued frames kept a garbage flooder from holding the +> link. That argument does not hold: the queue accepts **any** non-empty payload within +> the size cap ([command_queue.cpp:50-93](../src/command_queue.cpp)), including a +> two-byte malformed frame or an unknown opcode, which the dispatcher only rejects later +> ([communication.cpp:544,754](../src/communication.cpp)). Intake stamping rejects empty, +> oversized and ring-full frames and nothing else, so it leaves a flooder able to hold +> the slot indefinitely — precisely the failure the idle drop exists to prevent. + +**The stamp point is the shared dispatcher.** `imageDataWritten()` +([communication.cpp:541](../src/communication.cpp)) is the single place all three +transports (nRF BLE, ESP32 BLE, ESP32 LAN) dispatch through. Stamp there, after the +`len < 2` guard and gated on two tests: + +- **Recognised:** `commandName(command) != nullptr`. Unknown opcodes return nullptr and + fall to the switch default's "Unknown command" error, so they are not activity. This + reuses the existing recognition predicate rather than adding a second, drift-prone one. +- **From the owner:** the frame's instance identity — its requirement-6 tag, published + to the dispatcher as `g_commandInstance` — equals the owner word. *This supersedes an + earlier draft of this bullet, which compared `g_commandOrigin` + ([communication.cpp:30-36](../src/communication.cpp)) against the owning transport.* + Transport is not identity: a delayed frame from a dead BLE instance is + indistinguishable from the new BLE owner by transport alone, and would stamp the new + owner's clock. In practice the dispatch tag check has already dropped such a frame + before the stamp is reached; the stamp's own full-word test is one redundant compare, + kept because the two sites can otherwise drift. + +Recognition deliberately sits *before* the auth gate: `CMD_AUTHENTICATE` must count as +activity or a client cannot complete a handshake without racing the clock. An +unauthenticated peer that sends recognised-but-rejected commands is therefore held off by +Phase 4's auth-abuse counter, not by this clock — which is the correct division, since +the counter can distinguish "wrong credentials" from "silent." + +**One consequence worth naming: the clock is now loop-task-only.** Intake stamping ran on +the NimBLE host / Bluefruit callback task and needed `__atomic_store_n`/`__atomic_load_n` +(`__ATOMIC_RELAXED`). `imageDataWritten()` runs on the loop task, from `serviceBleRx()` +([main.cpp:513](../src/main.cpp)) and from `handleWiFiServer`, so the clock is a single- +writer plain global — no atomics, same argument as `g_commandOrigin`. The atomics +discipline is still required for the instance table above; it is just not required here. + +**Where it lives: with the token, not in the transport.** The clock is now keyed on +*ownership*, which makes it policy, not link state — and this repo has settled precedent +in that direction: [ble_transport.h:89-93](../src/ble_transport.h) records that the +loop-serviced deferred-work flags were moved out of the transport because they "encode +application policy, not link state, so exporting them from the transport seam was +backwards." So the clock sits beside the owner token in `link_owner.h/.cpp`: + +``` +uint32_t linkMsSinceOwnerCommand(void); // 0 when unowned +void linkStampOwnerCommand(void); // dispatcher, on a recognised owner command +void linkStampRefreshEnd(void); // endRefresh(), see below +``` + +One clock suffices because R1 admits one owner; R4's "each transport enforces its own +timer and constant" is satisfied by the *constants* differing — BLE's 120 s local define +against LAN's `OD_LAN_READ_TIMEOUT_S` — not by duplicating the clock. + +**The clock must not run during a refresh.** `epdRefreshInProgress` brackets a *blocking* +call on the loop task ([display_service.cpp:2446-2467](../src/display_service.cpp), +[:3358-3368](../src/display_service.cpp)): `loop()` does not execute for the refresh's +duration, but wall-clock time passes. A naive `millis() - lastStamp` accrues the whole +refresh and can drop an actively engaged client the instant `loop()` resumes. + +This is also what answers the intake-stamping rationale that has now been dropped. That +draft stamped at intake because a loop-side stamp would record when `loop()` *drained* a +frame rather than when it arrived, inflating silence by whatever the loop was blocked on +— and the thing it is blocked on is a refresh. The refresh exclusion addresses that +directly and correctly; intake timing addressed it only as a side effect, while getting +the definition of activity wrong. + +Implementation requirements for the exclusion, per R4: + +- **A loop-side edge detector cannot see the edge** — both transitions happen inside the + blocking handler. The re-stamp must be invoked *at* the transition, via a single + `endRefresh()` helper that **both** bracket sites call, not by polling the flag. Both + sites currently assign `epdRefreshInProgress = false` inline; the helper replaces both + assignments, so a future third refresh path cannot forget it. +- **Re-stamp the current owner's clock only**, and only if the same instance identity + still owns the slot. +- Re-stamping can only ever *delay* a drop, never cause a spurious one — which is why it + is safe to apply unconditionally at the transition. + +**The baseline is the later of admission, last recognised command, and last refresh +end** — the init fix. A naive "`UINT32_MAX` until first command" would put a freshly +admitted, still-silent client instantly past any timeout: + +``` +linkMsSinceOwnerCommand() := millis() - max(admittedMs, lastCommandMs, refreshEndMs) + // 0 when unowned +``` + +A new client thus gets the full idle window before its first command. On LAN the +baseline is **TLS handshake completion**, not TCP accept (R7a) — handshake traffic is not +a command; see Phase 3. + +### `abortToKnownState(reason, bool dropLink, LinkId ownerId)` + +New `src/session_guard.h/.cpp` (both targets; LAN parts under +`#ifdef OPENDISPLAY_HAS_WIFI`, **not** `TARGET_ESP32` — `esp32-N4` is ESP32 without +WiFi). `ownerId` is the identity the abort acts for, and it is a **parameter, not a +re-derivation**: ordinary callers pass a snapshot of `linkOwnerId()` taken before +calling (or use a two-argument convenience overload that snapshots it); the terminal +caller passes the identity `linkMarkTerminal()` displaced, because by then the word +reads terminal and a re-derivation would act for the wrong identity. Steps 10 and 11 +below consume it. Ordered teardown: + +1. Log first (one line, the reason). +2. Optional client NACK — **skip when `dropLink`** (the link is about to go). +3. `cleanupDirectWriteState(true)` — panel power + touch-resume. +4. `cleanupPartialWriteOnDisconnect()`. +5. `resetPipeWriteState()`. +6. **new** `resetChunkedWriteState()` — a real primitive replacing the open-coded + inline clears in `communication.cpp`; call it here and from those sites. +7. **new** `touchForceResume()` — asserts the suspend counter reached 0 and clears + `directWriteTouchSuspended` even when teardown bypassed `cleanupDirectWriteState`. + A new public idempotent API, not an existing primitive. +8. `clearEncryptionSession()` — **new on the disconnect path**; today crypto state + survives a link drop. +9. **new** ring reset primitives — `bleTxQueueReset` and `bleRxQueueReset`. + Discarding RX outright is sound because callback filtering (requirement 1) means + every frame in it passed the owner check when written. `bleRxQueueReset` follows + the SPSC contract in CONNECTION_POLICY requirement 6: **consumer-side discard + only** — acquire-load the producer's head, release-store that snapshot into the + tail, write neither the head nor any slot — so it cannot race an in-flight push; + and it must never run while a peek is outstanding (every returning abort caller is + loop-side after RX consumption; deep sleep, the one in-dispatch caller, never + returns). A frame the owner writes *after* this step, during step 10's wait, is + deliberately not re-flushed: it carries the departing instance's tag + (requirement 6) and fails the dispatch check once step 11 releases — the same + construction that makes an expired R3a wait harmless. An earlier draft flushed TX + only, which left R6's "both rings drained" unmet and the teardown window open. +10. If `dropLink`: drop **by the owner's transport** — the token records it, and this + routine is not BLE-only (the transfer watchdog that calls it is origin-agnostic). + `OWNER_BLE` → `bleDropAndWait(ownerHandle)`: request termination, then wait + cooperatively until the link is actually down or `OD_BLE_LINK_DOWN_WAIT_MS` + expires (R3a); not the bare seam call. `OWNER_LAN` → a new public + `wifiLanDropOwnedSocket()` seam in `wifi_service.cpp` — needed because + `tlsCloseSession()` is file-static ([wifi_service.cpp:281](../src/wifi_service.cpp)), + so the abort cannot reach the pieces directly. It performs the LAN-local subset of + today's `disconnectWiFiServer()` ([wifi_service.cpp:794-808](../src/wifi_service.cpp)): + `tlsCloseSession()`, `wifiClient.stop()`, `wifiServerConnected = false`, + `tcpReceiveBufferPos = 0` — everything *except* `clearEncryptionSession()` and + `requestTransferSessionCleanup()`, which are this routine's own steps 8 and 3–5, + so the two never nest. A TCP close is synchronous; no wait bound applies on LAN. +11. `linkRelease(ownerId)` — full-triple release, and **strictly after** step 10. + +**Steps 10 and 11 are ordered, and that order is the whole of R3a.** An earlier draft +released the token in the same breath as *requesting* the disconnect, which would let a +new connection be admitted while the old link was still physically up. If the wait in +step 10 expires the release still happens — expiry is an early exit, not a failure — and +the stale link is inert by construction: its writes are filtered as non-owner, and its +late disconnect is inert on stale epoch (7b rows 4 and 9). That is the guarantee that +let R3a drop the cross-pass `DROPPING` state an intermediate draft had introduced. + +Note the asymmetry with step 2: the NACK is skipped when `dropLink` because the link is +about to go, whereas Phase 4's auth-abuse drop must *deliver* its final `FE` first. Phase +4 therefore runs its own bounded TX barrier **before** calling the abort, rather than +asking the abort to hold the link open — see Phase 4. + +**Buzzer and LED are NOT stopped — deliberately.** An earlier draft added +`buzzerStop()` / `ledFlashStop()` as step 8. That is wrong: buzzer and LED are +user-facing *effects*, not session state. A client that fires a buzz and immediately +drops the link is a normal pattern — command, then disconnect to save power — and +truncating the buzz mid-note defeats the command's entire purpose. Nothing about a +playing melody corrupts or confuses a later connection, unlike a half-open pipe +session, a suspended touch input, or a live crypto session. And because this policy +fires the abort far more often than a plain disconnect once did (idle timeout, +transfer watchdog, auth-abuse), the regression would be correspondingly more visible. +Both are bounded and self-terminating (see ground truth), so leaving them running +cannot wedge anything. No stop step is added here. + +**The one exception is not an exception to this.** Deep sleep does silence both, because +sleep stops the clocks the effects run on — but that lives in the deep-sleep path and +calls the wrappers directly. `abortToKnownState` never silences anything, including on +the deep-sleep call in the invocation set below. Keeping the two apart is what stops a +future edit from "unifying" them and quietly truncating every buzz on an idle drop. + +**Panel power is NOT force-killed here — deliberately.** An earlier draft added an +`epdSessionForceOff()` step "unless refreshing". That is wrong: `epdSessionForceOff()` +powers off every state except `PWR_OFF`, **including `PWR_WARM`** (the only early +return is `if (pwrmgmState == PWR_OFF) return`, +[display_service.cpp:420-421](../src/display_service.cpp)) — a disconnect during a +refresh is deferred, so by the time abort runs the panel can be WARM with +`epdRefreshInProgress` false, and the step would kill exactly the panel that must +survive. Panel power is handled correctly by steps 3–5: `cleanupDirectWriteState` +forces off only a `PWR_ACTIVE` (mid-transfer) session and no-ops on WARM, matching +the existing "ACTIVE-only teardown" invariant in `serviceBleDisconnectCleanup`. So a +WARM keep-alive panel survives an abort — including an auth-abuse or idle drop of a +client while the panel is warm from a prior push. + +Idempotent and loop-task-only: every step is either already a no-op when its state +is inactive, or made one. + +### The complete invocation set + +Collected here rather than left implicit across three phases, because the value of a +single shared teardown routine depends entirely on every teardown actually reaching +it. Three callers, and one governing invariant: **`dropLink=false` iff no drop is +wanted from the abort — either the link is already gone (the disconnect-cleanup case) +or the whole stack is about to be torn down with admission terminally gated (the +deep-sleep case, via `linkMarkTerminal()` below)**. + +| Condition | `dropLink` | Phase | +|---|---|---| +| Disconnect event serviced: `s_disconnectCleanupPending && !epdRefreshInProgress && !ownerStillUp`, **and the event's identity matches the owner** (7b) | `false` | 2 | +| Deep sleep, forced or idle — **after `linkMarkTerminal()`, before `ble.end()`** (R7e row 3) | `false` | 2 | +| `serviceIdleTimeout()`: owned **by BLE** `&& !epdRefreshInProgress && linkMsSinceOwnerCommand() > OD_BLE_IDLE_TIMEOUT_MS` — **no** `transferActive()` gate, per R4 (LAN's reclaim is its own `OD_LAN_READ_TIMEOUT_S` path, per R4's per-transport rule) | `true` | 3 | +| Auth-abuse counter reaches its threshold, **after** the bounded TX barrier drains the `FE` or `OD_AUTH_ABUSE_FLUSH_MS` expires | `true` | 4 | + +**Deep sleep is a caller — for teardown uniformity, not for surviving state** +(CONNECTION_POLICY R7e row 3, which carries the twice-corrected rationale in full). +*Earlier drafts justified this with state surviving sleep — RAM in one draft, hardware +in the next; both false against the tree:* wake re-enters `setup()` with RAM reloaded, +only `RTC_DATA_ATTR` survives ([main.cpp:129-150](../src/main.cpp)); and the sleep +path already forces the panel off before sleeping ([main.cpp:812](../src/main.cpp)) +with touch re-initialised on wake ([main.cpp:238](../src/main.cpp)). The real reason: +deep sleep is a **mid-session exit** — forced sleep bypasses the live-link guard +([main.cpp:789](../src/main.cpp)) and the path does not arbitrate a LAN owner — whose +path hand-rolls a private teardown subset (panel force-off, advertising stop, stack +end, effect silencing). Routing the session half through the abort first makes sleep's +teardown identical to every other session end by construction, instead of a parallel +copy that every future session resource must be added to — the same anti-drift +argument that made the transfer watchdog a caller. The sleep path keeps its own sleep +quiescing on top: `epdSessionForceOff()` (WARM included — no panel sleeps powered; +this call must never move into the abort) and the buzzer/LED silencing below. + +**Order: `linkMarkTerminal()` first, then the abort, then `ble.end()`** (the R7e row 3 +ordering trap). Without the gate, the abort's step 11 frees the word while the owner's +link may still be up and advertising is still on — a connect on the host task could +win the freed word in that window and the new owner would be destroyed by `ble.end()` +with no abort ever run for it. With the word exchanged to `OWNER_TERMINAL` first, +claims fail for the rest of the shutdown, and wake reloads RAM clean. +`linkMarkTerminal()` **returns the displaced owner identity**, and that is the +identity the sleep path hands the abort to act for — after the exchange, +`linkOwnerId()` reads terminal, not the departing owner, so the abort must not +re-derive it. Step 11's `linkRelease(displacedId)` then finds the word not matching +and is naturally inert; `linkRelease` matches the full identity and never accepts the +terminal word itself, so nothing can CAS the gate back to zero. `dropLink=false` because +`ble.end()` takes the stack down immediately after; there is no link left to drop +politely, and no loop pass will service the resulting event. + +**Deep sleep also silences buzzer and LED — settled, and it is a *sleep* change, not an +abort one.** The abort leaves both running by design, and deep sleep cuts the clocks they +depend on, so at this one transition "let the effect finish" cannot hold: the effect +*cannot* finish. Of the two consistent resolutions, this plan takes **silence on the way +down** rather than making sleep wait via the `workInFlight` gate +([main.cpp:694-699](../src/main.cpp)) — sleep is never delayed by a playing effect. + +The argument is hardware state rather than symmetry. `enterDeepSleep` runs +`ble.stopAdvertising()` / `delay(200)` / `ble.end()` / `delay(100)`, then +`armButtonWakeSources()` and `powerLatchHoldForSleep()` +([main.cpp:806-836](../src/main.cpp)) — all outside `loop()`, so `buzzerService()` never +ticks through any of it. A tone still on is therefore not a melody playing out; it is a +driven pin held through teardown and into sleep, sounding continuously and drawing current +until the next wake. Waiting would only postpone that. + +Three scoping rules, each of which a careless implementation gets wrong: + +1. **In the deep-sleep path, never in `abortToKnownState`.** R6's carve-out is untouched: + an idle, auth-abuse or watchdog drop still leaves a melody playing. +2. **Deep sleep only — not every terminal transition.** Power-latch off (7e row 4) + deliberately *plays* a chirp on the way down, `passiveBuzzerPowerOffAlert()` immediately + before `powerLatchTriggerOff()` ([device_control.cpp:83](../src/device_control.cpp)). A + blanket "silence at every terminal transition" deletes that alert. +3. **ESP32-only.** `enterDeepSleep` sits inside `#ifdef TARGET_ESP32` + ([main.cpp:757](../src/main.cpp)), so nRF takes on nothing here. + +Placement: silence **before** `armButtonWakeSources()` / `powerLatchHoldForSleep()`, so +pin state is settled before the wake pads and latch hold are configured. Relative order +against `ble.end()` does not matter. The two public wrappers this needs are noted in the +ground truth above; `led_stop_internal`'s `clear_mode` should match `handleLedStop`'s +`true` ([device_control.cpp:587](../src/device_control.cpp)), so the observable result is +the same as the client having sent LED_STOP. + +**The other terminal transitions stay exempt** (R6 exception 2, R7e rows 1, 2, 4): nRF DFU +entry ([device_control.cpp:847-866](../src/device_control.cpp)), ESP32 DFU/reboot +([:880](../src/device_control.cpp)) and power-latch off ([:942](../src/device_control.cpp)) +all disconnect and then leave — the MCU resets, jumps to a bootloader, or loses power — so +"ready for a new connection" is meaningless and no loop pass will ever service the event. +Each may take a synchronous abort instead if that is ever wanted; the exemption is the +default. + +**Explicitly not a caller: refusing a contender.** Admission calls +`ble.disconnect(newHandle)` (or `incoming.stop()` on LAN) and nothing else — no +`abortToKnownState`, no `s_disconnectCleanupPending`, no `linkRelease`. The +incumbent's session must be untouched. This is the case most likely to be got wrong +in implementation, since refusal and teardown sit in the same handler and differ only +in which handle they act on. + +**Also not callers, deliberately.** `clearEncryptionSession()` at +[communication.cpp:66](../src/communication.cpp) (config reload) and +[encryption.cpp:261](../src/encryption.cpp) (session timeout) are crypto lifecycle, +not session aborts; they stay as they are. + +**Resolved: `checkTransferTimeouts()` is a caller.** The 15-minute watchdog +([display_service.cpp:584-638](../src/display_service.cpp)) routes its teardown through +`abortToKnownState(dropLink=true)` and stops carrying its own. There is exactly one +teardown routine, which is the whole point: this plan cites *that very function* as the +reason a shared routine is needed, so exempting it would have argued for the routine +while leaving the original drift source untouched. + +| Condition | `dropLink` | Phase | +|---|---|---| +| `checkTransferTimeouts()` fires on a direct-write or partial transfer past `TRANSFER_WATCHDOG_MS` | `true` | 2 | + +This is a **behaviour change**, deliberately taken, in three ways: + +1. **Crypto is now cleared.** The watchdog previously left the encryption session + intact. It no longer does. +2. **The link is now dropped.** `dropLink=true` rather than `false`, which follows + from (1) rather than being an independent choice: once the session is cleared, a + retained link is a confusing state — the client's next command draws + `RESP_AUTH_REQUIRED` with no event to explain it, and under Phase 4 those refusals + feed the auth-abuse counter until the client happens to re-authenticate. A dropped + link is an unambiguous signal, it frees the exclusive slot (CONNECTION_POLICY R1) + from a demonstrably broken client, and it makes the watchdog's semantics identical + to the idle and auth-abuse drops. The client reconnects and restarts the transfer — + which it had to do anyway, since the transfer state is gone either way. Note the + watchdog is **origin-agnostic** — both branches test transfer state, not origin + ([display_service.cpp:592,609](../src/display_service.cpp)), so a LAN transfer can + time out too — which is why step 10 dispatches on the owner's transport: a + timed-out LAN owner loses its socket, not an unrelated BLE handle. +3. **Teardown is no longer selective.** The two branches previously cleaned one + transfer half each; the abort clears all transfer state. Under one-client + exclusivity the halves are not independently owned, so this is a simplification + rather than a loss. + +The cost is that a legitimately slow-but-progressing transfer, cut off by the +from-START duration bound, now also loses its link and session. That is acceptable +because it must restart regardless, and because the real defect there is the +duration-vs-stall bound itself, recorded under residual risk. + +**Not folded in: the orphaned-pipe healer.** The third branch of +`checkTransferTimeouts()` (`pipeState.active && !pipeState.error && !directWriteActive +&& !partialCtx.active` → `resetPipeWriteState()`) is an *invariant repair*, not a +transfer timeout — it heals an internal inconsistency that should never arise. Dropping +a healthy client's link and session over an internal bookkeeping error would be +disproportionate. It stays as it is, and stays a plain `resetPipeWriteState()`. + +### Wire `serviceBleDisconnectCleanup` through it + +`serviceBleDisconnectCleanup` ([main.cpp:388-423](../src/main.cpp)) already defers +correctly and already checks `ownerStillUp`. Phase 2 routes its teardown body through +`abortToKnownState(..., dropLink=false)` (the link is already gone) so the disconnect +path and the abort path can never drift. Keeping two separate teardown paths is +exactly how the direct-write watchdog once tore down a panel while leaving its pipe +session live — a bug this branch already fixed in `checkTransferTimeouts`, and one a +single shared routine prevents from recurring. + +**No special nRF deferral is needed for the session clear.** An earlier draft called +for deferring `clearEncryptionSession()` on nRF to avoid a `memset(session_key)` +racing an inline `aes_ccm_decrypt`. That race does not exist in the current +architecture: nRF's write callback only *enqueues* +([ble_transport_nrf.cpp:148-156](../src/ble_transport_nrf.cpp)); all decrypt and +dispatch happen on the loop task in `serviceBleRx()` +([main.cpp:513](../src/main.cpp)), and `serviceBleDisconnectCleanup` is already +loop-task. The abort — session clear included — runs on the loop task, never +concurrently with a decrypt. No `nrfSessionClearPending` machinery. + +### Verification + +Disconnect mid-direct-write, mid-partial, mid-pipe, mid-chunked-config-write, and +mid-refresh (WARM survives); assert every flagged state is clean afterward, touch is +resumed, crypto cleared, **both rings reset**; assert a second transfer starts clean. +Deep sleep entered mid-transfer wakes with no residue (the R7e row 3 caller). + +The frame tag (requirement 6): frames queued by a departing instance — including one +written *during* the teardown window, after the step-9 ring reset — never dispatch +once the token is released, and never stamp the new owner's activity clock; a +reconnecting client (fresh epoch, possibly the same handle) starts with a ring whose +stale frames are dropped at dispatch, with the drop counted and visible in the log. + +Callback-boundary mechanisms, each checkable before any Phase 3 policy exists: a second +central's writes are dropped at the callback while the token is held; **its subscribe +does not disturb the incumbent's notify state**; and **it receives no notifications at +all** — the last is the live-leak fix and wants a sniffer or a second bleak client +reading, since a passing incumbent proves nothing about what leaked. + +The clock: `linkMsSinceOwnerCommand()` is 0 when unowned, ages only on true silence, is +**not** refreshed by a malformed or unknown-opcode frame (the R4 correction — send junk +that `bleRxQueuePush` happily accepts and confirm the clock keeps running), and is +re-stamped across a refresh so a client engaged either side of a ~16 s refresh is never +dropped. + +Host-buildable parts get unit tests: the `linkClaim`/`linkRelease` state machine on the +full triple; epoch discrimination — a claim carrying a reused handle with a new epoch +must not match the incumbent, and a release carrying a stale epoch must not release; and +the claim CAS under contention — two racing claims (host threads suffice) must end with +exactly one owner and one contender. Build all envs. + +Three seam-specific bench checks a build cannot cover. **The drop actually drops:** call +the seam from the loop task on both nRF and ESP32 and confirm the link goes down on a +scanner or the client — the 0x09 trap above is precisely a case where the code looks like +it worked, so "it compiled" proves nothing. **The drop waits, on the right predicate:** instrument +`bleDropAndWait()` and confirm it observes the owner's instance-table entry go down +before returning on a live peer, that the token is still held throughout — the R3a +ordering is invisible from outside — and that it returns promptly with a refused +contender still attached, the case where the aggregate `connectedCount()` would have +sat out its full bound. **The reason log is honest:** a real client disconnect logs a +sensible HCI reason, and a NimBLE host-layer reason now logs as `0x0xx` rather than +masquerading as an HCI code. + +--- + +## Phase 3 — Connection-exclusivity policy + idle drop + +**Goal:** the *policy* on top of Phase 2's mechanisms — refuse any contender while the +slot is held, and reclaim the slot from an incumbent that has gone silent. Phase 2 +already makes a second BLE central harmless (its writes, subscribes and notifications +are filtered, and it cannot own the token); Phase 3 makes it *clean* (actively +disconnected) and closes the idle-link hole. It consumes the instance table, the owner +token and `linkMsSinceOwnerCommand()` — all Phase 2 — and adds no new transport state. + +**Phase 3 is CONNECTION_POLICY R7 made executable.** The permutation tables there (7a +admission, 7b disconnect, 7c idle, 7d ordering, 7e terminal) are normative and are not +restated here; this phase says where each is enforced and what changes in the tree. +Any combination the tables do not list is a specification gap to take back to the +policy, not implementer's discretion. + +### The governing decision: admission never evicts + +**A contender is always refused while the slot is held. Reclaiming a slot is the job +of the idle timeout alone, never of the accept path.** These are two independent +mechanisms and this plan deliberately keeps them that way. + +An earlier draft made admission a three-way rule (refuse if the incumbent is +transferring or young-idle; *evict* it if idle past a threshold, then admit the +newcomer). That is rejected. What it bought — a faster reclaim when a stale link +lingers — is not worth what it cost: + +- **It made an incumbent's fate depend on whether someone else happened to knock.** + The same idle client is kept or killed for reasons it cannot observe, which is + hard to reason about and harder to test. +- **It needed a whole extra threshold** (evict-idle age) that this plan's own + residual-risk list already flagged as the one requiring the most conservative + tuning, since too aggressive a value refuses a legitimate reconnect. +- **It put a multi-step teardown at a stack-event boundary** — disconnect incumbent, + `abortToKnownState`, release token, then let the newcomer claim — with the newcomer + already connected throughout. Pure refusal never touches incumbent state at all. + +The cost accepted in exchange is that a returning client waits out the idle timeout +rather than ~10 s. That cost is smaller than it looks, and it differs by transport: + +- **BLE: mostly absorbed below us.** The firmware never sets a supervision timeout — + it takes whatever the central negotiates (commonly ~4–6 s). So an incumbent that is + genuinely *gone* is reaped by the link layer without firmware involvement, and the + idle timeout only has to handle a client that is alive and silent. Refusing a + contender in *that* case is arguably the correct answer anyway. +- **LAN: genuinely dependent on the timeout.** TCP has no supervision timeout; a + half-open socket persists indefinitely without keepalives. `OD_LAN_READ_TIMEOUT_S` + (30 s) is the only reclaim path, which is precisely why LAN already has one. + +### Within-pass ordering (R7d) — normative, not incidental + +**Fix the order first, because everything below depends on it.** The current loop order +is `serviceBleEvents()` → BLE RX → deferred disconnect cleanup → LAN accept/read +([main.cpp:624](../src/main.cpp)), and connect and disconnect flags are consumed +connect-first regardless of actual arrival order +([main.cpp:461-471](../src/main.cpp)) — exactly the ambiguity R7d removes. Without a +stated order, two conforming implementations pick different winners. Within one pass: + +1. **Owner disconnects** (7b) — the abort first, whose *final* step releases, so a + slot freed this pass is available to an admission decision in the *same* pass. + Never release before the abort: a claim CAS can succeed the instant the word is + zeroed, and an abort still running after that would tear down the new session. +2. **Contender refusal, and the LAN accept** (7a). Admission itself is the hook-side + CAS: for BLE it already happened — or failed — in the connect callback, so this + step only *refuses* live instances whose CAS failed; the LAN accept runs here + because the loop is its earliest hook, and its claim is the same CAS. No loop-side + rule picks a winner between transports; the word does. +3. **Inbound traffic**, which stamps the activity clock. +4. **Idle timeout** (7c) — last, so traffic parsed in step 3 counts. This is what + satisfies R4's ordering constraint for LAN, where inbound bytes may be sitting in the + socket when the deadline is evaluated. + +**But the authoritative arbitration point is the earliest transport hook — the BLE +connect callback and the LAN accept — not the loop.** Fixed loop ordering cannot +reconstruct true cross-transport arrival order: a BLE connect during a refresh and a LAN +socket queued in the listen backlog are not comparable by the time `loop()` resumes. The +loop order resolves *ties within a pass* only; the claim itself must be atomic at the +callback — mechanically, the one-word owner CAS from Phase 2 — and where the two +disagree the callback wins. Do not build correctness on step order alone. + +### Enforcement + +- **ESP32 admission — refuse, unconditionally.** Scanning the instance table, any live + entry whose `(handle, epoch)` is not the owner's is a contender: `ble.disconnect(its + handle)` and stop. Do **not** raise `s_disconnectCleanupPending`, do **not** + `linkRelease()`, do **not** inspect the incumbent's state at all — no `transferActive()` + test, no idle-age test. The incumbent's session is untouched by construction rather than + by a guard that could be got wrong. Note this is a **table scan, not an event handler** + (Phase 2 requirement 5): a refusal missed because two connects coalesced self-corrects on + the next pass, where an event-driven version would leak the contender permanently. + Because refusal is idempotent and inert, re-refusing an entry that is already tearing + down costs nothing. nRF gets the same refusal free from `begin(1,0)`; this bullet is the + ESP32 analogue. **The scan never admits**: admission is one CAS at each instance's own + connect hook, decided once and never revisited (7a rows 9–10) — a contender whose + refusal is still pending when the slot frees stays refused, and the freed slot goes to + the next *new* instance. Racing arrivals, including a BLE connect against a LAN accept, + are serialized by the word, not by scan or loop order. + + **7a row 4 is the case to test.** A contender reusing the incumbent's handle after a + stale link must be refused, and the *only* thing distinguishing it from the incumbent is + the epoch — which is why R2 allocates one for every instance, admitted or not. +- **Proactive idle drop — the sole reclaim mechanism.** Since admission never evicts, + this is the *only* way a held slot is ever released short of the client leaving. A + loop-serviced `serviceIdleTimeout()`: if the slot is owned **by BLE**, no refresh is + in progress, and `linkMsSinceOwnerCommand() > OD_BLE_IDLE_TIMEOUT_MS`, call + `abortToKnownState(dropLink=true)` — its step 10 drops the owner's link and waits for + link-down, its step 11 releases; the R3a order, all within the one pass (7c row 1). + This is the BLE side of R4's each-transport-its-own-timer rule: LAN's reclaim stays + its existing `OD_LAN_READ_TIMEOUT_S` path (whose teardown routes through the abort + per R6). An `#ifndef`-guarded define in the file that services it, not a wire/config + field. + + **There is no `!transferActive()` gate**, per + [CONNECTION_POLICY](CONNECTION_POLICY.md) R4, which supersedes an earlier draft of + this bullet. An in-flight transfer confers no protection: a client that goes silent + *during an upload* is precisely the case that wedges the device, and a transfer gate + would exempt exactly it. Idleness excludes only refresh-in-progress — via the + `endRefresh()` re-stamp Phase 2 builds, since `loop()` is blocked throughout a refresh + while wall-clock time passes (7c row 3). The from-START watchdog remains the backstop + for the remaining case: a transfer that keeps sending recognised commands but never + ends. + - *Default: `OD_BLE_IDLE_TIMEOUT_MS = 120000` (120 s).* Set deliberately generous, + and note this is **double** an earlier draft's 60 s — the reasoning inverted when + R4 landed, so the direction of the change is not an oversight: + + - While the idle drop was gated on `!transferActive()`, the timeout could only + ever kill an *idle* client, so erring short was cheap and a shorter value + shortened the lockout. + - R4 removed that gate. The timeout can now terminate an **in-progress upload** + whose client has gone quiet, so erring short no longer costs a stale session — + it costs a legitimate transfer. Conservative is now the safer direction. + + The cost is bounded and falls only on one case: a returning client waits up to + 120 s if a stale-but-*alive* incumbent holds the slot. An incumbent that is + genuinely gone is reaped by the link layer in ~4–6 s (the firmware sets no + supervision timeout, so the central's negotiated value applies), so the 120 s + lockout never applies to a crashed or out-of-range client. + + **120 s is settled.** It is a chosen value rather than a measured one, and it is not + gated on a measurement: implementation proceeds on it. What remains is *drift + detection*, not verification — the `py-opendisplay` assertion below fails if a client + change ever pushes legitimate inter-command silence toward 120 s, which is the same + treatment every other threshold here gets. Record the reasoning, not a pending + confirmation, in the comment on the define. + - *Why it cannot live where its LAN cousin does, and what that costs.* + `OD_LAN_READ_TIMEOUT_S` is **not** a local tunable: it is defined at + [opendisplay_protocol.h:984](../include/opendisplay_protocol.h) and documented at + `:84` and `:945` as a client-visible contract ("the server drops a client only + after `OD_LAN_READ_TIMEOUT_S` with no traffic"). Its home is the wire header + because the client is entitled to know the number. The hard constraint forbids + touching that header, so the BLE timeout is forced local — deliberately + asymmetric with the LAN one, and invisible to clients except through the + client-side CI assertions below. That is the accepted trade, not an oversight: a + wrongly-dropped BLE client reconnects, so the cost of the client not knowing the + exact number is bounded. If the BLE timeout ever needs to be genuinely + client-visible, that is a wire change and goes through `../opendisplay-protocol` + first — at which point it belongs in the protocol header beside its LAN cousin, + not in firmware. + - *Deep sleep:* the idle drop leaves `lastActivityMs` and the deep-sleep quiet window + alone — this is a *link* drop, not a sleep decision. After it `connCount` falls to 0, + `pollActivity` stops re-stamping, and the existing idle/deep-sleep path takes over. + (Separately, deep sleep itself becomes an abort caller — R7e row 3, Phase 2. That is + a change to the *sleep* path, not to this one.) +- **LAN, one consistent model — including LAN-vs-LAN.** The token is connection-level, + so a LAN accept while *any* transport owns the slot is refused (`incoming.stop()`), + and symmetrically a BLE connect while LAN owns is refused. `handleWiFiServer` accept + ([wifi_service.cpp:869-877](../src/wifi_service.cpp)) gains the token check and + `linkClaim({OWNER_LAN, 0, epoch})` — the same CAS the BLE connect callback uses, so + cross-transport arbitration is the word itself, not loop ordering. + + **The claim happens at TCP accept, before the TLS handshake** (R7a row 2). The + handshake is driven incrementally across later loop passes + ([wifi_service.cpp:905-920](../src/wifi_service.cpp)), so deferring the claim until it + completes would leave the slot free for a BLE connect or a second socket in the + meantime — a race the accept-time claim closes. Three consequences follow, and each is + a real code change on that path: + + - A second accept *during* the handshake is refused (rows 5/7 apply) — it does not get + to displace a half-established session. + - **TLS handshake failure is an owner disconnect**: the existing + `disconnectWiFiServer()` at [wifi_service.cpp:918](../src/wifi_service.cpp) must now + run R6's abort and release the token, or a failed handshake strands the slot until + the idle timeout. + - **Handshake traffic is not activity.** The idle baseline starts at handshake + completion, which the code already stamps ([wifi_service.cpp:910](../src/wifi_service.cpp)). + + **No separate handshake deadline is added, deliberately.** An earlier reading required + one. It is unnecessary: because the baseline does not start until the handshake + completes, a handshake that never finishes leaves the clock at its accept-time stamp and + the existing 30 s `OD_LAN_READ_TIMEOUT_S` drop fires. A dedicated deadline would only + tighten that window — not worth a second tunable until something shows 30 s is too slow. + + **This is a behaviour change for LAN, not just a new cross-transport check.** Today + that path is unconditional last-in-wins: a second TCP accept tears down TLS, clears + crypto and stops the previous client, with no test of what it was doing. Under the + rule above it becomes a refusal, which matters more on LAN than on BLE because TLS + bypasses app-layer auth by design — so today *any* host on the network can kill an + in-flight display push simply by opening a socket, with no credentials. Refusing + closes that. + + **A pre-existing bug on the same path, fixed by the same change.** The accept-side + eviction clears TLS/crypto but never calls `requestTransferSessionCleanup()` — unlike + `disconnectWiFiServer()`, which does ([wifi_service.cpp:807](../src/wifi_service.cpp)). + So an evicted client's in-flight direct-write/pipe/partial state stays live, and + because both clients are `ORIGIN_LAN`, `frameOwnsSession()` does not stop the *new* + client's frames from landing in the *evicted* one's transfer — the same class of hole + as the ESP32 multi-central case. Making the path refuse rather than evict removes the + bug by removing the eviction; nothing is left needing the cleanup call. + + LAN's reclaim path is unchanged in *mechanism* and remains `OD_LAN_READ_TIMEOUT_S` + ([wifi_service.cpp:952](../src/wifi_service.cpp)), which already drops an idle client + after 30 s and is already ungated by transfer state — so LAN needs no new timer, only + R4's two semantic corrections. `lastLanActivityMs` is close to a true activity clock + already: stamped at connect, TLS-handshake completion, bytes read and frame dispatch + ([wifi_service.cpp:886,910,946,972](../src/wifi_service.cpp)) and — unlike BLE's + `lastActivityMs` — never re-stamped merely for being connected. + - *The `got > 0` stamp must go.* `:946` stamps on **any bytes read**, not on a + recognised frame, so a plain-mode flooder defeats both the 30 s read timeout and any + policy built on that clock. This is the same defect the BLE clock had in intake form, + and Phase 2 fixes both at once: stamping moved to `imageDataWritten()`, which LAN also + dispatches through, so LAN inherits "recognised command from the owner" without a + second implementation. Delete the `:946` stamp rather than adding a parallel one — + two clocks for one rule is how they drift. + - *The refresh exclusion applies to LAN too.* `endRefresh()` re-stamps the owner's + clock whoever the owner is; a LAN client mid-push across a ~16 s refresh is exposed to + exactly the same spurious drop as a BLE one. + - *The 30 s constant is settled and unchanged.* `OD_LAN_READ_TIMEOUT_S` satisfies R4 as + it stands: it is already ungated by transfer state, which is the substance of the + rule, and R4 governs only its **semantics** — which stamp counts as activity, and the + refresh exclusion — both firmware-local and both fixed above. Its **value** is a + wire-header contract and out of bounds here, so "does 30 s satisfy R4" is not an open + question but a closed one: yes, with the two stamping corrections applied. The + asymmetry with BLE's 120 s is deliberate and follows from where each constant is + allowed to live. + +### One thing to get right (easy to assume wrong) + +The ESP32 central cap **cannot** be forced to 1 with a `-D` build flag — the +`CONFIG_BT_NIMBLE_MAX_CONNECTIONS = 3` in the precompiled `sdkconfig.h` wins, and a +local override is silently inert. Exclusivity must be enforced in firmware, as above, +not by config. R1 is therefore phrased in terms of **admission, not physical links**, +and that is not a weakening: NimBLE establishes a second central's link *before* it +calls `onConnect` ([ble_transport_esp32.cpp:81-93](../src/ble_transport_esp32.cpp)) and +the server API has no pre-connection filter, so a transient second *physical* link +necessarily exists while it is being refused. What R1 constrains is what is +*serviceable*; what R3's callback-side filtering constrains is what that transient link +can touch, which is nothing. An implementation that reports "two links were briefly up" +is conforming; one where the second link moved any shared state is not. + +(`serviceBleDisconnectCleanup`'s `ownerStillUp` guard is **already** unconditional as of +PR `#132`, [main.cpp:404-409](../src/main.cpp) — Phase 3 adds policy, not that +restructuring.) + +### Verification + +Admission (7a): two centrals against one ESP32, second always refused whatever the +incumbent is doing; **row 4** — a contender reusing the incumbent's handle after a stale +link is refused, which is the epoch's whole justification and the one case a +handle-only implementation passes by accident; **row 10** — a contender still connected +when the incumbent departs is *not* admitted: it stays refused and the slot goes to the +next fresh connect; BLE⇄LAN arbitration both directions; a +second LAN client refused rather than evicted, with the first's transfer surviving. + +Refusal is inert (R3), the property most likely to be got wrong since refusal and +teardown sit in the same handler: a refused stranger's connect **and** disconnect leave +the incumbent's transfer, crypto session, notify state and panel power untouched — check +the `esp32-N4` no-WiFi path specifically. Two coalesced connects (both arriving inside one +refresh block) still end with both contenders refused, which is the table-scan property +rather than an event-handler one. + +Idle drop (7c): a client that connects, authenticates and idles past the timeout is +dropped; a fresh client gets the full window before its first command (the init fix); a +streaming client is not dropped; a keepalive-sending client is not; **a client that goes +silent mid-upload IS dropped** — the R4 case, and the one an earlier `!transferActive()` +gate would have exempted; a client engaged either side of a ~16 s refresh is **not** +dropped (the `endRefresh()` re-stamp). After a drop the device returns to +advertising/idle, and the slot is claimable by a new client in the same pass a disconnect +freed it (R7d step 1 before step 2). + +--- + +## Phase 4 — Auth-abuse disconnect + +**Goal:** drop the link after a bounded run of BLE commands that never authenticate, +so an unauthenticated peer cannot hold the exclusive slot (on ESP32, the *only* slot +the owner token would otherwise hand it) indefinitely. + +### Design (fresh — a prototype exists off-branch but is not adopted wholesale) + +`feat/nonce-replay-and-auth-guard` carries `fbc7ab2`/`b4fafb5`, which implement this +but (a) drop the link **inline** on nRF — flagged as loop-starving — and (b) place +two `serviceBleAuthAbuseDisconnect()` call sites in the per-target loop arms that +`#132` then merged, so they no longer have a home. Reuse the *counter* logic; drop +the placement. + +- **Count only BLE.** A per-session counter of consecutive commands answered with + `RESP_AUTH_REQUIRED`, incremented **only when `g_commandOrigin == ORIGIN_BLE`**. + The generic auth gate at [communication.cpp:584,591](../src/communication.cpp) and + the config-write sites at `:410,472` are also reachable via the LAN-TLS bypass, + where app-layer auth is intentionally unnecessary; counting those without the + origin gate would let LAN-TLS traffic increment a counter that disconnects **BLE**. + Reset to 0 on any authenticated command. +- **Threshold 10** (justify against the client's legitimate handshake, which + authenticates within one exchange — 10 is generous). Overflow raises + `s_authAbuseDropPending`; a loop-serviced `serviceBleAuthAbuseDisconnect()` handles + it. One placement, both targets — the whole reason Phase 2's seam and the unified + loop exist. Per the threshold discipline above, the count lives `#ifndef`-guarded in + `communication.cpp` beside the auth gate that increments it, and + `OD_AUTH_ABUSE_FLUSH_MS` beside the servicer that enforces it — not in a shared + header. +- **Best-effort delivery of the final `FE` before dropping — a real barrier, not one + flush, and honestly not a guarantee.** The last `00 xx FE` *should* reach the client + so it is not dropped without a stated reason. A single `serviceBleTx()` then + disconnect does not even get the frame to the stack reliably: TX deliberately + retains an entry on mbuf backpressure or a missing CCCD + ([command_queue.cpp:190](../src/command_queue.cpp)), and the final response may not + even enqueue if the 10-slot ring is full. So the drop is gated on a bounded barrier: + `serviceBleAuthAbuseDisconnect()` drains TX each loop pass and proceeds only once the + TX ring has drained the `FE` **or** a bounded deadline (`OD_AUTH_ABUSE_FLUSH_MS`, + ~500 ms) elapses — then it drops regardless, so a wedged/un-draining client cannot keep + the abuser attached. **An empty ring proves stack acceptance, not receipt**: the ring + advances when `notify()` returns true ([command_queue.cpp:190-199](../src/command_queue.cpp)), + which means NimBLE queued an *unacknowledged* notification — nothing confirms it went + on air. So after the drain, the servicer dwells + `min(remaining deadline, one negotiated connection interval + margin)` before + dropping. The interval is the central's choice, not ours; today both targets read the + negotiated value only inside link-tune *logging* + ([ble_transport_esp32.cpp:74-77](../src/ble_transport_esp32.cpp), + [ble_transport_nrf.cpp:81](../src/ble_transport_nrf.cpp)) and `BleTransport` exposes + no accessor — so Phase 2's transport work adds one (`connIntervalMs(handle)`, or a + value published at the link-tune callback), with a conservative fallback + (`OD_AUTH_ABUSE_DWELL_FALLBACK_MS`, ~50 ms) for when no negotiated value has been + seen. **Any dwell truncated by the deadline — including to zero — is the best-effort + case and may forfeit the `FE`**; only a drain early enough for the full + interval-plus-margin dwell makes on-air delivery *expected* rather than hoped for. + That is as far as best-effort can go without an indication — a wire change this plan + is forbidden. + Then `abortToKnownState(dropLink=true)`, whose step 10 is itself the R3a bounded + wait for link-down before the token is released. + + **Two bounded waits in sequence, and they compose rather than conflict** — this is the + shape CONNECTION_POLICY R3a predicts. The flush barrier runs *before* the abort because + the abort's step 2 deliberately skips the client NACK when `dropLink` (the link is about + to go); asking the abort to also hold the link open for a response would put two + contradictory jobs in one routine. So the ordering is: drain the `FE` (bounded) → + `abortToKnownState(dropLink=true)` → request termination and wait for link-down + (bounded) → release. Both waits are bounded, both proceed on expiry, and neither + treats expiry as a failure — but their mechanics differ: the flush barrier spans + loop passes (`serviceBleAuthAbuseDisconnect()` drains TX each pass), while the R3a + wait inside the abort ticks on its plain bounded `delay()`. + +### Depends on + +Phase 2 (the seam and its R3a wait, `abortToKnownState`, the owner token) and Phase 3 +(it slots into the same admission/idle policy layer). + +**Phase 4 is now an OPTIMISATION, not a correctness requirement — this changed +during Phase 3.** An earlier revision of this section said Phase 3 depended on Phase 4 +for one case: because the activity clock stamped any *recognised* command before the +auth gate, a peer flooding recognised-but-never-authenticating commands kept its clock +fresh forever, so only the auth-abuse counter could reach it. The two were called +exhaustive. + +They were not, and the fix removed the dependency rather than patching it. That rule +let `CMD_FIRMWARE_VERSION` pin the slot too — it is dispatched *ahead* of the auth +gate, so it never drew `RESP_AUTH_REQUIRED` and would never have incremented the +counter either. Phase 3 therefore narrowed what counts as activity: the two +handshake/discovery opcodes never stamp the clock in any configuration, and where an +auth gate exists a command must be past it. + +The consequence for this phase: a peer that never authenticates now ages normally and +**the idle timeout drops it after `OD_BLE_IDLE_TIMEOUT_MS`**. Phase 4 no longer closes +a hole; it shortens a 120 s reclaim to roughly one exchange, and gives the client an +explicit reason (`RESP_AUTH_REQUIRED`, then a deliberate drop) instead of a silent +timeout. Worth having, and cheap — but it should be scheduled on that value, not on a +correctness argument that no longer applies. + +### Verification + +A BLE peer sending N unauthenticated commands is dropped at the threshold with the +`FE` observed **on air** first *when the drain and the full interval-plus-margin dwell +both complete inside the deadline* (a sniffer, necessarily — ring state proves only +stack acceptance, and the barrier is the subtle part); a deadline-truncated dwell may +forfeit the `FE` by design; +the drop still happens within the deadline if the client stops reading; a legitimate +client authenticating on its first exchange is never dropped; the counter resets +across a good command; **LAN-TLS traffic never increments it**; on nRF the drop is not +loop-starved. + +--- + +## Verification model + +Every phase distinguishes two states, because "the code merged" and "the gap +closed" are not the same claim. Phase 1 is the live example: it is shipped and +host-tested, yet its entire hardware matrix is unrun — landed, not closed. + +- **Landed** = builds on all envs + host tests pass. A phase may merge here. +- **Closed** = its companion HIL script has passed on **both** an nRF and an ESP32 + board. The plan tracks a phase as open until then. + +The HIL scripts are the executable form of each Verification section, under +`tests/`, pytest driving a real device through `py-opendisplay`/bleak +(`tests/serial_stall_test.py` is the existing template): + +| Phase | Script | Asserts | +|---|---|---| +| 1 (retroactive) | `test_nonce_gap.py` | a transfer survives a forced >256 forward counter gap; a nonce-dropped `0x0081` frame is repaired by the client's SACK path and the upload completes | +| 2 | `test_abort_state.py` | disconnect mid-{direct, partial, pipe, chunked-config, refresh}; every flagged state clean afterward, touch resumed, crypto cleared, both rings reset, WARM panel survives; a frame written by the departing owner during the teardown window never dispatches after release (the requirement-6 tag); a buzzer melody and LED pattern in flight at the abort **keep playing to completion**; deep sleep entered mid-transfer wakes with no residue (R7e row 3) and **silences a playing buzzer/LED without waiting for it** — with the pin confirmed quiet through sleep, not merely the state flag cleared; power-latch off still sounds its shutdown chirp; the drop holds the token until link-down (R3a) | +| 2 | `test_link_isolation.py` | a gatecrasher's writes are dropped at the callback while the token is held; its subscribe does not move the incumbent's notify state; **it receives no notifications** — the live-leak fix, needs a second reader or a sniffer; `linkMsSinceOwnerCommand()` is 0 when unowned, ages on true silence, is **not** refreshed by malformed or unknown-opcode frames, and is re-stamped across a refresh; a stale-epoch frame left queued across a reconnect neither dispatches nor stamps the clock | +| 3 | `test_exclusivity.py` | two centrals against one ESP32 → second always refused, incumbent idle or transferring; **7a row 4** — a contender reusing the incumbent's handle after a stale link is refused (the epoch case a handle-only build passes by accident); **7a row 10** — a contender still connected when the incumbent departs stays refused, and the slot goes to the next fresh connect; two connects coalesced inside one refresh block still end with both refused (the table-scan property); a second LAN client is refused, not evicted, and the first's transfer survives; BLE⇄LAN arbitration both directions; refused-stranger connect **and** disconnect do not tear down the incumbent (the `esp32-N4` no-WiFi path) | +| 3 | `test_idle_drop.py` | a fresh silent client survives its first window then is dropped; a streaming client is not; a keepalive-sending client is not; **a client silent mid-upload IS dropped** (the R4 case a transfer gate would exempt); a client engaged either side of a ~16 s refresh is not; a LAN flooder sending unrecognised bytes is dropped at 30 s despite the traffic; the device returns to advertising after the drop | +| 4 | `test_auth_abuse.py` | N unauthenticated BLE commands → drop at the threshold with the `FE` on air first when the drain and full dwell complete inside the deadline (sniffer — ring state proves only stack acceptance); drop still occurs within the deadline if the client stops reading, forfeiting the `FE` by design; a first-exchange auth is never dropped; the counter resets across a good command; LAN-TLS never increments it; on nRF the drop is not loop-starved | + +**Threshold drift is caught in the client's CI, not ours.** These thresholds +assume specific `py-opendisplay` behaviours (handshake authenticates +within one exchange; retransmits carry fresh, higher counters; keepalive cadence). +Add an assertion of each to `py-opendisplay`'s test suite, so a client change that +would invalidate a firmware constant breaks *there* — the same move already used +for the `0x04`-NACK reasoning recorded in `sendPipeNack()`. Every +threshold-triggered drop also logs at WARN with the measured value, so field tuning +has data rather than guesses. + +## Cross-cutting: what still has no watchdog + +Two distinct gaps, both out of scope, both named here rather than assumed away. + +**A stuck refresh (CONNECTION_POLICY R5).** R4 excludes refresh from idleness — the +`endRefresh()` re-stamp is precisely that exclusion — so **a refresh that never completes +is not caught by the idle timeout, by construction**. That is a deliberate trade, not an +oversight: without the exclusion, an actively engaged client is dropped the instant a +~16 s refresh ends. But it means the exposure moves rather than closing, and on FastEPD +targets it is total: `fastepd_wait_refresh()` ignores its timeout argument outright +([display_fastepd.cpp:277-280](../src/display_fastepd.cpp)), so the naive "panel never +signals done" case is fully unbounded. The `bb_epaper` path is bounded at 60 s +([display_service.cpp:803-831](../src/display_service.cpp)), which is a bound but not a +useful one for a session policy. + +R5 names the shape of the fix and this plan does not build it: no loop-serviced watchdog +can observe a stuck refresh, because `loop()` is blocked for its entire duration, so it +needs an independent timebase (hardware WDT fed from `loop()`, a timer ISR, or a separate +task); recovery must run from a safe context, which realistically means an MCU reset +rather than panel/SPI teardown from an ISR; and there is no refresh start timestamp in the +tree, so the watchdog must add one. The one thing Phase 2 contributes toward it is +`endRefresh()`: a single helper both bracket sites call is the natural place a future +start/stop timestamp pair lands. + +**Loop liveness.** None of Phases 2–4 add a loop-liveness monitor either. A `loop()` +genuinely wedged inside a non-yielding operation is still uncaught on nRF (no watchdog) +and on ESP32 (`loop()` unsubscribed from the TWDT). The realistic mitigation — subscribe +`loop()` to the ESP32 TWDT and add an nRF hardware WDT fed from `loop()` — is a separate +effort whenever it is +taken up; it is the true "supervisor," and it is none of the four phases here. + +## Deliberately not changed + +- No wire/protocol/config-schema change (hard constraint). +- No `include/opendisplay_protocol.h` or `include/opendisplay_structs.h` edit — which is + what forces `OD_BLE_IDLE_TIMEOUT_MS` to be firmware-local while `OD_LAN_READ_TIMEOUT_S` + stays a client-visible contract in the wire header. +- **No per-connection command queue.** CONNECTION_POLICY's hard constraint: one RX ring + and one TX ring, shared by all transports. The instance table this plan adds is + metadata only (~8 bytes per slot); nothing that holds frames is ever replicated per + connection. Callback-side write filtering (Phase 2 requirement 1) is what makes that + possible — with only the owner's frames entering the ring there is never a second + client's traffic to separate. The requirement-6 identity tag adds four bytes per + slot — per-frame metadata in the one ring, never a second ring — and supersedes + `bleRxQueueDiscardTo`'s boundary flush, which Phase 2 retires. +- The from-START transfer watchdog stays as the backstop; Phase 3's idle drop is + additive, not a replacement. +- The orphaned-pipe healer in `checkTransferTimeouts()` stays a plain + `resetPipeWriteState()` — it repairs an internal invariant, and dropping a healthy + client's link over a bookkeeping error would be disproportionate. +- The nonce subsystem (Phase 1) is not reopened. + +## Residual risk (honest list) + +These are the gaps this plan **cannot** design away, distinct from the ones it now +tracks as work (HIL verification, and the client-side drift assertions — those have owners +and exit criteria above, so they are no longer "risk"). Threshold *selection* is no longer +on either list: every value except the R3a wait bound is settled above. + +- **No loop-liveness watchdog** (see the watchdog section above). A `loop()` + wedged inside a non-yielding operation is still uncaught on nRF, and a true hard + fault is unrecoverable there. Deliberately left as a separate future effort. +- **No refresh watchdog, and the exposure is now *explicit* rather than latent** + (CONNECTION_POLICY R5). R4's refresh exclusion is a deliberate hole in the idle + timeout: a refresh that never completes is not caught, and cannot be, since the clock + is re-stamped at the transition. On FastEPD targets there is no bound anywhere in the + path. This plan makes the situation no worse — the exclusion only ever *delays* a drop + — but it does make the idle timeout unable to serve as an accidental backstop, which + before R4 it arguably was. R5 is the named owner of the gap; it is not scheduled here. +- **The R3a wait can expire with the link still up.** The bound is sized for a few + connection intervals, so an unresponsive peer can outlast it. This is the least + consequential item on the list because expiry is an early exit rather than a failure: + the abort runs regardless, and the stale link is inert by construction — its writes are + filtered as non-owner and its late disconnect is inert on stale epoch. The residual + exposure is a physical link lingering until the link layer reaps it at ~4–6 s, holding + no slot and touching nothing. +- **Thresholds remain heuristics even though they are settled.** *Settled* means decided + and not gated on a measurement — it does not mean proven. The mandatory + client-behaviour comment on each define, plus the client-side assertions, make the + assumptions legible and drift-detectable, but the numbers are still judgement calls + against a client that can change. The auth-abuse drop is self-limiting (a + wrongly-dropped client reconnects). The one that carries real weight is + `OD_BLE_IDLE_TIMEOUT_MS` (120 s): with admission refusing rather than evicting, it is + the sole path by which a held slot is ever reclaimed, and with R4 removing the transfer + gate it can also terminate a live upload. It is set generously precisely because the + second error is the worse one — but that trade is a judgement, and it is the number to + revisit first if field behaviour disappoints. The residual exposure it accepts is a + returning client waiting up to 120 s behind a stale-but-alive incumbent. +- **A wedged transfer is now mostly caught, but not entirely.** CONNECTION_POLICY R4 + removed the `!transferActive()` gate, so the common wedge — a client that starts a + transfer and *goes silent* — is dropped by the idle timeout like any other silent + client. What remains uncaught is narrower: a client that keeps sending recognised + commands while its transfer never completes. That one is still bounded only by + `TRANSFER_WATCHDOG_MS`, because the from-START watchdog is a total-duration bound + rather than a stall timeout. The full fix is a genuine stall timeout gating on + *transfer active **and** progressing*, using the same activity clocks Phase 2 and LAN + already provide; it is a candidate for the next phase after this plan, alongside the + loop-liveness watchdog. +- **"Closed" depends on hardware nobody has run yet.** The verification model makes + this explicit rather than papering over it: until the HIL scripts pass on both an + nRF and an ESP32 board, every phase — including Phase 1 — is landed, not closed. diff --git a/docs/PLAN_PHASE0_LINK_DROP_SEAM_2026-07-31.md b/docs/PLAN_PHASE0_LINK_DROP_SEAM_2026-07-31.md new file mode 100644 index 0000000..de91bad --- /dev/null +++ b/docs/PLAN_PHASE0_LINK_DROP_SEAM_2026-07-31.md @@ -0,0 +1,159 @@ +# Phase 0 — BLE Link-Drop Seam (2026-07-31) + +> **SUPERSEDED 2026-07-31 — do not implement from this document.** +> +> Both deliverables were folded into **Phase 2 (BLE-HAL foundation)** of +> [`PLAN_FREEZE_HARDENING_2026-07-31.md`](PLAN_FREEZE_HARDENING_2026-07-31.md), which +> is the live plan. Read that instead; this file is kept only for the reasoning trail. +> +> Two things here are **out of date** and were corrected in the fold: +> +> - **The seam signature.** This document specifies `disconnect(uint8_t reason)`. The +> live plan specifies `disconnect(uint16_t handle)` with 0x13 hard-coded and *no* +> reason parameter. Both stacks were read to settle it: Bluefruit's +> `disconnect(uint16_t conn_hdl)` (`bluefruit.h:171`) has no reason parameter at all +> — it delegates to `sd_ble_gap_disconnect(_conn_hdl, BLE_HCI_REMOTE_USER_TERMINATED_CONNECTION)` +> (`BLEConnection.cpp:206`) — and NimBLE already *defaults* its reason to 0x13 +> (`NimBLEServer.h:66`). A handle is what both stacks genuinely take, and Phase 3's +> admission policy needs to drop a *specific* link, not "the current one". +> - **The phase numbering.** References below to "Phases 2, 4 and 5" are the earlier +> five-phase draft. The live plan has four phases; the seam is in Phase 2. +> +> Deliverable 2 (the ESP32 disconnect-reason truncation fix) and the deferred +> `OdDiscReason` classifier carried over unchanged, and now live in the live plan's +> Phase 2 seam section. + +The foundational seam for [`PLAN_FREEZE_HARDENING_2026-07-31.md`](PLAN_FREEZE_HARDENING_2026-07-31.md). +Phases 2, 4 and 5 all need to **drop a BLE link from the loop task**, and none can +today. This phase adds that one capability, plus the minimal fix to stop the +disconnect-reason log from lying. + +No wire change (a disconnect reason is an HCI byte, not an app-protocol field). + +## Scope decision — why this is small + +An earlier draft of this phase also normalized the *inbound* disconnect reason into +a six-value enum. That was cut after checking what actually consumes it: **nothing +in Phases 2–5 branches on why a link dropped.** + +- Phase 2 (owner token) releases and tears down on *any* disconnect. +- Phase 3 (abort) runs the same teardown regardless of reason. +- Phases 4 and 5 *initiate* the drop, and their authoritative "did I cause this" is + a `*DropPending` flag (see [Deliverable 1](#deliverable-1)), not the reason byte. + +So the normalized reason would feed a log line and nothing else. A classification +layer nothing consumes is not worth its surface. It is deferred to +[Deferred](#deferred-until-something-consumes-it) — a small header and a `switch`, +cheap to add the day a phase branches on a reason (repeated-MIC-failure handling is +the likely first customer). + +What is **not** deferred is the outbound drop, and the one honest bug in the +current reason handling. + +## Deliverable 1 — `BleTransport::disconnect(uint8_t reason)` + +Add to the abstraction ([ble_transport.h](../src/ble_transport.h)); implement per +target; call **only from the loop task**. + +- **ESP32:** `s_server->disconnect(s_connHandle, reason)` when `s_connHandle != + BLE_HS_CONN_HANDLE_NONE`. Return the call's bool; log WARN on failure. +- **nRF:** `Bluefruit.disconnect(s_connHandle)` when `s_connHandle != + BLE_CONN_HANDLE_INVALID`; keep `restartOnDisconnect(true)` (unlike the DFU path at + [device_control.cpp:857](../src/device_control.cpp), which disables it). + +**Reason to send: `0x13`** (`BLE_ERR_REM_USER_CONN_TERM` / +`BLE_HCI_REMOTE_USER_TERMINATED_CONNECTION`, identical in both stacks and in the +Core Spec's legal `HCI_Disconnect` allowlist). **Do NOT send `0x09`** (`CONN_LIMIT`): +it is not a legal host-disconnect reason, so the controller rejects it (0x12) while +the code looks like it worked and the gatecrasher stays connected. Neither constant +exists in `src/` today; add one named constant with a comment carrying the 0x09 +trap. + +**Loop-task only.** The call is made from a loop-serviced helper (or inline in the +existing `serviceBle*` helpers), never a stack callback — a callback that severs its +own link mid-dispatch is exactly the class of bug `#132` removed. The phases that +request a drop do so by raising a `*DropPending` flag; the loop services it. That +flag — not any reason byte read back afterward — is the authoritative record of a +self-initiated drop. + +## Deliverable 2 — stop the disconnect-reason log from lying (ESP32) + +Not a new feature; a correctness fix to what is already logged at +[main.cpp:472](../src/main.cpp). Today ESP32 stores the reason wrong: + +```cpp +// ble_transport_esp32.cpp:35,99 +static volatile uint8_t s_disconnectReason = 0; +... +s_disconnectReason = (uint8_t)reason; // int -> uint8_t: truncates +``` + +NimBLE's `onDisconnect(int reason)` uses two ranges: HCI reasons wrapped as +`BLE_HS_ERR_HCI_BASE + code` (`0x200 + code`), and host-layer `BLE_HS_E*` codes in +`1..31`. The `uint8_t` cast keeps only the low byte, so: + +- an HCI reason survives by luck (`0x213 & 0xFF == 0x13`), but +- a host code like `BLE_HS_ENOTCONN = 7` truncates to `0x07`, which reads back as + the unrelated HCI code "memory capacity exceeded". The stored byte is ambiguous + and the log can name the wrong reason. + +nRF is unaffected — it stores a raw HCI `uint8_t` from the SoftDevice with no +wrapping. + +**Fix (ESP32 only, ~3 lines):** widen `s_disconnectReason` to `uint16_t` so the +`0x200` offset survives capture, and log the raw value as-is: + +```cpp +static volatile uint16_t s_disconnectReason = 0; +... +s_disconnectReason = (uint16_t)reason; // keep the full value, no truncation +``` + +`takeDisconnectedEvent`'s out-param widens to `uint16_t*` +([ble_transport.h:81](../src/ble_transport.h), one caller at +[main.cpp:471](../src/main.cpp)), and the log line becomes +`"Disconnect reason: 0x%03X"` so a wrapped HCI reason (`0x213`) and a host reason +(`0x007`) are visibly distinct rather than colliding on `0x13`/`0x07`. No enum, no +classifier, no interpretation — just stop discarding half the value. + +## Files touched + +| File | Change | +|---|---| +| `src/ble_transport.h` | add `disconnect(uint8_t)` + the `0x13`/`0x09` reason constant & comment; widen `takeDisconnectedEvent`'s reason out-param to `uint16_t*` | +| `src/ble_transport_nrf.cpp` | implement `disconnect()`; reason storage unchanged (already a raw HCI byte, widened only to match the signature) | +| `src/ble_transport_esp32.cpp` | implement `disconnect()`; widen `s_disconnectReason` to `uint16_t`, drop the truncating cast | +| `src/main.cpp` | update the one `takeDisconnectedEvent` caller + its log line | + +No new file, no host test (nothing here is pure logic worth a standalone test — the +drop needs a board; the widening is a type change verified by build + bench log). + +## Verification + +- **Build** all envs. +- **Bench (closes the phase):** on nRF and ESP32, call `disconnect(0x13)` from the + loop task and confirm the link actually drops (the `0x09` trap means "it + compiled" is not enough — watch for the disconnect on a scanner or the client + side). Confirm a real client disconnect logs a sensible reason, and that a + NimBLE host-layer reason now logs as `0x0xx` rather than masquerading as an HCI + code. + +## Deferred until something consumes it + +The normalized inbound reason (a `src/ble_disc_reason.h` with an `OdDiscReason` +enum — `SUCCESS / REMOTE / LOCAL / TIMEOUT / MIC_FAILURE / OTHER` — a pure +`od_disc_classify(uint8_t hci)` switch, the ESP32 `0x200`-offset normalization, and +a host test) is **not built here**. It is deferred until a phase branches on a +reason rather than just logging it. The most likely trigger is MIC-failure-driven +behaviour (0x3D signals encryption desync — the failure class this whole effort +targets), e.g. forcing re-auth after repeated MIC failures. When that lands, the +enum is a small header and a `switch`; the `uint16_t` raw value this phase already +preserves is exactly the input the classifier needs, so nothing here has to be +redone. + +## Out of scope + +- Any behaviour that *acts* on a link drop — that is Phases 2/4/5. Phase 0 only + makes the drop possible and the reason log honest. +- LAN disconnects; the owner token (Phase 2) handles LAN, and TCP has no HCI reason + to preserve. diff --git a/src/ble_transport.h b/src/ble_transport.h index c77af49..6db368c 100644 --- a/src/ble_transport.h +++ b/src/ble_transport.h @@ -11,12 +11,18 @@ // base: virtual dispatch would cost a vtable and indirect calls for zero // benefit, and application code would still only ever see one type. // -// Threading, as of Phase 1: the callback contract is NOT yet symmetric. ESP32 -// stack callbacks are flag-only (they copy into the RX ring and set a flag); -// nRF still dispatches commands inline on the SoftDevice callback task and runs -// the app connect/disconnect hooks there. Phase 3 makes nRF match ESP32. Until -// then the asymmetry lives entirely inside the two implementation files -- see -// docs/PLAN_BLE_TRANSPORT_ABSTRACTION_2026-07-27.md. +// Threading: the callback contract is now SYMMETRIC. On both targets a stack +// callback may copy bytes into the RX ring, publish its own connection-instance +// metadata, attempt the one ownership claim CAS, and set an event flag -- nothing +// else. Command dispatch, decrypt, EPD streaming, notify() and the connect/ +// disconnect application work all run on the loop() task. (An earlier revision of +// this note said nRF still dispatched inline and that a later phase would fix it; +// that landed with the loop/BLE unification.) +// +// The claim CAS is the one addition to the historical "copy and flag" rule, and it +// is deliberate: ownership must be decided at the earliest transport hook, because +// the write filter below has to be able to answer "is this the owner?" long before +// any loop pass runs -- during a refresh, up to ~16 s before one. class BleTransport { public: // --- lifecycle --- @@ -33,9 +39,80 @@ class BleTransport { // --- state --- bool isReady() const; // stack initialised and usable + // The stack's TOTAL peer count. Note what this is NOT: a test for whether one + // particular link is up. CONNECTION_POLICY R1 permits a refused contender to be + // transiently attached, so dropping the owner takes this 2->1, never to 0 -- + // which is why the R3a wait polls instanceLive() per handle instead. Keep using + // this only for "is anything connected at all". uint8_t connectedCount() const; bool isConnected() const { return connectedCount() > 0; } - bool notifyReady() const; // connected AND the client has subscribed (CCCD) + bool notifyReady() const; // owner connected AND subscribed (CCCD), per-instance + + // --- connection instances (CONNECTION_POLICY R2/R3 requirement 5) --- + // A fixed per-handle table, sized by the connection cap (3 on ESP32, 1 on nRF), + // holding metadata only -- never frames. Callbacks write their own handle's + // entry; the loop scans it. That inversion is what makes lost edges stop + // mattering: state is bounded by the connection cap rather than by event rate, + // so there is nothing to overflow and no eviction policy to specify. + // + // Liveness IS the packed (handle, epoch) identity word -- all-zero means empty -- + // so an entry can never present a live identity with a stale state, and the R3a + // wait can read identity and liveness in one atomic load. + bool instanceLive(uint16_t handle, uint16_t epoch) const; + uint8_t liveInstanceCount() const; + // Packed identity word of the i'th live instance, 0 when the slot is empty. + // Phase 3's admission scan walks these to find contenders (any live instance + // that is not the owner); Phase 2 only needs it for diagnostics and the wait. + uint32_t instanceWordAt(uint8_t index) const; + // 0 while this entry's ownership claim is still in flight; otherwise the + // IDENTITY WORD the claim was decided for. Published with release ordering after + // the claim CAS. + // + // Why the identity and not a bool: the loop-side scan reads the entry word, the + // disposition and the owner word separately and cannot get them atomically. A + // bare flag lets it pair one entry's identity with another's disposition after a + // slot is retired and reused (ABA). Requiring decidedWord == the entry word + // proves the disposition belongs to THIS instance. + // + // The distinction is load-bearing either way: refusing an in-flight instance can + // disconnect the connection that is winning the slot, while never refusing one + // leaves a decided loser attached forever -- on nRF, holding the only link. + uint32_t instanceClaimDecidedWordAt(uint8_t index) const; + static uint8_t instanceCapacity(); + + // --- link drop (CONNECTION_POLICY R3a) --- + // Requests termination of ONE link. Returns false only on a genuine failure to + // ask; a true return means "requested", NOT "down" -- BLE disconnect is + // asynchronous, so callers that need the link actually gone must wait on + // instanceLive() (bleDropAndWait() in session_guard.cpp does exactly that). + // + // No `reason` parameter, deliberately. A host-initiated disconnect must send a + // Core-Spec-legal HCI reason; 0x13 (REMOTE_USER_TERMINATED) is the only value + // this firmware wants, it is what NimBLE defaults to, and it is the only value + // Bluefruit can send at all (BLEConnection::disconnect() hardcodes it, with no + // reason argument to pass). Note 0x09 (CONN_LIMIT) is NOT legal here: the + // controller silently rejects it and the peer stays connected while the code + // looks like it worked. + // + // Loop task only. A callback that severs its own link mid-dispatch is exactly + // the class of bug the unified-loop work removed. + // Takes the full instance identity, not just a handle: the transport + // re-validates that (handle, epoch) is still the live instance immediately + // before asking the stack, so a caller acting on a slightly stale scan cannot + // disconnect whoever inherited the numeric handle in the meantime. + // + // RESIDUAL, stated rather than implied: this narrows that window to a few + // instructions but cannot close it, because the stack API is handle-addressed + // and the host task can retire and reassign a handle at any point. Closing it + // fully would need the validate-and-disconnect pair to run on the host task + // itself. The exposure is a spuriously dropped client that reconnects -- not + // stranded ownership. + bool disconnect(uint16_t handle, uint16_t epoch); + + // Negotiated connection interval in ms for `handle`, 0 when unknown. The + // central chooses it; this firmware requests none. Phase 4's TX-flush dwell + // sizes itself on this rather than on a constant. + uint16_t connIntervalMs(uint16_t handle) const; // --- data out --- // false means backpressure ("retry next pass"), not a hard failure: the @@ -70,15 +147,25 @@ class BleTransport { // arriving inside the check-then-clear window is lost. This peek neither // introduces nor worsens that; fixing it is a separate change. bool eventPending() const; - bool takeConnectedEvent(); - // Optionally reports the stack's disconnect reason code, which is otherwise - // lost now that the callback no longer runs application code inline. - // rxBoundary, when requested, is the RX ring head at the instant the link went - // down -- the dividing line between the departed client's queued frames and any - // pushed by whoever connected afterwards. Pass it to bleRxQueueDiscardTo(); a - // flush without it drops the next client's frames whenever loop() was blocked - // long enough for a reconnect to land before this event was serviced. - bool takeDisconnectedEvent(uint8_t* reason = nullptr, uint8_t* rxBoundary = nullptr); + // Reports the connecting instance's identity (packed word, link_owner.h) so the + // loop can act on a specific newcomer. Under requirement 5 this is a hint: the + // instance table, not the event, is the mechanism -- a connect that coalesces + // away still leaves a live table entry. + bool takeConnectedEvent(uint32_t* instanceWord = nullptr); + // Optionally reports the departing instance's identity and the stack's + // disconnect reason, which is otherwise lost now that the callback no longer + // runs application code inline. + // + // `reason` is uint16_t because NimBLE's is not a byte: it uses two ranges, HCI + // reasons wrapped as BLE_HS_ERR_HCI_BASE + code (0x200 + code) and host-layer + // BLE_HS_E* codes in 1..31. The old uint8_t truncation kept only the low byte, + // so an HCI reason survived by luck (0x213 & 0xFF == 0x13) while BLE_HS_ENOTCONN + // (7) read back as the unrelated HCI "memory capacity exceeded". nRF stores a + // raw HCI byte with no wrapping and is unaffected. + // + // The rxBoundary out-param is GONE: the RX-boundary mechanism it fed is retired + // in favour of per-frame identity tags (CommandQueueItem::tag). + bool takeDisconnectedEvent(uint16_t* reason = nullptr, uint32_t* instanceWord = nullptr); // --- identity --- const char* addressString(); // advertised BLE address, lowercase "aa:bb:.." diff --git a/src/ble_transport_esp32.cpp b/src/ble_transport_esp32.cpp index 79348e7..2df8d80 100644 --- a/src/ble_transport_esp32.cpp +++ b/src/ble_transport_esp32.cpp @@ -5,9 +5,14 @@ // environments. Every NimBLE object lives here as file-static state; nothing // outside this file names a NimBLE type. // -// Threading contract (already holds here, and is what Phase 3 brings to nRF): -// NimBLE host-task callbacks do exactly two things -- copy bytes into the RX -// ring, and set a flag. Everything else runs on the loop() task. +// Threading contract, identical on both targets: a NimBLE host-task callback may +// copy bytes into the RX ring, publish its own instance-table entry, attempt the +// single ownership claim CAS, and set an event flag. Everything else -- dispatch, +// decrypt, EPD streaming, notify(), the connect/disconnect application work -- +// runs on the loop() task. +// +// The claim is the one thing beyond "copy and flag", and it belongs here because +// the write filter must be able to test ownership before any loop pass runs. #ifdef TARGET_ESP32 #include @@ -16,6 +21,7 @@ #include "ble_transport.h" #include "ble_transport_esp32.h" #include "command_queue.h" +#include "link_owner.h" #include "structs.h" #include "od_log.h" @@ -29,22 +35,105 @@ static BLEService* s_service = nullptr; static BLECharacteristic* s_txCharacteristic = nullptr; static BLEAdvertisementData s_advertisementData; -static volatile bool s_notifySubscribed = false; static volatile bool s_connectedEvent = false; static volatile bool s_disconnectedEvent = false; -static volatile uint8_t s_disconnectReason = 0; -// RX ring head at the instant the link dropped: the boundary between the departed -// client's queued frames and anything the next client pushes. Captured in the -// disconnect callback because that is the only moment it is knowable -- by the time -// loop() services the event, a reconnect may already have queued frames of its own. -static volatile uint8_t s_rxBoundaryAtDisconnect = 0; -static volatile uint16_t s_connHandle = BLE_HS_CONN_HANDLE_NONE; +// NimBLE's reason is an int spanning two ranges (HCI wrapped at 0x200+, host-layer +// 1..31), so a uint8_t here silently aliased them onto each other. See +// takeDisconnectedEvent(). +static volatile uint16_t s_disconnectReason = 0; +static volatile uint32_t s_connectedWord = 0; // identity of the last connect +static volatile uint32_t s_disconnectedWord = 0; // identity of the last disconnect + +// --- the instance table (CONNECTION_POLICY R3 requirement 5) ----------------- +// Sized by the connection cap. CONFIG_BT_NIMBLE_MAX_CONNECTIONS is 3 in the +// precompiled sdkconfig.h for S3/C3/C6 and absent for classic ESP32 (NimBLE's own +// #ifndef default is also 3), and a -D override is inert because the precompiled +// header wins -- which is exactly why exclusivity must be enforced in firmware +// rather than by config. +// +// Each entry's packed identity word IS its liveness: all-zero means empty. There is +// no separate `state` field, so a reader can never see a live identity with a stale +// state. Written on the NimBLE host task, read on the loop task; the word is the +// synchronisation point, and `reason`/`subscribed` are only read once it says down +// or under owner comparison. +#ifndef OD_BLE_MAX_INSTANCES +#define OD_BLE_MAX_INSTANCES 3 +#endif + +struct BleInstance { + volatile uint32_t word; // packed (OWNER_BLE, handle, epoch); 0 = empty + // Per-link CCCD state (requirement 2). Written on the NimBLE host task by + // onSubscribe, read on the loop task by notifyReady, so every access goes + // through __atomic_*: `volatile` orders nothing and does not make a concurrent + // read/write anything but a data race in C++. + volatile uint8_t subscribed; + // Claim disposition, published with RELEASE after the CAS resolves: 0 while the + // claim is in flight, otherwise the IDENTITY WORD the claim was decided for. + // + // It carries the identity rather than a bare flag because the loop-side scan + // reads several fields and cannot get them atomically. A boolean lets two + // distinct hazards through: the scan could pair entry w1 with a disposition that + // actually belongs to w2 after the slot was retired and reused (ABA), and it + // could not tell "resolved for THIS instance" from "resolved for whoever holds + // this slot now". Matching decidedWord against the entry word proves both. + // + // The distinction itself is load-bearing: refusing an in-flight instance can + // disconnect the connection that is winning the slot, while never refusing one + // leaves a decided loser attached forever -- on nRF, holding the only link. + volatile uint32_t decidedWord; +}; +static BleInstance s_instances[OD_BLE_MAX_INSTANCES]; + +// Search by handle rather than indexing by it. NimBLE allocates from 0 upward in +// practice, so direct indexing usually works, but a 3-entry linear search costs the +// same at this size and cannot be broken by a stack change that hands out sparse +// handles. +static int instanceIndexOf(uint16_t handle) { + for (int i = 0; i < OD_BLE_MAX_INSTANCES; i++) { + const uint32_t w = __atomic_load_n(&s_instances[i].word, __ATOMIC_ACQUIRE); + if (w != 0 && linkUnpackWord(w).handle == handle) return i; + } + return -1; +} + +static uint32_t instancePublish(uint16_t handle, uint16_t epoch) { + const uint32_t w = linkPackWord(OWNER_BLE, handle, epoch); + for (int i = 0; i < OD_BLE_MAX_INSTANCES; i++) { + uint32_t expected = 0; + if (__atomic_compare_exchange_n(&s_instances[i].word, &expected, w, + false, __ATOMIC_RELEASE, __ATOMIC_RELAXED)) { + __atomic_store_n(&s_instances[i].subscribed, (uint8_t)0, __ATOMIC_RELAXED); + __atomic_store_n(&s_instances[i].decidedWord, (uint32_t)0, __ATOMIC_RELEASE); + return w; + } + } + // Cannot happen while the table is sized at the connection cap; if the stack + // ever exceeds it, the link is unrepresentable and therefore unserviceable. + od_log_error("ERROR: BLE instance table full, handle %u unrepresentable", (unsigned)handle); + return 0; +} + +static void instanceRetire(uint16_t handle) { + const int i = instanceIndexOf(handle); + if (i < 0) return; + __atomic_store_n(&s_instances[i].subscribed, (uint8_t)0, __ATOMIC_RELAXED); + __atomic_store_n(&s_instances[i].decidedWord, (uint32_t)0, __ATOMIC_RELAXED); + __atomic_store_n(&s_instances[i].word, (uint32_t)0, __ATOMIC_RELEASE); +} + +static void instancesClear() { + for (int i = 0; i < OD_BLE_MAX_INSTANCES; i++) { + __atomic_store_n(&s_instances[i].subscribed, (uint8_t)0, __ATOMIC_RELAXED); + __atomic_store_n(&s_instances[i].decidedWord, (uint32_t)0, __ATOMIC_RELAXED); + __atomic_store_n(&s_instances[i].word, (uint32_t)0, __ATOMIC_RELEASE); + } +} static void clearHandles() { s_server = nullptr; s_service = nullptr; s_txCharacteristic = nullptr; - s_notifySubscribed = false; + instancesClear(); } // --- link diagnostics (implementation-private) ------------------------------ @@ -80,35 +169,79 @@ static void logNegotiatedLink(NimBLEConnInfo& info, const char* trigger) { class OdServerCallbacks : public BLEServerCallbacks { void onConnect(NimBLEServer* pServer, NimBLEConnInfo& connInfo) override { (void)pServer; - od_log_info("=== BLE CLIENT CONNECTED (ESP32) ==="); - s_notifySubscribed = false; - // Captured here because it is the only place NimBLE hands it to us; the - // link tuning that consumes it runs later, on the loop task. - s_connHandle = connInfo.getConnHandle(); - // Flag-only. The app work this implies (rebootFlag reset, updatemsdata() - // -- which polls I2C and mutates the shared advertisement vector that - // loop() also drives on a 60 s cadence) would corrupt the heap if run - // here on the NimBLE host task. loop() consumes the event instead. - s_connectedEvent = true; + const uint16_t handle = connInfo.getConnHandle(); + // ORDER IS NORMATIVE (R2): allocate the epoch, publish the table entry, + // THEN attempt the claim -- so a successful claim never names an instance + // the loop cannot yet see. + // + // The epoch is allocated for EVERY instance, admitted or not. Allocating on + // successful claim instead (an earlier draft) would leave a refused + // contender with no epoch, making 7a row 4 -- a contender that reuses the + // incumbent's handle after a stale link -- indistinguishable from the + // incumbent. Identity is what the admission decision is MADE on, so it must + // precede the decision. + const uint16_t epoch = linkNextEpoch(); + const uint32_t word = instancePublish(handle, epoch); + // The claim itself, here rather than on the loop task: this is the earliest + // transport hook, and R7d makes it the authoritative arbitration point. A + // CAS win IS admission; a loss makes this instance a contender, whose writes + // the filters below drop from its very first frame. Phase 3 adds the policy + // that actively disconnects it. + const LinkId id = { OWNER_BLE, handle, epoch }; + const bool admitted = (word != 0) && linkClaim(id); + // The claim has resolved; publish that fact so the loop-side refusal scan + // can tell this instance from one whose CAS has not run yet. + { + const int di = instanceIndexOf(handle); + // Publish the identity the claim resolved FOR, not merely that it + // resolved. NimBLE serialises host callbacks, so this handle cannot be + // retired and reused underneath us here. + if (di >= 0) __atomic_store_n(&s_instances[di].decidedWord, word, __ATOMIC_RELEASE); + } + od_log_info("=== BLE CLIENT CONNECTED (ESP32) h=%u e=%u %s ===", + (unsigned)handle, (unsigned)epoch, + admitted ? "[owner]" : "[contender - refused service]"); + // Payload before flag, flag RELEASE-stored, so a consumer that sees the flag + // is guaranteed to see this word. A plain store here against the consumer's + // atomic exchange would be a data race with nothing for its acquire to pair + // against. + __atomic_store_n(&s_connectedWord, word, __ATOMIC_RELAXED); + // Flag-only beyond the claim. The app work a connect implies (rebootFlag + // reset, updatemsdata() -- which polls I2C and mutates the shared + // advertisement vector that loop() also drives on a 60 s cadence) would + // corrupt the heap if run here on the NimBLE host task. loop() consumes the + // event instead. The claim is exempt because it is one CAS on one word. + __atomic_store_n(&s_connectedEvent, true, __ATOMIC_RELEASE); } void onDisconnect(NimBLEServer* pServer, NimBLEConnInfo& connInfo, int reason) override { (void)pServer; - (void)connInfo; - od_log_info("=== BLE CLIENT DISCONNECTED (ESP32) ==="); - s_notifySubscribed = false; - s_disconnectReason = (uint8_t)reason; - s_connHandle = BLE_HS_CONN_HANDLE_NONE; - // Producer-task read of the producer-owned head: no synchronisation needed, - // and within the copy-and-flag contract. Nothing this client can still send - // exists past this point -- the link is gone -- so this is exactly the last - // frame of the departed session. - s_rxBoundaryAtDisconnect = bleRxQueueHead(); + const uint16_t handle = connInfo.getConnHandle(); + const int idx = instanceIndexOf(handle); + const uint32_t word = (idx >= 0) ? __atomic_load_n(&s_instances[idx].word, __ATOMIC_ACQUIRE) : 0; + od_log_info("=== BLE CLIENT DISCONNECTED (ESP32) h=%u reason=0x%03X ===", + (unsigned)handle, (unsigned)reason); + // Full width: NimBLE's reason spans two ranges and truncation aliases them. + __atomic_store_n(&s_disconnectReason, (uint16_t)reason, __ATOMIC_RELAXED); + __atomic_store_n(&s_disconnectedWord, word, __ATOMIC_RELAXED); + // Retiring the entry is what makes the link's death observable per handle -- + // the predicate bleDropAndWait() polls, and the comparison that lets the loop + // notice a departed owner even when every event edge was lost. + // + // No RX-boundary capture here any more: frames carry their writer's identity + // (CommandQueueItem::tag), so a departed session's frames self-discard at + // dispatch instead of needing a boundary that handle reuse could destroy. + instanceRetire(handle); + // The token is deliberately NOT released here -- see the matching note in + // ble_transport_nrf.cpp. Releasing on this callback would admit a new owner + // while the departed session's state is still live, and RX/TX run before + // the deferred cleanup. Release stays the abort's last step (R3a); a + // reconnecting client that loses its claim is reaped by contender refusal. // Flag-only. The session teardown this implies (EPD force-off with // SPI.end()/rail cut, partial + pipe cleanup) is heavyweight, // state-mutating work that races loop()'s SPI streaming and pipe-frame // processing. loop() consumes the event and applies its own deferral // policy (see serviceBleDisconnectCleanup in main.cpp). - s_disconnectedEvent = true; + __atomic_store_n(&s_disconnectedEvent, true, __ATOMIC_RELEASE); } // Negotiation completes asynchronously, after requestFastLink() returns, so // these are the only points where the granted values are knowable. Both are @@ -126,14 +259,35 @@ class OdServerCallbacks : public BLEServerCallbacks { class OdCharacteristicCallbacks : public BLECharacteristicCallbacks { public: + // Requirement 2: per-link subscribe state. This used to (void) its connInfo and + // write one global, so a contender's subscribe cleared or overwrote the + // incumbent's apparent notify-readiness and stalled its TX. void onSubscribe(NimBLECharacteristic* pCharacteristic, NimBLEConnInfo& connInfo, uint16_t subValue) override { (void)pCharacteristic; - (void)connInfo; - s_notifySubscribed = (subValue & 0x0001) != 0; - od_log_info("BLE notify subscription: %s", s_notifySubscribed ? "enabled" : "disabled"); + const uint16_t handle = connInfo.getConnHandle(); + const int idx = instanceIndexOf(handle); + if (idx < 0) return; + const bool on = (subValue & 0x0001) != 0; + __atomic_store_n(&s_instances[idx].subscribed, (uint8_t)(on ? 1 : 0), __ATOMIC_RELAXED); + od_log_info("BLE notify subscription h=%u: %s", (unsigned)handle, on ? "enabled" : "disabled"); } void onWrite(NimBLECharacteristic* pCharacteristic, NimBLEConnInfo& connInfo) override { - (void)connInfo; + const uint16_t handle = connInfo.getConnHandle(); + // Requirement 1: drop a non-owner's write BEFORE it reaches the RX ring. + // This must happen here, not on the loop task -- during a ~16 s refresh no + // loop-side decision runs at all, which is long enough for a gatecrasher to + // inject a full transfer's worth of commands into the incumbent's stream. + // + // One atomic load of the owner word, compared against this instance's + // identity. That comparison is only possible because the token is a single + // published word; a loop-task-only owner would give this callback nothing to + // test against. + const int idx = instanceIndexOf(handle); + const uint32_t word = (idx >= 0) ? __atomic_load_n(&s_instances[idx].word, __ATOMIC_ACQUIRE) : 0; + if (word == 0 || word != linkOwnerWord()) { + od_log_debug("Dropped write from non-owner h=%u", (unsigned)handle); + return; + } // Keep the raw NimBLEAttValue: converting to Arduino String uses the // C-string (strlen) constructor, which truncates at the first 0x00 byte. // Pipe-write frames start with 0x00 (00 70 / 00 71 / 00 81), so String() @@ -144,7 +298,11 @@ class OdCharacteristicCallbacks : public BLECharacteristicCallbacks { // bleRxQueuePush() owns the arrival log and every drop reason (empty, too // large, ring full) so this callback and nRF's onWriteCb() cannot report the // same frame differently. Add no logging here. - (void)bleRxQueuePush((const uint8_t*)value.c_str(), value.length()); + // + // The frame carries `word` as its tag: the dispatcher re-checks it against + // the live owner word, so a frame that was legitimate on arrival but whose + // session ended before it was drained never executes in the next session. + (void)bleRxQueuePush((const uint8_t*)value.c_str(), value.length(), word); } }; @@ -262,18 +420,112 @@ bool BleTransport::notifyReady() const { if (s_txCharacteristic == nullptr || s_server == nullptr || s_server->getConnectedCount() == 0) { return false; } - // NimBLE auto-creates the 0x2902 CCCD; onSubscribe tracks the client's toggle. - return s_notifySubscribed; + // The OWNER's subscription, not "whoever subscribed last". NimBLE auto-creates + // the 0x2902 CCCD; onSubscribe tracks each client's toggle per instance. + const uint32_t owner = linkOwnerWord(); + if (owner == 0) return false; + const LinkId id = linkUnpackWord(owner); + if (id.who != OWNER_BLE) return false; + const int idx = instanceIndexOf(id.handle); + if (idx < 0) return false; + if (__atomic_load_n(&s_instances[idx].word, __ATOMIC_ACQUIRE) != owner) return false; + return __atomic_load_n(&s_instances[idx].subscribed, __ATOMIC_RELAXED) != 0; } bool BleTransport::notify(const uint8_t* data, uint16_t len) { if (s_txCharacteristic == nullptr) return false; - // notify(data,len) copies the payload into an mbuf immediately, so a + // Requirement 3, and a LIVE LEAK FIX rather than a new feature. This used to + // call notify(data, len) -- the two-argument overload, whose third parameter + // defaults to BLE_HS_CONN_HANDLE_NONE, documented as "send to ALL subscribed + // clients". A second central that connected and subscribed therefore received + // every response the incumbent was sent, authentication traffic included, + // before loop() ran at all and with no policy decision having been made. + // + // Targeting the owner's handle closes it. With no owner there is nobody to + // notify, and returning false leaves the entry queued rather than dropping it. + const uint32_t owner = linkOwnerWord(); + if (owner == 0) return false; + const LinkId id = linkUnpackWord(owner); + if (id.who != OWNER_BLE) return false; + // Re-validate the handle against the table immediately before sending, rather + // than trusting a readiness check that ran earlier in the pass. Between + // notifyReady() and here the host task can retire the owner and hand the SAME + // numeric handle to a contender -- and a bare handle carries no epoch, so + // NimBLE would happily deliver the departed owner's queued response to the + // newcomer, reopening exactly the leak this function exists to close. The + // full-word comparison catches it because the epoch differs. + // + // RESIDUAL, stated rather than papered over: this narrows the window to the few + // instructions between the check and NimBLE's send, but cannot close it. The + // stack can retire a link and reassign its numeric handle at any point on the + // host task, and notify(handle) addresses whatever connection the stack + // currently calls `handle` -- there is no epoch to pass it. Closing it fully + // needs the send to be serialised with the host's connection lifecycle, which + // this API does not offer. What IS closed is the systematic leak: the previous + // two-argument call broadcast every response to ALL subscribed clients, for the + // whole life of a contender's subscription. + const int idx = instanceIndexOf(id.handle); + if (idx < 0) return false; + if (__atomic_load_n(&s_instances[idx].word, __ATOMIC_ACQUIRE) != owner) return false; + // notify(data,len,handle) copies the payload into an mbuf immediately, so a // concurrent client WRITE_NR on this shared RX/TX characteristic cannot // corrupt the outgoing frame (as setValue()+notify() could, since the no-arg // notify sends whatever value is currently stored). On mbuf exhaustion this // returns false -- backpressure, not failure. - return s_txCharacteristic->notify(data, len); + return s_txCharacteristic->notify(data, len, id.handle); +} + +bool BleTransport::instanceLive(uint16_t handle, uint16_t epoch) const { + const int idx = instanceIndexOf(handle); + if (idx < 0) return false; + return __atomic_load_n(&s_instances[idx].word, __ATOMIC_ACQUIRE) == + linkPackWord(OWNER_BLE, handle, epoch); +} + +uint8_t BleTransport::liveInstanceCount() const { + uint8_t n = 0; + for (int i = 0; i < OD_BLE_MAX_INSTANCES; i++) { + if (__atomic_load_n(&s_instances[i].word, __ATOMIC_ACQUIRE) != 0) n++; + } + return n; +} + +uint32_t BleTransport::instanceWordAt(uint8_t index) const { + if (index >= OD_BLE_MAX_INSTANCES) return 0; + return __atomic_load_n(&s_instances[index].word, __ATOMIC_ACQUIRE); +} + +uint32_t BleTransport::instanceClaimDecidedWordAt(uint8_t index) const { + if (index >= OD_BLE_MAX_INSTANCES) return 0; + return __atomic_load_n(&s_instances[index].decidedWord, __ATOMIC_ACQUIRE); +} + +uint8_t BleTransport::instanceCapacity() { return OD_BLE_MAX_INSTANCES; } + +bool BleTransport::disconnect(uint16_t handle, uint16_t epoch) { + if (s_server == nullptr) return false; + // Re-validate the identity as late as possible: the caller's decision to drop + // this link may have been made a few loads ago, and a numeric handle alone does + // not identify a connection over time. + if (!instanceLive(handle, epoch)) return true; // already gone, or reassigned + // BLE_ERR_REM_USER_CONN_TERM (0x13) is what NimBLE defaults to and the only + // reason this seam ever sends; see the header for why 0x09 must not be used. + const bool ok = s_server->disconnect(handle, BLE_ERR_REM_USER_CONN_TERM); + if (!ok) { + // NimBLE already treats "the link is gone" as success (it returns true for + // BLE_HS_ENOTCONN / BLE_HS_EALREADY / UNK_CONN_ID), so a false here is a + // genuine failure to ask, not a benign race with a client that left first. + od_log_warn("WARNING: BLE disconnect request failed for handle %u", (unsigned)handle); + } + return ok; +} + +uint16_t BleTransport::connIntervalMs(uint16_t handle) const { + if (s_server == nullptr) return 0; + NimBLEConnInfo info = s_server->getPeerInfoByHandle(handle); + const uint16_t units = info.getConnInterval(); // 1.25 ms units + if (units == 0) return 0; + return (uint16_t)((units * 5 + 3) / 4); // ceil(units * 1.25) } void BleTransport::setManufacturerData(const uint8_t* msd, uint8_t len) { @@ -309,17 +561,22 @@ void BleTransport::setManufacturerData(const uint8_t* msd, uint8_t len) { // Called from loop() when the connect event is consumed, not from the connect // callback -- these are host-stack calls, which the callback contract excludes. void BleTransport::requestFastLink() { - if (s_server == nullptr || s_connHandle == BLE_HS_CONN_HANDLE_NONE) { - return; - } + // Tune the OWNER's link. This used to read the single s_connHandle scalar, + // which the newest connect overwrote -- so with a contender attached, link + // tuning targeted the wrong link (one of the shared-scalar defects R3 names). + if (s_server == nullptr) return; + const uint32_t owner = linkOwnerWord(); + if (owner == 0) return; + const LinkId id = linkUnpackWord(owner); + if (id.who != OWNER_BLE) return; // 2 Mbps both directions. phyOptions applies only to the CODED PHY, so 0. // The peer may decline and stay at 1M -- not an error. - if (!s_server->updatePhy(s_connHandle, BLE_GAP_LE_PHY_2M_MASK, BLE_GAP_LE_PHY_2M_MASK, 0)) { + if (!s_server->updatePhy(id.handle, BLE_GAP_LE_PHY_2M_MASK, BLE_GAP_LE_PHY_2M_MASK, 0)) { od_log_warn("2M PHY request rejected (staying at 1M)"); } // 251-octet Link-Layer PDUs (max DLE); NimBLE derives the PHY-appropriate // on-air duration itself, so there is no time parameter to pass. - s_server->setDataLen(s_connHandle, 251); + s_server->setDataLen(id.handle, 251); od_log_debug("Requested fast link: 2M PHY + 251-octet DLE"); // No negotiated-parameter logging here yet, unlike nRF: that would need an // equivalent of nRF's delayed one-shot, since negotiation completes after @@ -335,20 +592,45 @@ void BleTransport::tick() { } bool BleTransport::eventPending() const { - return s_connectedEvent || s_disconnectedEvent; + // RELAXED: a non-destructive peek used to decide whether to return to loop(), + // never to establish ordering. Reading one pass stale is harmless -- the next + // pass sees it. + return __atomic_load_n(&s_connectedEvent, __ATOMIC_RELAXED) || + __atomic_load_n(&s_disconnectedEvent, __ATOMIC_RELAXED); } -bool BleTransport::takeConnectedEvent() { - if (!s_connectedEvent) return false; - s_connectedEvent = false; +bool BleTransport::takeConnectedEvent(uint32_t* instanceWord) { + // Atomic exchange, not check-then-clear. The old form could lose an event that + // arrived inside the gap, and -- now that the flag carries an identity payload + // -- could also lose an event that arrived inside the gap. ACQUIRE pairs with + // the callback's RELEASE store of the flag, which it makes after writing the + // payload, so the payload we read is at least fully written. + // + // It does NOT bind the payload to the flag we just consumed: acquire/release + // orders writes that PRECEDE the release, and nothing freezes the payload + // afterwards, so a second connect landing between the exchange and the load + // below hands us ITS word instead. That is tolerable only because no decision + // depends on it -- teardown and connect-side work are both derived from the + // owner token and the instance table, which are authoritative. The payload is + // diagnostic. Do not build a decision on it without binding it properly. + if (!__atomic_exchange_n(&s_connectedEvent, false, __ATOMIC_ACQUIRE)) return false; + if (instanceWord != nullptr) { + *instanceWord = __atomic_load_n(&s_connectedWord, __ATOMIC_RELAXED); + } return true; } -bool BleTransport::takeDisconnectedEvent(uint8_t* reason, uint8_t* rxBoundary) { - if (!s_disconnectedEvent) return false; - s_disconnectedEvent = false; - if (reason != nullptr) *reason = s_disconnectReason; - if (rxBoundary != nullptr) *rxBoundary = s_rxBoundaryAtDisconnect; +bool BleTransport::takeDisconnectedEvent(uint16_t* reason, uint32_t* instanceWord) { + // See takeConnectedEvent(), including the caveat: the exchange stops events + // being lost in the old check-then-clear gap, but does not bind this payload to + // the flag just consumed. The reason code below is therefore diagnostic -- a + // burst of disconnects can report the latest reason twice. Teardown decides on + // table state, not on this. + if (!__atomic_exchange_n(&s_disconnectedEvent, false, __ATOMIC_ACQUIRE)) return false; + if (reason != nullptr) *reason = __atomic_load_n(&s_disconnectReason, __ATOMIC_RELAXED); + if (instanceWord != nullptr) { + *instanceWord = __atomic_load_n(&s_disconnectedWord, __ATOMIC_RELAXED); + } return true; } diff --git a/src/ble_transport_nrf.cpp b/src/ble_transport_nrf.cpp index 424a55e..9921d21 100644 --- a/src/ble_transport_nrf.cpp +++ b/src/ble_transport_nrf.cpp @@ -9,6 +9,7 @@ #include "ble_transport.h" #include "ble_transport_nrf.h" #include "command_queue.h" +#include "link_owner.h" #include "structs.h" #include "encryption.h" #include "od_log.h" @@ -35,12 +36,27 @@ static bool s_begun = false; static uint16_t s_connHandle = BLE_CONN_HANDLE_INVALID; static volatile bool s_connectedEvent = false; static volatile bool s_disconnectedEvent = false; -static volatile uint8_t s_disconnectReason = 0; -// RX ring head at the instant the link dropped: the boundary between the departed -// client's queued frames and anything the next client pushes. Captured in the -// disconnect callback because that is the only moment it is knowable -- by the time -// loop() services the event, a reconnect may already have queued frames of its own. -static volatile uint8_t s_rxBoundaryAtDisconnect = 0; +// uint16_t for signature parity with ESP32, where NimBLE's reason genuinely needs +// the width. The SoftDevice hands us a raw HCI byte with no wrapping, so nothing +// is lost or aliased here -- but the two targets now log the same way. +static volatile uint16_t s_disconnectReason = 0; +static volatile uint32_t s_connectedWord = 0; +static volatile uint32_t s_disconnectedWord = 0; + +// --- the instance table (CONNECTION_POLICY R3 requirement 5) ----------------- +// Degenerate at one entry: Bluefruit.begin(1, 0) configures the SoftDevice for a +// single peripheral link, so cross-central injection is unreachable at the link +// layer. It exists anyway because the mechanisms above it are portable -- the R3a +// wait polls per-handle liveness, and frames carry identity tags on both targets. +// A single-link target still queues frames that can outlive their session across a +// disconnect/reconnect pair inside one blocked-loop window, which is precisely +// what the tag protects against. +static volatile uint32_t s_instanceWord = 0; +static volatile bool s_instanceSubscribed = false; +// Claim disposition: 0 while the claim is in flight, otherwise the identity word +// the claim was decided for. See the matching field in ble_transport_esp32.cpp for +// why it carries the identity rather than a bare flag. +static volatile uint32_t s_instanceDecidedWord = 0; // --- advertising interval policy -------------------------------------------- static uint32_t s_advBoostUntil = 0; @@ -115,30 +131,62 @@ static void armLinkDiag(uint16_t conn_handle) { s_linkDiagTimer.reset(); // start/restart the one-shot; fires ~2.5 s later } -// --- stack callbacks (SoftDevice callback task -- flag-only) ----------------- -// The threading contract, as of Phase 3 and matching ESP32: a stack callback may -// do exactly two things, copy bytes into the RX ring and set a flag. Everything -// else -- command dispatch, zlib inflate, EPD SPI streaming, notify(), the -// connect/disconnect application work, even the PHY/DLE request -- runs on the -// loop() task. Anything added below that is not a push or a flag store -// reintroduces the cross-task races this phase exists to remove. +// --- stack callbacks (SoftDevice callback task) ------------------------------ +// The threading contract, matching ESP32: a stack callback may copy bytes into the +// RX ring, publish its own instance metadata, attempt the single ownership claim +// CAS, and set an event flag. Everything else -- command dispatch, zlib inflate, +// EPD SPI streaming, notify(), the connect/disconnect application work, even the +// PHY/DLE request -- runs on the loop() task. Anything added below beyond that set +// reintroduces the cross-task races this design exists to remove. static void onConnectCb(uint16_t conn_handle) { - od_log_info("=== BLE CLIENT CONNECTED (nRF) ==="); s_connHandle = conn_handle; - s_connectedEvent = true; + // Same normative order as ESP32 (R2): epoch, then table entry, then claim. + const uint16_t epoch = linkNextEpoch(); + const uint32_t word = linkPackWord(OWNER_BLE, conn_handle, epoch); + __atomic_store_n(&s_instanceDecidedWord, (uint32_t)0, __ATOMIC_RELAXED); + __atomic_store_n(&s_instanceWord, word, __ATOMIC_RELEASE); + s_instanceSubscribed = false; + const LinkId id = { OWNER_BLE, conn_handle, epoch }; + const bool admitted = linkClaim(id); + // Publish the identity the claim resolved FOR, so the loop-side refusal scan + // can bind the disposition to this exact instance. + __atomic_store_n(&s_instanceDecidedWord, word, __ATOMIC_RELEASE); + od_log_info("=== BLE CLIENT CONNECTED (nRF) h=%u e=%u %s ===", + (unsigned)conn_handle, (unsigned)epoch, + admitted ? "[owner]" : "[contender - refused service]"); + // Payload before flag, flag RELEASE-stored: see the ESP32 twin. + __atomic_store_n(&s_connectedWord, word, __ATOMIC_RELAXED); + __atomic_store_n(&s_connectedEvent, true, __ATOMIC_RELEASE); } static void onDisconnectCb(uint16_t conn_handle, uint8_t reason) { (void)conn_handle; - od_log_info("=== BLE CLIENT DISCONNECTED (nRF) ==="); + od_log_info("=== BLE CLIENT DISCONNECTED (nRF) reason=0x%03X ===", (unsigned)reason); s_connHandle = BLE_CONN_HANDLE_INVALID; - s_disconnectReason = reason; - // Producer-task read of the producer-owned head: no synchronisation needed, and - // within the copy-and-flag contract. Nothing this client can still send exists - // past this point -- the link is gone -- so this is exactly the last frame of - // the departed session. - s_rxBoundaryAtDisconnect = bleRxQueueHead(); - s_disconnectedEvent = true; + __atomic_store_n(&s_disconnectReason, (uint16_t)reason, __ATOMIC_RELAXED); + const uint32_t word = __atomic_load_n(&s_instanceWord, __ATOMIC_ACQUIRE); + __atomic_store_n(&s_disconnectedWord, word, __ATOMIC_RELAXED); + // Retire the entry: this is what makes link death observable per handle, for + // the R3a wait and for the loop's owner comparison. No RX-boundary capture -- + // frames carry their writer's identity instead (CommandQueueItem::tag). + s_instanceSubscribed = false; + __atomic_store_n(&s_instanceDecidedWord, (uint32_t)0, __ATOMIC_RELAXED); + __atomic_store_n(&s_instanceWord, (uint32_t)0, __ATOMIC_RELEASE); + // The token is deliberately NOT released here. An intermediate version did + // release it on this callback, to let a fast reconnect win a fresh claim -- but + // that admits a new owner while the departed session's transfer, crypto and TX + // ring are still live, and RX/TX are serviced before the deferred cleanup, so + // the new client's commands would run against the old session's state and its + // queued responses could be delivered to the newcomer. Release stays where R3a + // puts it: the last step of the abort, after teardown. + // + // What makes that safe for a reconnecting client is contender refusal + // (serviceContenderRefusal in main.cpp): a client that reconnects into a + // still-held slot is disconnected once the loop runs, and its NEXT connect + // claims cleanly. Without refusal the client would sit on nRF's only + // peripheral link forever with every write filtered, since admission is decided + // once per instance and never revisited. + __atomic_store_n(&s_disconnectedEvent, true, __ATOMIC_RELEASE); } // Adapter: Bluefruit's write_callback_t is BLECharacteristic*-shaped, whereas the @@ -146,13 +194,24 @@ static void onDisconnectCb(uint16_t conn_handle, uint8_t reason) { // every target). Adapting here is what keeps Bluefruit types out of // communication.cpp. static void onWriteCb(uint16_t conn_hdl, BLECharacteristic* chr, uint8_t* data, uint16_t len) { - (void)conn_hdl; (void)chr; + // Same owner filter as ESP32, so the two targets read identically. Latent + // rather than live here -- the SoftDevice permits only one peripheral link, so + // there is no second central to filter out -- but the TAG it produces is not + // latent at all: it is what stops a frame queued by a departed session from + // dispatching into the next one. + // + // This callback used to (void) the handle it was given. + const uint32_t word = __atomic_load_n(&s_instanceWord, __ATOMIC_ACQUIRE); + if (word == 0 || linkUnpackWord(word).handle != conn_hdl || word != linkOwnerWord()) { + od_log_debug("Dropped write from non-owner h=%u", (unsigned)conn_hdl); + return; + } // bleRxQueuePush() owns the arrival log and every drop reason (empty, too large, // ring full) so this callback and ESP32's onWrite() cannot report the same frame // differently. This site used to print "queue full" for all three, sending you // after ring depth when the real cause was a malformed frame. Add no logging here. - (void)bleRxQueuePush(data, len); + (void)bleRxQueuePush(data, len, word); } // --- BleTransport ------------------------------------------------------------ @@ -239,13 +298,68 @@ uint8_t BleTransport::connectedCount() const { } bool BleTransport::notifyReady() const { - return Bluefruit.connected() && s_imageCharacteristic.notifyEnabled(); + // Gated on ownership as well as CCCD, matching ESP32: with the slot unowned + // there is nobody a response may legitimately go to. + if (!Bluefruit.connected() || !s_imageCharacteristic.notifyEnabled()) return false; + const uint32_t owner = linkOwnerWord(); + return owner != 0 && owner == __atomic_load_n(&s_instanceWord, __ATOMIC_ACQUIRE); } bool BleTransport::notify(const uint8_t* data, uint16_t len) { + // Single-link by construction (Bluefruit.begin(1, 0)), so there is no + // all-subscribers overload to avoid as there is on NimBLE -- but the ownership + // gate is kept so both targets refuse to notify an unowned or foreign link. + const uint32_t owner = linkOwnerWord(); + if (owner == 0 || owner != __atomic_load_n(&s_instanceWord, __ATOMIC_ACQUIRE)) return false; return s_imageCharacteristic.notify(data, len); } +bool BleTransport::instanceLive(uint16_t handle, uint16_t epoch) const { + return __atomic_load_n(&s_instanceWord, __ATOMIC_ACQUIRE) == + linkPackWord(OWNER_BLE, handle, epoch); +} + +uint8_t BleTransport::liveInstanceCount() const { + return __atomic_load_n(&s_instanceWord, __ATOMIC_ACQUIRE) != 0 ? 1 : 0; +} + +uint32_t BleTransport::instanceWordAt(uint8_t index) const { + if (index != 0) return 0; + return __atomic_load_n(&s_instanceWord, __ATOMIC_ACQUIRE); +} + +uint32_t BleTransport::instanceClaimDecidedWordAt(uint8_t index) const { + if (index != 0) return 0; + return __atomic_load_n(&s_instanceDecidedWord, __ATOMIC_ACQUIRE); +} + +uint8_t BleTransport::instanceCapacity() { return 1; } + +bool BleTransport::disconnect(uint16_t handle, uint16_t epoch) { + // Re-validate as late as possible; see the ESP32 twin and the header note. + if (!instanceLive(handle, epoch)) return true; // already gone, or reassigned + // Bluefruit takes ONLY a handle: BLEConnection::disconnect() calls + // sd_ble_gap_disconnect(_conn_hdl, BLE_HCI_REMOTE_USER_TERMINATED_CONNECTION), + // so 0x13 is sent and there is no reason argument to pass -- which is why the + // seam exposes none. + // + // Unlike the DFU-entry disconnect in device_control.cpp, restartOnDisconnect + // is deliberately left ON: this drop frees the slot for the next client rather + // than ending the device's session life. + if (!Bluefruit.connected()) return true; // already gone + const bool ok = Bluefruit.disconnect(handle); + if (!ok) od_log_warn("WARNING: BLE disconnect request failed for handle %u", (unsigned)handle); + return ok; +} + +uint16_t BleTransport::connIntervalMs(uint16_t handle) const { + BLEConnection* conn = Bluefruit.Connection(handle); + if (conn == nullptr) return 0; + const uint16_t units = conn->getConnectionInterval(); // 1.25 ms units + if (units == 0) return 0; + return (uint16_t)((units * 5 + 3) / 4); +} + void BleTransport::setManufacturerData(const uint8_t* msd, uint8_t len) { Bluefruit.Advertising.clearData(); Bluefruit.Advertising.addFlags(BLE_GAP_ADV_FLAGS_LE_ONLY_GENERAL_DISC_MODE); @@ -317,20 +431,45 @@ void BleTransport::tick() { } bool BleTransport::eventPending() const { - return s_connectedEvent || s_disconnectedEvent; + // RELAXED: a non-destructive peek used to decide whether to return to loop(), + // never to establish ordering. Reading one pass stale is harmless -- the next + // pass sees it. + return __atomic_load_n(&s_connectedEvent, __ATOMIC_RELAXED) || + __atomic_load_n(&s_disconnectedEvent, __ATOMIC_RELAXED); } -bool BleTransport::takeConnectedEvent() { - if (!s_connectedEvent) return false; - s_connectedEvent = false; +bool BleTransport::takeConnectedEvent(uint32_t* instanceWord) { + // Atomic exchange, not check-then-clear. The old form could lose an event that + // arrived inside the gap, and -- now that the flag carries an identity payload + // -- could also lose an event that arrived inside the gap. ACQUIRE pairs with + // the callback's RELEASE store of the flag, which it makes after writing the + // payload, so the payload we read is at least fully written. + // + // It does NOT bind the payload to the flag we just consumed: acquire/release + // orders writes that PRECEDE the release, and nothing freezes the payload + // afterwards, so a second connect landing between the exchange and the load + // below hands us ITS word instead. That is tolerable only because no decision + // depends on it -- teardown and connect-side work are both derived from the + // owner token and the instance table, which are authoritative. The payload is + // diagnostic. Do not build a decision on it without binding it properly. + if (!__atomic_exchange_n(&s_connectedEvent, false, __ATOMIC_ACQUIRE)) return false; + if (instanceWord != nullptr) { + *instanceWord = __atomic_load_n(&s_connectedWord, __ATOMIC_RELAXED); + } return true; } -bool BleTransport::takeDisconnectedEvent(uint8_t* reason, uint8_t* rxBoundary) { - if (!s_disconnectedEvent) return false; - s_disconnectedEvent = false; - if (reason != nullptr) *reason = s_disconnectReason; - if (rxBoundary != nullptr) *rxBoundary = s_rxBoundaryAtDisconnect; +bool BleTransport::takeDisconnectedEvent(uint16_t* reason, uint32_t* instanceWord) { + // See takeConnectedEvent(), including the caveat: the exchange stops events + // being lost in the old check-then-clear gap, but does not bind this payload to + // the flag just consumed. The reason code below is therefore diagnostic -- a + // burst of disconnects can report the latest reason twice. Teardown decides on + // table state, not on this. + if (!__atomic_exchange_n(&s_disconnectedEvent, false, __ATOMIC_ACQUIRE)) return false; + if (reason != nullptr) *reason = __atomic_load_n(&s_disconnectReason, __ATOMIC_RELAXED); + if (instanceWord != nullptr) { + *instanceWord = __atomic_load_n(&s_disconnectedWord, __ATOMIC_RELAXED); + } return true; } diff --git a/src/buzzer_control.cpp b/src/buzzer_control.cpp index ef5c66e..7e0849c 100644 --- a/src/buzzer_control.cpp +++ b/src/buzzer_control.cpp @@ -236,6 +236,16 @@ static void buzzer_run(void) { } } +void buzzerStopForSleep(void) { + // A SLEEP api, not a session-teardown one. Nothing in abortToKnownState() may + // call this: R6 deliberately lets a melody play through a session abort, and + // this policy fires the abort far more often than a plain disconnect once did, + // so truncating buzzes there would be a correspondingly visible regression. + // Deep sleep is the one transition where "let the effect finish" cannot hold, + // because buzzerService() stops ticking and the pin would simply stay driven. + buzzer_stop_internal(); +} + void buzzerService(void) { if (!s_buzzer.active) { return; diff --git a/src/buzzer_control.h b/src/buzzer_control.h index 89952d5..1bb4fdd 100644 --- a/src/buzzer_control.h +++ b/src/buzzer_control.h @@ -95,5 +95,14 @@ void initPassiveBuzzers(void); void handleBuzzerActivate(uint8_t* data, uint16_t len); void passiveBuzzerPowerOffAlert(void); void buzzerService(void); // non-blocking playback tick, called from loop() +/** + * Silence the buzzer immediately. DEEP SLEEP ONLY -- not a session-teardown API. + * + * abortToKnownState() must never call this: a playing melody is a user-facing + * effect, not session state, and cannot confuse a later connection the way a + * half-open pipe session can. Deep sleep is the exception only because it stops the + * clock playback depends on, so an unstopped tone holds the pin driven into sleep. + */ +void buzzerStopForSleep(void); #endif diff --git a/src/command_queue.cpp b/src/command_queue.cpp index bff34a7..285d15d 100644 --- a/src/command_queue.cpp +++ b/src/command_queue.cpp @@ -47,7 +47,7 @@ static volatile uint8_t s_rxTail = 0; // healthy path reads [BLE][Q:0] and a rising Q means arrivals are outrunning // loop()'s drain. RX is BLE-only by construction -- LAN frames reach the dispatcher // without touching this ring -- so the tag is a literal, not originTag(). -bool bleRxQueuePush(const uint8_t* data, uint16_t len) { +bool bleRxQueuePush(const uint8_t* data, uint16_t len, uint32_t tag) { if (len == 0) { od_log_warn("WARNING: Empty BLE frame received, dropping"); return false; @@ -89,6 +89,10 @@ bool bleRxQueuePush(const uint8_t* data, uint16_t len) { memcpy(s_rx[head].data, data, len); s_rx[head].len = len; s_rx[head].pending = true; + // Before the RELEASE store, with the payload: the consumer's ACQUIRE load of + // the head is what makes all of these visible, and a tag published after it + // could be read stale -- dispatching a frame against the wrong identity. + s_rx[head].tag = tag; __atomic_store_n(&s_rxHead, nextHead, __ATOMIC_RELEASE); return true; } @@ -108,31 +112,29 @@ void bleRxQueueConsume(void) { __atomic_store_n(&s_rxTail, (uint8_t)((tail + 1) % COMMAND_QUEUE_SIZE), __ATOMIC_RELEASE); } -uint8_t bleRxQueueDiscardTo(uint8_t boundary) { - // Discard up to `boundary` -- a head value captured when the departed client's - // link went down -- NOT up to the current head. Discarding to the current head - // is what broke: loop() can be blocked for tens of seconds inside an EPD refresh, - // during which the old client's disconnect, the next client's connect, and that - // client's first command all land. Servicing the stale disconnect then threw away - // a frame that had never belonged to the departed session. +uint8_t bleRxQueueReset(void) { + // Consumer-side discard ONLY -- see the contract in command_queue.h. Snapshot + // the producer's head with ACQUIRE, store it into the tail with RELEASE, and + // touch neither the head nor any slot payload. Writing both indices (or + // clearing slots) would race a producer that is mid-memcpy into s_rx[head] + // before its own RELEASE publishes the frame. // - // ACQUIRE the head for the same reason peek does. Safe against a concurrent - // producer: it only ever advances the head, so frames pushed after this load - // survive to the next pass rather than being lost or double-counted. + // A frame the departing owner pushes after this snapshot survives, by design: + // it carries that instance's tag, so serviceBleRx() drops it once the token is + // released. That is the same construction that makes an expired R3a wait + // harmless, and it is why this reset needs no retry or second pass. uint8_t tail = __atomic_load_n(&s_rxTail, __ATOMIC_RELAXED); uint8_t head = __atomic_load_n(&s_rxHead, __ATOMIC_ACQUIRE); if (tail == head) return 0; - const uint8_t occupied = (uint8_t)((head - tail + COMMAND_QUEUE_SIZE) % COMMAND_QUEUE_SIZE); - const uint8_t wanted = (uint8_t)((boundary - tail + COMMAND_QUEUE_SIZE) % COMMAND_QUEUE_SIZE); - // The consumer already drained past the boundary: nothing of the old session is - // left. Without this test the modular subtraction above would read as a nearly - // full ring and discard the live client's frames -- the very bug being fixed. - if (wanted > occupied) return 0; - for (uint8_t i = tail; i != boundary; i = (uint8_t)((i + 1) % COMMAND_QUEUE_SIZE)) { - s_rx[i].pending = false; - } - __atomic_store_n(&s_rxTail, boundary, __ATOMIC_RELEASE); - return wanted; + const uint8_t dropped = (uint8_t)((head - tail + COMMAND_QUEUE_SIZE) % COMMAND_QUEUE_SIZE); + // Advance the tail and touch NOTHING else. Clearing each discarded slot's + // `pending` (as a first draft did, copying bleRxQueueDiscardTo) writes producer + // territory: the producer owns every slot from `head` onward, and a slot this + // loop walks can already have been handed to a concurrent push. It is only + // harmless today because nothing reads `pending`, which is precisely the kind + // of latent violation that turns into a corrupted frame the moment it does. + __atomic_store_n(&s_rxTail, head, __ATOMIC_RELEASE); + return dropped; } uint8_t bleRxQueueHead(void) { @@ -175,6 +177,15 @@ bool bleTxQueuePush(const uint8_t* data, uint16_t len) { return true; } +void bleTxQueueReset(void) { + // Both ends are the loop task, so this is a plain drain -- no ordering rules, + // unlike the RX side. + while (s_txTail != s_txHead) { + s_tx[s_txTail].pending = false; + s_txTail = (uint8_t)((s_txTail + 1) % RESPONSE_QUEUE_SIZE); + } +} + uint8_t bleTxQueueDepth(void) { return (uint8_t)((s_txHead - s_txTail + RESPONSE_QUEUE_SIZE) % RESPONSE_QUEUE_SIZE); } diff --git a/src/command_queue.h b/src/command_queue.h index 4e54a5a..1bbd404 100644 --- a/src/command_queue.h +++ b/src/command_queue.h @@ -73,6 +73,20 @@ struct CommandQueueItem { uint8_t data[MAX_COMMAND_SIZE]; uint16_t len; bool pending; + // Packed identity word (link_owner.h) of the instance that wrote this frame, + // stamped in the write callback from the same owner-word load the non-owner + // filter already does. The dispatcher executes a frame only if this still + // equals the current owner word: a frame dispatches iff its instance was the + // owner both when it arrived and when it dispatches. + // + // This is what retired the RX-boundary mechanism (a head captured at link-down + // and discarded to on the loop). That boundary lived in the departing + // instance's slot and was lost whenever the stack reissued the handle before + // loop() scanned -- reachable inside one refresh block. Tags travel with the + // frame, so stale frames self-discard at dispatch however many edges were + // missed. 4 bytes x 18-34 slots = 72-136 B, per-frame metadata in the ONE ring; + // nothing that holds frames is ever replicated per connection. + uint32_t tag; }; // SPSC. Push runs on the stack callback task; peek/consume run on loop(). The @@ -91,25 +105,36 @@ struct CommandQueueItem { // transport is what let nRF report a malformed frame as "queue full". It logs at // arrival, on the callback task, so the timestamp is delivery time rather than // dispatch time; see the note on the definition for what that costs. -bool bleRxQueuePush(const uint8_t* data, uint16_t len); // false = dropped (logged) +// `tag` is the writing instance's packed identity word; the dispatcher re-checks +// it against the live owner word before executing the frame. It is written into +// the slot BEFORE the release-store that publishes the head, exactly like data and +// len, or the consumer's acquire load would not be guaranteed to see it. +bool bleRxQueuePush(const uint8_t* data, uint16_t len, uint32_t tag); // false = dropped (logged) CommandQueueItem* bleRxQueuePeek(void); // nullptr = empty void bleRxQueueConsume(void); // advance past the peeked slot uint8_t bleRxQueueHead(void); // producer-side, for pollActivity() uint8_t bleRxQueueDepth(void); // unconsumed frame count bool bleRxQueuePending(void); // unconsumed frames waiting -// Discard unconsumed frames up to `boundary`, a head value captured at the instant -// the departed client's link went down (BleTransport::takeDisconnectedEvent hands it -// out). Consumer-side: call only from the loop task. Returns how many were dropped. +// Discard every unconsumed frame. Consumer-side: call only from the loop task, and +// only from abortToKnownState()'s ring-reset step. // -// The boundary is what makes this safe, and it is not optional. loop() can be blocked -// for tens of seconds inside an EPD refresh -- long enough for the old client's -// disconnect, the next client's connect, and that client's first command to all land -// before the disconnect is serviced. A "discard everything present now" flush then -// eats the NEW client's frames; observed on nRF as a dropped 0x0080 immediately after -// a reconnect. Frames pushed after the boundary belong to whoever connected next and -// must survive. -uint8_t bleRxQueueDiscardTo(uint8_t boundary); +// SPSC-SAFE BY CONSTRUCTION, and the contract is not optional: this snapshots the +// producer's head with ACQUIRE and stores that snapshot into the tail with RELEASE. +// It writes NEITHER the head NOR any slot payload. A conventional "reset both +// indices / memset the ring" would race a producer mid-copy, since the push writes +// the payload before publishing the head. A push in flight either published before +// the snapshot (discarded here) or after it (survives, carrying the departing +// owner's tag, and is dropped at dispatch) -- which is exactly why this needs no +// stronger guarantee than the frame tag already provides. +// +// Must not run while a peek is outstanding: the consumer holds a pointer into the +// current slot across dispatch. Every returning abort caller is loop-side, after +// the pass's RX consumption; deep sleep, the one in-dispatch caller, never returns. +// +// Replaces bleRxQueueDiscardTo(boundary), retired with the RX-boundary mechanism +// (see CommandQueueItem::tag). +uint8_t bleRxQueueReset(void); // --- TX: command handlers (producer) -> loop() flush (consumer) -------------- // One definition of the struct, in one place: communication.cpp used to carry @@ -128,6 +153,10 @@ struct ResponseQueueItem { // Both ends run on loop() today, so no atomics here. bool bleTxQueuePush(const uint8_t* data, uint16_t len); // false = too large or full +// Discard every queued response. The TX-side analogue of bleRxQueueReset(), and +// the other half of R6's "RX and TX rings drained of the departed session's +// traffic". Single-task, so no ordering rules apply here. +void bleTxQueueReset(void); uint8_t bleTxQueueDepth(void); uint8_t bleTxQueueHead(void); // producer-side, for pollActivity() bool bleTxQueuePending(void); diff --git a/src/communication.cpp b/src/communication.cpp index cc37d9a..4101e3f 100644 --- a/src/communication.cpp +++ b/src/communication.cpp @@ -20,6 +20,9 @@ #include "wifi_service.h" #endif +#include "link_owner.h" +#include "session_guard.h" + bool isAuthenticated(); extern struct GlobalConfig globalConfig; @@ -35,6 +38,18 @@ extern struct GlobalConfig globalConfig; // name the values instead of comparing against a bare 0. volatile uint8_t g_commandOrigin = ORIGIN_BLE; +// Instance identity of the frame currently being dispatched -- the packed owner +// word (link_owner.h) of the connection that WROTE it, not merely its transport. +// +// g_commandOrigin says BLE-or-LAN and nothing more, which is not enough to decide +// whether a frame still belongs to the live session: BLE conn handles are reused, +// so a frame queued by a dead instance is indistinguishable from the new owner's by +// transport alone. serviceBleRx() sets this from the frame's own tag (which +// CommandQueueItem carries from the write callback) and the LAN listener sets it +// from the LAN owner's identity, both immediately before dispatch. Same +// single-loop-task argument as g_commandOrigin, so no locking. +volatile uint32_t g_commandInstance = 0; + // Transport tag for the RX banner and TX dump. Three transports share this // dispatcher (nRF BLE, ESP32 BLE via commandQueue, ESP32 LAN), and without a tag // the log cannot show which one a frame took -- in particular whether a frame used @@ -51,6 +66,141 @@ static const char* originTag(void) { } } +// --- auth-abuse drop (CONNECTION_POLICY R3 / freeze-hardening Phase 4) --------- +// +// Count CONSECUTIVE commands answered RESP_AUTH_REQUIRED and drop the link at the +// threshold, so a session that cannot authenticate stops holding the exclusive slot +// while it retries. +// +// This is an OPTIMISATION, not a hole-closer -- the distinction matters for how +// hard it should try. Phase 3 narrowed the activity clock so handshake/discovery +// opcodes and pre-auth commands no longer stamp it, which means such a peer already +// ages normally and the idle timeout reclaims the slot at OD_BLE_IDLE_TIMEOUT_MS. +// What this adds is speed and a reason: roughly one exchange instead of 120 s, and +// an explicit final RESP_AUTH_REQUIRED before a deliberate drop rather than a +// silent timeout. So it may fail safe (never dropping) without reopening anything. +#ifndef OD_AUTH_ABUSE_THRESHOLD +// CLIENT BEHAVIOUR THIS ASSUMES: py-opendisplay authenticates in ONE exchange, so a +// legitimate client never reaches 2, let alone 10. +// +// Chosen deliberately BELOW py-opendisplay's 16-frame pipe window, which is the one +// case where a well-behaved client trips it: if its session dies mid-upload, every +// in-flight frame bounces. Dropping at 10 rather than waiting out all 16 is the +// right outcome -- the session is already dead, every one of those frames is doomed, +// and the drop tells the client immediately instead of after a full window of +// pointless round trips. Recorded because an earlier prototype inherited this +// threshold by accident rather than deciding it. +#define OD_AUTH_ABUSE_THRESHOLD 10 +#endif +#ifndef OD_AUTH_ABUSE_FLUSH_MS +// Hard bound on the whole best-effort delivery attempt below. On expiry the drop +// happens regardless, so a client that has stopped reading cannot keep the abuser +// attached by refusing to drain. +#define OD_AUTH_ABUSE_FLUSH_MS 500 +#endif +#ifndef OD_AUTH_ABUSE_DWELL_FALLBACK_MS +// Used when the negotiated connection interval is not yet known. The central +// chooses that interval and this firmware requests none, so there is no constant to +// hard-code -- see BleTransport::connIntervalMs(). +#define OD_AUTH_ABUSE_DWELL_FALLBACK_MS 50 +#endif + +// Set by any RESP_AUTH_REQUIRED answer for the frame being dispatched, on EVERY +// transport. Read once after the dispatch switch to decide whether the frame was +// activity. Loop-task-only, like g_commandOrigin. +static bool s_frameRejected = false; +static uint8_t s_authRejectRun = 0; // consecutive RESP_AUTH_REQUIRED answers +static bool s_authAbuseDropPending = false; +static uint32_t s_authAbuseDeadlineMs = 0; // hard bound on the delivery attempt +static uint32_t s_authAbuseDwellUntil = 0; // set once TX has drained; 0 = not yet + +// Called at every site that answers RESP_AUTH_REQUIRED. +// +// BLE ONLY, and the origin gate is not decoration: the same auth gate is reachable +// from plaintext LAN, and counting those would let LAN traffic drop a BLE client. +// TLS-LAN never reaches the gate at all (the transport is the authentication). +static void noteAuthRejected(void) { + // Mark the frame first, for every origin. The COUNTER is BLE-only (see below), + // but "this frame was refused, so it is not activity" is transport-independent + // -- and getting that wrong on LAN is exactly how a TLS client could hold the + // slot forever. + s_frameRejected = true; + if (g_commandOrigin != ORIGIN_BLE) return; + if (s_authAbuseDropPending) return; // already decided + if (s_authRejectRun < 255) s_authRejectRun++; + if (s_authRejectRun < OD_AUTH_ABUSE_THRESHOLD) return; + // The offender is the frame's own instance, taken from its queue tag -- not + // "whichever peer the stack lists first", which is how an earlier prototype + // misidentified it before frames carried identity. + if (!linkIsOwnerWord(g_commandInstance)) return; // not the owner: nothing to drop + od_log_warn("Auth abuse: %u consecutive unauthenticated commands - dropping link", + (unsigned)s_authRejectRun); + s_authAbuseDropPending = true; + s_authAbuseDeadlineMs = millis() + OD_AUTH_ABUSE_FLUSH_MS; + s_authAbuseDwellUntil = 0; +} + +void resetAuthAbuseCounter(void) { + s_authRejectRun = 0; + s_authAbuseDropPending = false; + s_authAbuseDeadlineMs = 0; + s_authAbuseDwellUntil = 0; +} + +void serviceBleAuthAbuseDisconnect(void) { + if (!s_authAbuseDropPending) return; + // Never mid-refresh: loop() is blocked throughout one, and the abort is + // loop-task-only by contract. + if (epdRefreshInProgress) return; + + const LinkId owner = linkOwnerId(); + if (owner.who != OWNER_BLE) { + // The link went away, or LAN took the slot, while we were draining. Nothing + // to drop -- and dropping on a stale identity is exactly what the epoch + // exists to prevent. + resetAuthAbuseCounter(); + return; + } + + // BEST EFFORT, and deliberately not more than that. An empty TX ring proves the + // stack ACCEPTED the notification, not that it went on air: the ring advances + // when notify() returns true, and a BLE notification is unacknowledged. Without + // an indication -- a wire change this plan forbids -- there is no delivery + // signal to wait on, so this drains, dwells about one connection interval to + // give the radio a chance to send, and then drops. + serviceBleTx(); + const bool expired = (int32_t)(millis() - s_authAbuseDeadlineMs) >= 0; + if (!bleTxQueuePending() && s_authAbuseDwellUntil == 0) { + uint16_t intervalMs = ble.connIntervalMs(owner.handle); + if (intervalMs == 0) intervalMs = OD_AUTH_ABUSE_DWELL_FALLBACK_MS; + const uint32_t dwellEnd = millis() + intervalMs + 5u; // +margin + // Never past the hard deadline: a drain landing just before it yields a + // short or zero dwell, which is the expiry case behaving as specified + // rather than a contradiction. + s_authAbuseDwellUntil = + ((int32_t)(dwellEnd - s_authAbuseDeadlineMs) > 0) ? s_authAbuseDeadlineMs : dwellEnd; + } + const bool dwelled = (s_authAbuseDwellUntil != 0) && + ((int32_t)(millis() - s_authAbuseDwellUntil) >= 0); + if (!expired && !dwelled) return; // keep draining next pass + + // One more pass if RX still holds frames. serviceBleRx() drains once per pass, + // early, while this runs late -- so a frame that arrived on the callback task in + // between is still queued, and the abort's ring reset would discard it unread. + // That frame may be the client's authentication, which would cancel this drop + // entirely. Bounded by the same hard deadline, so a client that keeps the ring + // permanently non-empty cannot defer the drop indefinitely. + if (!expired && bleRxQueuePending()) return; + + resetAuthAbuseCounter(); + // dropLink=true. The abort's own step 10 is the R3a bounded wait for link-down + // before its step 11 releases -- two bounded waits in sequence, composing rather + // than conflicting: this one runs BEFORE the abort precisely because the abort + // deliberately skips the client NACK when dropping, and asking one routine to + // both hold the link open for a response and tear it down is contradictory. + abortToKnownState("auth abuse", true, owner); +} + static void reloadConfigAfterSave(void) { if (!loadGlobalConfig()) { od_log_warn("WARNING: Config was saved but reload from storage failed (see errors above). " @@ -407,6 +557,7 @@ void handleWriteConfig(uint8_t* data, uint16_t len) { if (isEncryptionEnabled() && !isAuthenticated()) { bool rewriteAllowed = (securityConfig.flags & (1 << 0)) != 0; if (!rewriteAllowed) { + noteAuthRejected(); uint8_t response[] = {RESP_ACK, (uint8_t)(CMD_CONFIG_WRITE & 0xFF), RESP_AUTH_REQUIRED}; sendResponseUnencrypted(response, sizeof(response)); return; @@ -468,7 +619,8 @@ void handleWriteConfigChunk(uint8_t* data, uint16_t len) { if (chunkedWriteState.receivedChunks == 1 && isEncryptionEnabled() && !isAuthenticated()) { bool rewriteAllowed = (securityConfig.flags & (1 << 0)) != 0; if (!rewriteAllowed) { - chunkedWriteState.active = false; + resetChunkedWriteState(); + noteAuthRejected(); uint8_t response[] = {RESP_ACK, (uint8_t)(CMD_CONFIG_CHUNK & 0xFF), RESP_AUTH_REQUIRED}; sendResponseUnencrypted(response, sizeof(response)); return; @@ -476,7 +628,7 @@ void handleWriteConfigChunk(uint8_t* data, uint16_t len) { secureEraseConfig(); } if (len == 0 || len > CONFIG_CHUNK_SIZE || chunkedWriteState.receivedSize + len > MAX_CONFIG_SIZE || chunkedWriteState.receivedChunks >= MAX_CONFIG_CHUNKS) { - chunkedWriteState.active = false; + resetChunkedWriteState(); uint8_t errorResponse[] = {RESP_NACK, RESP_CONFIG_CHUNK, 0x00, 0x00}; sendResponse(errorResponse, sizeof(errorResponse)); return; @@ -492,9 +644,7 @@ void handleWriteConfigChunk(uint8_t* data, uint16_t len) { reloadConfigAfterSave(); } sendResponse(saved ? ok : err, 4); - chunkedWriteState.active = false; - chunkedWriteState.receivedSize = 0; - chunkedWriteState.receivedChunks = 0; + resetChunkedWriteState(); } else { uint8_t ackResponse[] = {RESP_ACK, RESP_CONFIG_CHUNK, 0x00, 0x00}; sendResponse(ackResponse, sizeof(ackResponse)); @@ -547,6 +697,8 @@ void imageDataWritten(BLEConnHandle conn_hdl, BLECharPtr chr, uint8_t* data, uin } uint16_t command = (data[0] << 8) | data[1]; + + // Silence the per-frame command spam for image-write data (0x0071) once the // stream is past its first chunk; the display handler's 5% meter reports it. const bool quietCmd = (command == CMD_DIRECT_WRITE_DATA || command == CMD_PIPE_WRITE_DATA) && imageWriteLogQuietCmd(); @@ -581,6 +733,7 @@ void imageDataWritten(BLEConnHandle conn_hdl, BLECharPtr chr, uint8_t* data, uin if (isEncryptionEnabled() && g_commandOrigin != ORIGIN_LAN_TLS) { if (!isAuthenticated()) { od_log_error("ERROR: [%s] Command requires authentication (encryption enabled)", originTag()); + noteAuthRejected(); uint8_t response[] = {RESP_ACK, (uint8_t)(command & 0xFF), RESP_AUTH_REQUIRED}; sendResponseUnencrypted(response, sizeof(response)); return; @@ -588,6 +741,7 @@ void imageDataWritten(BLEConnHandle conn_hdl, BLECharPtr chr, uint8_t* data, uin if (len < BLE_CMD_HEADER_SIZE + ENCRYPTION_NONCE_SIZE + ENCRYPTION_TAG_SIZE) { od_log_error("ERROR: [%s] Unencrypted command received when encryption is enabled", originTag()); + noteAuthRejected(); uint8_t response[] = {RESP_ACK, (uint8_t)(command & 0xFF), RESP_AUTH_REQUIRED}; sendResponseUnencrypted(response, sizeof(response)); return; @@ -629,6 +783,10 @@ void imageDataWritten(BLEConnHandle conn_hdl, BLECharPtr chr, uint8_t* data, uin data = decrypted_data; } + // Cleared before dispatch, inspected after it: the handlers themselves can + // still refuse this frame, so acceptance is not knowable until they return. + s_frameRejected = false; + // The per-command banner is logged once above (commandName()); cases below do // NOT log their own "=== ... COMMAND ... ===". CMD_AUTHENTICATE and // CMD_FIRMWARE_VERSION are handled by the early returns above and so are absent @@ -701,4 +859,37 @@ void imageDataWritten(BLEConnHandle conn_hdl, BLECharPtr chr, uint8_t* data, uin od_log_error("ERROR: Unknown command: 0x%04X", command); break; } + + // R4 ACTIVITY, decided HERE -- after dispatch, on the OUTCOME rather than on a + // prediction of it. This is the third and final position for this test, and the + // two earlier ones were both wrong in the same way: + // + // - At the top, gated on isAuthenticated(): an authenticated client sending a + // too-short plaintext frame stamped here, then got RESP_AUTH_REQUIRED from + // the length check below it. + // - Just before the switch: TLS-LAN frames bypass the CCM gate and reach + // dispatch, but handleWriteConfig() and the chunk handler apply their OWN + // app-layer auth check and can still answer RESP_AUTH_REQUIRED -- so a TLS + // client repeating CMD_CONFIG_WRITE stamped the clock on every rejected + // attempt and held the slot indefinitely. + // + // Both are the same mistake at different depths: anything that predicts + // acceptance is wrong at whatever layer rejects next. Reading s_frameRejected + // after the handler has run is the only position with nothing below it. + // + // Unknown opcodes do not stamp (commandName() is null for them), and the two + // handshake/discovery opcodes return from their own early branches and never + // reach here -- so "handshake and discovery are not activity" holds + // structurally rather than by a test that could drift. + if (!s_frameRejected && commandName(command) != nullptr && + linkIsOwnerWord(g_commandInstance)) { + linkStampOwnerCommand(); + // A fully accepted command means this client is working normally, so it + // clears the auth-abuse state ENTIRELY -- including a drop already pending. + // Clearing only the run would let a client that recovered mid-flush (say it + // re-authenticated after its session expired under a 16-frame pipe burst) + // still be dropped by a decision taken moments earlier, which is the worst + // outcome for a mechanism whose whole value is reacting quickly. + resetAuthAbuseCounter(); + } } diff --git a/src/communication.h b/src/communication.h index fb4c453..eac34b6 100644 --- a/src/communication.h +++ b/src/communication.h @@ -36,6 +36,31 @@ enum CommandOrigin { ORIGIN_BLE = 0, ORIGIN_LAN_PLAIN = 1, ORIGIN_LAN_TLS = 2 }; /// Origin of the command currently being dispatched (a CommandOrigin value). uint8_t commandOrigin(void); +/** + * Instance identity (packed owner word) of the frame being dispatched. Set by each + * transport immediately before it calls imageDataWritten(): BLE from the frame's own + * queue tag, LAN from the LAN owner's identity. Compared against the live owner word + * so a frame from a departed instance neither executes nor stamps the activity clock. + */ +extern volatile uint32_t g_commandInstance; + +/** + * Drop a BLE link that has answered OD_AUTH_ABUSE_THRESHOLD consecutive commands + * with RESP_AUTH_REQUIRED. Loop-serviced, both targets, and it must run on the loop + * task: it ends in abortToKnownState(). + * + * Best-effort delivery of the final RESP_AUTH_REQUIRED before the drop -- it drains + * TX and dwells about one connection interval, both inside a hard bound. An empty + * ring proves stack acceptance of an unacknowledged notification, not receipt, so a + * deadline-truncated attempt may forfeit it by design. + */ +void serviceBleAuthAbuseDisconnect(void); + +/** Clear the consecutive-rejection run. Called by abortToKnownState() so every + * session end resets it -- otherwise a new client inherits its predecessor's + * rejections, which is a defect an earlier prototype shipped with. */ +void resetAuthAbuseCounter(void); + // --- deferred work, serviced by loop() --------------------------------------- // Implemented in main.cpp, which owns loop() and the flags behind these. They // are requests, not commands: the work happens on a later pass, and main.cpp diff --git a/src/config_parser.cpp b/src/config_parser.cpp index 4d439bc..ec7d839 100644 --- a/src/config_parser.cpp +++ b/src/config_parser.cpp @@ -55,6 +55,18 @@ void powerDownExternalFlashFromConfig(void); void ws_pp_init(); extern bool encryptionInitialized; +// Defined in main.h (the single-inclusion globals header), so it needs an extern +// here rather than an include -- main.h may not be included twice. +extern chunked_write_state_t chunkedWriteState; + +void resetChunkedWriteState(void) { + chunkedWriteState.active = false; + chunkedWriteState.totalSize = 0; + chunkedWriteState.receivedSize = 0; + chunkedWriteState.expectedChunks = 0; + chunkedWriteState.receivedChunks = 0; +} + bool initConfigStorage(){ #ifdef TARGET_NRF if (!InternalFS.begin()) { diff --git a/src/config_parser.h b/src/config_parser.h index d153afe..b8825d9 100644 --- a/src/config_parser.h +++ b/src/config_parser.h @@ -46,6 +46,22 @@ typedef struct { uint32_t receivedChunks; } chunked_write_state_t; +/** + * Clear the chunked config-upload state. + * + * The single primitive for it. The three sites in communication.cpp that used to + * clear it inline each zeroed a different subset -- one set only `active`, another + * also the counters, none the totals -- so a teardown routed through the wrong one + * left a partially-live upload. abortToKnownState() calls this too, which is what + * gives session teardown any coverage of this state at all: it previously had no + * reset function, so no disconnect path and no watchdog touched it. + * + * The payload buffer is deliberately not zeroed: `active = false` makes it + * unreachable, and MAX_CONFIG_SIZE is large enough that clearing it on every + * teardown would be pointless work. + */ +void resetChunkedWriteState(void); + bool initConfigStorage(); void formatConfigStorage(); bool saveConfig(uint8_t* configData, uint32_t len); diff --git a/src/device_control.cpp b/src/device_control.cpp index 1c6a78c..356a3db 100644 --- a/src/device_control.cpp +++ b/src/device_control.cpp @@ -578,6 +578,13 @@ void handleLedActivate(uint8_t* data, uint16_t len) { sendResponse(successResponse, sizeof(successResponse)); } +void ledStopForSleep(void) { + // Sleep API, not teardown -- see buzzerStopForSleep(). clear_mode=true matches + // handleLedStop() below, so the observable result is the same as the client + // having sent LED_STOP. + led_stop_internal(true); +} + void handleLedStop(uint8_t* data, uint16_t len) { if (s_led.active && len >= 1 && data[0] != s_led.instance) { uint8_t errorResponse[] = {RESP_NACK, RESP_LED_STOP_ACK, 0x02, 0x00}; diff --git a/src/device_control.h b/src/device_control.h index 0648a4a..7bcd660 100644 --- a/src/device_control.h +++ b/src/device_control.h @@ -10,6 +10,11 @@ void processLedFlash(); void initButtons(); void handleLedActivate(uint8_t* data, uint16_t len); void handleLedStop(uint8_t* data, uint16_t len); +/** + * Stop LED playback immediately. DEEP SLEEP ONLY -- see buzzerStopForSleep() for + * why this must not be called from abortToKnownState(). + */ +void ledStopForSleep(void); void enterDFUMode(); void handleDeepSleepCommand(const uint8_t* payload, uint16_t payloadLen); void handlePowerOffCommand(const uint8_t* payload, uint16_t payloadLen); diff --git a/src/display_service.cpp b/src/display_service.cpp index b3cacfb..04a9c8a 100644 --- a/src/display_service.cpp +++ b/src/display_service.cpp @@ -12,6 +12,8 @@ #include "communication.h" #include "encryption.h" #include "boot_screen.h" +#include "link_owner.h" +#include "session_guard.h" #include "touch_input.h" #include "uzlib.h" #if defined(TARGET_ESP32) && defined(OPENDISPLAY_FASTEPD) @@ -84,6 +86,24 @@ extern bool directWriteActive; extern uint8_t decompressionChunk[OPENDISPLAY_DECOMPRESSION_CHUNK_SIZE]; volatile bool epdRefreshInProgress = false; +// The ONE place the refresh bracket is closed, on every path. +// +// Both bracket sites used to assign epdRefreshInProgress = false inline. Routing +// them through a helper is what makes the R4 refresh exclusion implementable: a +// loop-side edge detector cannot see this transition, because loop() does not run +// for the refresh's whole duration -- both edges happen inside the blocking +// handler while wall-clock time passes. The activity clock has to be re-stamped AT +// the transition or a naive millis()-lastStamp accrues the entire refresh and drops +// an actively engaged client the instant loop() resumes. +// +// Re-stamping can only ever DELAY a drop, never cause a spurious one, which is why +// it is safe to apply unconditionally here. A future third refresh path gets the +// exclusion by calling this instead of remembering a second statement. +void endRefresh(void) { + epdRefreshInProgress = false; + linkStampRefreshEnd(); +} + extern uint32_t displayed_etag; // 0x76 partial-write error codes come from the canonical opendisplay_protocol.h; @@ -589,30 +609,51 @@ void checkTransferTimeouts(void) { // in the ~1 ms window where millis() wraps through zero. Of order one in 10^9 // transfers, so this is removing a special case from the invariant rather than // fixing a live risk. + // Both branches route through the ONE teardown routine (CONNECTION_POLICY R6's + // teardown extended to a non-disconnect trigger). This function is cited in the + // freeze-hardening plan as the very reason a shared routine is needed -- it is + // where a watchdog once tore down a panel while leaving its pipe session live -- + // so exempting it would have argued for the routine while leaving the original + // drift source untouched. + // + // Three deliberate behaviour changes come with it: crypto is now cleared (it + // used to survive), the link is now dropped, and teardown is no longer selective + // (each branch used to clean one transfer half). Dropping follows from clearing: + // a retained link whose session is gone draws RESP_AUTH_REQUIRED with no event + // to explain it. The client must restart the transfer either way, since the + // transfer state is gone regardless. + // + // dropLink=true dispatches on the OWNER'S transport inside the abort -- this + // watchdog is origin-agnostic (both tests below read transfer state, not + // origin), so a timed-out LAN transfer must lose its socket, not some unrelated + // BLE handle. + // Drop the link only when the slot's owner is the transport that OWNS THIS + // TRANSFER. Under the claim CAS the two agree in every sequence I can construct + // -- a session that does not hold the slot is refused rather than admitted, and + // every abort clears transfer state BEFORE releasing -- so this comparison is + // defensive rather than load-bearing. It is kept because the cost is one test + // and the failure it guards against (dropping an innocent client's link over + // another transport's stuck transfer) is invisible from the log. + const LinkId owner = linkOwnerId(); + const bool lanOwnsTransfer = (transferSessionOrigin() != 0); // != ORIGIN_BLE + const bool dropOwnersLink = + (lanOwnsTransfer && owner.who == OWNER_LAN) || + (!lanOwnsTransfer && owner.who == OWNER_BLE); + if (directWriteActive) { uint32_t directWriteDuration = millis() - directWriteStartTime; if (directWriteDuration > TRANSFER_WATCHDOG_MS) { - od_log_error("ERROR: Direct write timeout (%u ms) - cleaning up stuck state", (unsigned)directWriteDuration); - cleanupDirectWriteState(true); - // Parity with the pipe-partial branch below: a full PIPE transfer owns this - // direct-write session as its hardware half, so the pipe half must die with - // it. Left alive, pipeState.active keeps the 0x0081 handler accepting frames - // into a torn-down session -- and because the cleanup above zeroes the byte - // counters, the uncompressed auto-complete test reads 0 >= 0 and drives a - // full refresh at an unpowered panel. Deliberately not folded into - // cleanupDirectWriteState(), which normal END also calls and where the pipe - // reset is already sequenced separately. - if (pipeState.active) resetPipeWriteState(); + od_log_error("ERROR: Direct write timeout (%u ms) - aborting session", (unsigned)directWriteDuration); + abortToKnownState("direct-write transfer watchdog", dropOwnersLink, owner); + return; // the abort cleared every branch below } } if (partialCtx.active && (millis() - partialCtx.start_time) > TRANSFER_WATCHDOG_MS) { - od_log_error("ERROR: Partial write timeout - cleaning up stuck state"); - cleanup_partial_write_state(); - // A pipe-partial transfer shares partialCtx: also clear pipeState so a zombie - // pipeState.active can't misroute later 0x0081 frames into the dead partialCtx. - if (pipeState.partial) resetPipeWriteState(); + od_log_error("ERROR: Partial write timeout - aborting session"); + abortToKnownState("partial transfer watchdog", dropOwnersLink, owner); + return; } // Postcondition over both branches above: a live, non-errored pipe session @@ -2464,7 +2505,7 @@ static void directWriteFinishAndRefresh(uint8_t* data, uint16_t len, uint8_t end // No bbepSleep here: cleanupDirectWriteState(false) releases the session, // keeping the controller awake + rail up when keep-alive holds it warm. } - epdRefreshInProgress = false; + endRefresh(); cleanupDirectWriteState(false); // Request rather than re-arm inline: main.cpp owns the deferral policy and // runs it later in this same loop() pass (the refresh above is reached from @@ -3347,7 +3388,7 @@ static bool partial_write_to_panel(int refreshMode) { { refreshSuccess = partial_trigger_refresh(refreshMode); } - epdRefreshInProgress = false; + endRefresh(); // A successful partial refresh leaves both controller planes consistent. if (refreshSuccess) epdPlanesPrepared = true; // Release keeps the panel warm (rail/SPI up, controller awake) on success; diff --git a/src/display_service.h b/src/display_service.h index 03303b8..6d8e0af 100644 --- a/src/display_service.h +++ b/src/display_service.h @@ -75,6 +75,13 @@ bool imageWriteLogQuietCmd(void); bool imageWriteLogQuietAck(void); bool imageWriteLogQuietFrame(const uint8_t* data, uint16_t len); extern volatile bool epdRefreshInProgress; +/** + * Close the refresh bracket: clears epdRefreshInProgress AND re-stamps the owner's + * activity clock. Every refresh path must end through this rather than assigning + * the flag, or that path silently loses the R4 refresh exclusion and can drop an + * engaged client the moment loop() resumes. + */ +void endRefresh(void); void handlePartialWriteStart(uint8_t* data, uint16_t len); // Both transfer watchdogs, together. They live beside the state they terminate // rather than in loop(): pipeState is reachable from main.cpp via diff --git a/src/encryption.cpp b/src/encryption.cpp index 1508e6e..0b9ac75 100644 --- a/src/encryption.cpp +++ b/src/encryption.cpp @@ -1,4 +1,5 @@ #include "encryption.h" +#include "communication.h" #include "encryption_state.h" #include "od_log.h" @@ -652,6 +653,13 @@ bool handleAuthenticate(uint8_t* data, uint16_t len) { return false; } encryptionSession.authenticated = true; + // A successful handshake ends any run of rejections, even one that has not + // yet reached the threshold. Without this the run survives the very event + // that resolves it: CMD_AUTHENTICATE returns from its own early branch and + // never reaches the post-dispatch reset, so nine rejections followed by a + // good handshake followed by one more rejection would drop a client that + // had just authenticated. + resetAuthAbuseCounter(); encryptionSession.nonce_counter = 0; encryptionSession.last_seen_counter = 0; encryptionSession.integrity_failures = 0; diff --git a/src/link_owner.cpp b/src/link_owner.cpp new file mode 100644 index 0000000..bd9fd9f --- /dev/null +++ b/src/link_owner.cpp @@ -0,0 +1,144 @@ +// Connection ownership arbiter. See link_owner.h for the word layout and why the +// token has to be a single atomic word rather than loop-task-only state. + +#include + +#include "link_owner.h" +#include "od_log.h" + +// The token. Written by CAS from either the stack callback task (BLE connect) or +// the loop task (LAN accept, release), read from both. +static volatile uint32_t s_ownerWord = 0; + +// Epoch source. Fetch-add rather than ++ because BLE allocates on the callback +// task and LAN on the loop task. +static volatile uint16_t s_epochCounter = 0; + +// Activity clock. +// +// ONE baseline, not three timestamps compared with a max(). The policy defines the +// baseline as "the later of admission, last recognised command, and last refresh +// end" -- but each of those events stamps the clock AT the moment it happens, so +// storing millis() into a single variable at each of the three sites already IS +// their maximum, and it stays correct across the ~49.7-day millis() wrap. +// +// A three-timestamp max() is not: comparing stamps pairwise needs signed +// difference arithmetic, which is only meaningful while the values are within 2^31 +// of each other. An admission stamp left far behind a recent command stamp +// straddles that and the comparison inverts, pinning the baseline to the OLDEST +// stamp and reporting a ~49-day silence. Caught by tools/test_link_owner.cpp's +// wrap case; the single baseline removes the class of bug rather than patching the +// comparison. +// +// Atomic, not plain. The command and refresh stamps are loop-task-only as the +// policy says, but ADMISSION is not: linkClaim() runs in the BLE connect callback, +// on the stack host task. So the variable genuinely has two writing contexts and a +// plain global would be a data race regardless of how benign the values look. +static volatile uint32_t s_baselineMs = 0; + +// Which owner the baseline above belongs to. The baseline cannot be published +// atomically WITH the owner word (two words, no lock-free 64-bit CAS here), and the +// gap is not theoretical: linkClaim's CAS publishes the new owner first, so between +// it and the baseline store a reader sees the NEW owner beside the PREVIOUS owner's +// baseline and computes an arbitrarily large silence -- which Phase 3 would act on +// by dropping a client that just connected. +// +// Re-reading the owner word around the load does not fix that, because both reads +// see the same (new) owner. Tagging does: the claimer stores the baseline, THEN +// release-stores this tag, so a reader whose tag does not match the live owner +// knows the baseline is not yet its own and reports 0 -- "just admitted", which is +// both true and the safe direction. +static volatile uint32_t s_baselineOwner = 0; + +uint16_t linkNextEpoch(void) { + uint16_t e; + do { + e = __atomic_add_fetch(&s_epochCounter, 1, __ATOMIC_RELAXED); + } while (e == 0); // 0 is reserved: the all-zero word means unowned + return e; +} + +bool linkClaim(LinkId id) { + if (id.who != OWNER_BLE && id.who != OWNER_LAN) return false; + uint32_t expected = 0; // succeeds ONLY against unowned + const uint32_t desired = linkIdWord(id); + const bool won = __atomic_compare_exchange_n(&s_ownerWord, &expected, desired, + false, __ATOMIC_ACQ_REL, __ATOMIC_ACQUIRE); + if (won) { + // Admission starts the idle window (R4): a freshly admitted client gets a + // full window before its first command, rather than reading as infinitely + // idle the instant it connects. + // + // Baseline first, then the tag that claims it for this owner. A reader that + // catches the gap sees a tag that does not match the live owner and reports + // 0 rather than the previous owner's stale baseline. + __atomic_store_n(&s_baselineMs, millis(), __ATOMIC_RELAXED); + __atomic_store_n(&s_baselineOwner, desired, __ATOMIC_RELEASE); + } + return won; +} + +void linkRelease(LinkId id) { + if (id.who == OWNER_NONE || id.who == OWNER_TERMINAL) return; + uint32_t expected = linkIdWord(id); + // Full-identity CAS: a release carrying a stale epoch, a foreign transport, or + // the identity the terminal gate displaced simply fails and is inert. + (void)__atomic_compare_exchange_n(&s_ownerWord, &expected, (uint32_t)0, + false, __ATOMIC_ACQ_REL, __ATOMIC_ACQUIRE); +} + +LinkId linkMarkTerminal(void) { + const uint32_t prev = __atomic_exchange_n(&s_ownerWord, (uint32_t)OD_LINK_WORD_TERMINAL, + __ATOMIC_ACQ_REL); + return linkUnpackWord(prev); +} + +uint32_t linkOwnerWord(void) { + return __atomic_load_n(&s_ownerWord, __ATOMIC_ACQUIRE); +} + +LinkId linkOwnerId(void) { + return linkUnpackWord(linkOwnerWord()); +} + +bool linkIsOwner(LinkId id) { + if (id.who == OWNER_NONE) return false; + return linkIdWord(id) == linkOwnerWord(); +} + +// --- activity clock ---------------------------------------------------------- +uint32_t linkMsSinceOwnerCommand(void) { + const uint32_t owner = linkOwnerWord(); + const LinkId id = linkUnpackWord(owner); + if (id.who != OWNER_BLE && id.who != OWNER_LAN) return 0; + // Acquire the tag BEFORE the baseline it guards: if the tag already names this + // owner, the release-store that published it also published the baseline. + if (__atomic_load_n(&s_baselineOwner, __ATOMIC_ACQUIRE) != owner) { + return 0; // claim still in flight: this owner has no baseline yet + } + const uint32_t base = __atomic_load_n(&s_baselineMs, __ATOMIC_RELAXED); + // Re-check that the owner did not change under us; if it did, `base` may belong + // to a different session and 0 is the right answer for the one that just took + // the slot. + if (linkOwnerWord() != owner) return 0; + // One unsigned subtraction, which is wrap-correct by construction: modular + // arithmetic gives the true elapsed interval for any gap under 2^32 ms. + return millis() - base; +} + +void linkStampOwnerCommand(void) { + // Loop-task-only, and only ever for the live owner, so the tag is already this + // owner's -- but stamp it the same way regardless, so the two writers publish + // through one discipline. + __atomic_store_n(&s_baselineMs, millis(), __ATOMIC_RELAXED); + __atomic_store_n(&s_baselineOwner, linkOwnerWord(), __ATOMIC_RELEASE); +} + +void linkStampRefreshEnd(void) { + // Unconditional by design: because each stamp writes the CURRENT time, and time + // only moves forward, re-stamping can only ever DELAY a drop and never cause a + // spurious one -- which is what makes it safe to apply at the transition + // without first testing who owns the slot. + __atomic_store_n(&s_baselineMs, millis(), __ATOMIC_RELAXED); + __atomic_store_n(&s_baselineOwner, linkOwnerWord(), __ATOMIC_RELEASE); +} diff --git a/src/link_owner.h b/src/link_owner.h new file mode 100644 index 0000000..8d1abec --- /dev/null +++ b/src/link_owner.h @@ -0,0 +1,126 @@ +#ifndef LINK_OWNER_H +#define LINK_OWNER_H + +#include + +// Connection ownership arbiter -- CONNECTION_POLICY R1/R2, and the activity clock +// of R4. Deliberately free of BLE/WiFi headers: the BLE transports, the LAN +// transport and the session guard all include it, and it must not drag a stack +// header into any of them. +// +// THE TOKEN IS ONE 32-BIT WORD, and that is the whole design: +// +// [31:30] transport [29:16] handle [15:0] epoch +// +// A claim is one compare-and-swap against it; a read is one atomic load. That is +// what lets a stack callback -- which runs on the host task, long before any loop +// pass -- decide "is this write from the owner?" without a lock. An earlier draft +// made the token loop-task-only plain state while still requiring callback-side +// write filtering; those are incompatible, because at the moment a contender's +// onWrite fires there is nothing for the filter to compare against. +// +// Why 16 bits of epoch: the word must stay lock-free, and neither Cortex-M4 nor +// the ESP32 ISAs have a lock-free 64-bit CAS. HCI connection handles are +// spec-bounded at 0x0EFF (12 bits), so 14 bits holds them with headroom. The +// invariant the width rests on is that no outstanding event may survive a full +// counter cycle: epochs churn at link-layer connection rate (tens of ms each), so +// a 2^16 cycle needs ~half an hour of continuous connect churn inside a single +// blocking window that later COMPLETES. A refresh that never completes never +// resumes loop(), so nothing is consumed there and a collision has no consumer to +// mislead. +// +// All-zero means unowned, so epoch 0 is never allocated (linkNextEpoch re-draws). + +enum LinkOwnerKind { + OWNER_NONE = 0, + OWNER_BLE = 1, + OWNER_LAN = 2, + // One-way admission gate for a terminal transition (deep sleep). Claims fail + // against it, so no connection can be admitted between the abort's release and + // the stack teardown that follows. Nothing transitions out of it: the next wake + // reloads RAM. See CONNECTION_POLICY 7e row 3. + OWNER_TERMINAL = 3, +}; + +struct LinkId { + uint8_t who; // LinkOwnerKind + uint16_t handle; // BLE conn handle; LAN is single-socket and uses 0 + uint16_t epoch; +}; + +#define OD_LINK_WORD_TERMINAL 0xC0000000UL + +static inline uint32_t linkPackWord(uint8_t who, uint16_t handle, uint16_t epoch) { + return ((uint32_t)(who & 0x3) << 30) | ((uint32_t)(handle & 0x3FFF) << 16) | (uint32_t)epoch; +} + +static inline LinkId linkUnpackWord(uint32_t w) { + LinkId id; + id.who = (uint8_t)((w >> 30) & 0x3); + id.handle = (uint16_t)((w >> 16) & 0x3FFF); + id.epoch = (uint16_t)(w & 0xFFFF); + return id; +} + +static inline uint32_t linkIdWord(LinkId id) { return linkPackWord(id.who, id.handle, id.epoch); } + +// Allocated in the connect callback for EVERY connection instance, admitted or +// not -- never on successful claim. A refused contender that carried no epoch +// could not be told apart from an incumbent that reused its handle (7a row 4), +// and the identity is what the admission decision is MADE on. Atomic because BLE +// allocates on the stack callback task and LAN on the loop task. +uint16_t linkNextEpoch(void); + +// One CAS. Succeeds only against the all-zero (unowned) word, so it is safe to +// call from a stack callback and it is where admission actually happens. Failure +// means "someone else owns the slot" -- the caller is a contender. +bool linkClaim(LinkId id); + +// CAS holder -> unowned. Loop task only, and only after R3a's link-down wait. +// Matches the FULL identity, so a stale-epoch release is inert, and it can never +// zero the terminal word (which is not a valid `id` to pass here). +void linkRelease(LinkId id); + +// Unconditional exchange to OWNER_TERMINAL, returning the identity it displaced. +// The caller hands that identity to abortToKnownState(): after this returns, +// linkOwnerId() reads terminal, NOT the departing owner, so the abort must not +// re-derive it. Deep-sleep path only. +LinkId linkMarkTerminal(void); + +// One atomic load; callable from ANY task, which is the point. +LinkId linkOwnerId(void); +uint32_t linkOwnerWord(void); + +// Full-triple comparison. A handle alone is never sufficient: NimBLE reuses +// handles from 0 upward, so a reconnect can be handed the same one. +bool linkIsOwner(LinkId id); +static inline bool linkIsOwnerWord(uint32_t w) { return w != 0 && w == linkOwnerWord(); } + +// --- activity clock (CONNECTION_POLICY R4) ----------------------------------- +// "Idle" is: no inbound command from the owner on the owning transport, AND no +// refresh in progress. +// +// NOT loop-task-only, despite what the policy's phrasing suggests: the command and +// refresh stamps are, but ADMISSION stamps the baseline too, and that runs in the +// BLE connect callback on the stack host task. So the state is atomic, and the +// baseline carries the owner word it belongs to -- otherwise a reader can pair a +// newly published owner with the previous owner's baseline and judge a client that +// just connected to have been silent for minutes. +// +// The baseline is the LATER of admission, last recognised owner command, and last +// refresh end. Admission is included so a freshly admitted, still-silent client +// gets a full window before its first command instead of reading as infinitely +// idle. Refresh end is included because loop() does not run for a refresh's +// duration while wall-clock time passes, so a naive millis()-lastStamp would +// accrue the whole refresh and drop an actively engaged client the instant loop() +// resumes. +uint32_t linkMsSinceOwnerCommand(void); // 0 when unowned +// Restart the idle window for the current owner. Named for its main caller -- the +// dispatcher, on a recognised command from the owner -- but it is also what starts +// the window at a point the policy defines as a fresh baseline rather than as +// activity: LAN calls it at TLS handshake COMPLETION, because handshake traffic is +// not a command and the window must not run from TCP accept. +void linkStampOwnerCommand(void); +void linkStampRefreshEnd(void); // endRefresh(), both bracket sites + +#endif // LINK_OWNER_H diff --git a/src/main.cpp b/src/main.cpp index 6450ee5..3352ae0 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -9,6 +9,8 @@ #include "touch_input.h" #include "encryption.h" #include "ble_transport.h" +#include "link_owner.h" +#include "session_guard.h" #include "od_log.h" #if defined(TARGET_ESP32) && defined(OPENDISPLAY_LOG_UART) @@ -288,10 +290,26 @@ uint32_t getDeepSleepCount() { // when nRF's stack callbacks stopped running application code -- before that the // disconnect path executed on the SoftDevice callback task. static bool s_disconnectCleanupPending = false; +// The owner word as it stood when the cleanup was requested. The flag alone is not +// enough: it is a bare boolean shared by the BLE and LAN teardown paths, so by the +// time it is serviced -- deferred past a refresh, possibly several passes later -- +// the session it was raised for may already have been torn down by some OTHER path +// (the idle timeout or the transfer watchdog, both of which abort and release). +// Servicing it then would run a destructive teardown against whoever holds the slot +// NOW, resetting a freshly admitted client's crypto and rings and stalling it. +// +// Recording the identity turns "something disconnected" into "THIS session +// disconnected", which is the only form the abort can act on safely. +static uint32_t s_cleanupForOwner = 0; static bool s_advertisingRestartPending = false; static bool s_msdUpdatePending = false; void requestTransferSessionCleanup(void) { + // Capture the owner here rather than at service time: this is the moment the + // departing session is still identifiable. Callers on the LAN path still hold + // the token at this point (release is the abort's final step), and the BLE + // raise site does the same. + s_cleanupForOwner = linkOwnerWord(); s_disconnectCleanupPending = true; } @@ -387,39 +405,78 @@ static void pollActivity() { // clears it. Also raised by the LAN transport, so it is not a BLE-only path. static void serviceBleDisconnectCleanup() { if (!s_disconnectCleanupPending || epdRefreshInProgress) return; + const uint32_t forOwner = s_cleanupForOwner; s_disconnectCleanupPending = false; + s_cleanupForOwner = 0; + // Act only if the slot still holds exactly what it held when this was raised. + // Any difference means that session has already been torn down and released by + // another path -- the idle timeout and the transfer watchdog both abort and + // release -- so there is nothing left for this to do, and proceeding would + // apply the teardown to whoever holds the slot now. + if (forOwner == 0) { + // Raised while nothing owned the slot -- restartWiFiLanAfterReconnect() + // calls disconnectWiFiServer() unconditionally, so this is routine. Zero is + // not a session identity and must not authorise a destructive teardown: a + // BLE claim landing between the test below and the abort would have its + // fresh crypto and rings reset, and the abort's release (passed NONE) would + // not even free the slot afterwards. There is by definition no session to + // tear down here; genuinely orphaned transfer state is healed by the + // orphaned-pipe repair in checkTransferTimeouts(). + od_log_debug("Disconnect cleanup skipped: raised with no owner"); + return; + } + if (linkOwnerWord() != forOwner) { + od_log_debug("Disconnect cleanup skipped: slot changed hands since it was raised"); + return; + } // BLE and LAN both raise this flag, so tear down only when the transport that - // OWNS the in-flight transfer is the one that went away. Otherwise a BLE - // disconnect kills a live LAN push (and a LAN disconnect kills a BLE push) - // purely because the other link dropped. Owner is recorded at START. + // OWNS THE SLOT is the one that went away. Otherwise a BLE disconnect kills a + // live LAN push (and a LAN disconnect kills a BLE push) purely because the + // other link dropped. + // + // The question is asked of the OWNER TOKEN and the instance table, not of + // transferSessionOrigin() plus ble.isConnected() as it used to be. Two reasons, + // and the second is a live defect the aggregate count would reintroduce: // - // The guard is NOT inside OPENDISPLAY_HAS_WIFI, though it used to be. Only the - // LAN half is WiFi-specific; ble.isConnected() is meaningful on every target, and - // gating the whole test left nRF with no guard at all. That mattered: this flag - // can be serviced tens of seconds late (loop() blocked in an EPD refresh), by - // which time a NEW client may be connected and mid-transfer -- and the - // resetPipeWriteState() below would destroy its session, not the departed one's. + // - The token is authoritative about who holds the slot, whereas + // transferSessionOrigin() only says who started the last transfer -- so the + // old test could not answer the question at all when no transfer was running, + // which is exactly when a stranded token matters most. + // - ble.isConnected() is the stack's TOTAL peer count. With a refused contender + // transiently attached (which R1 explicitly permits), it stays true after the + // owner leaves, so the old guard would skip the cleanup and the slot would + // never be released -- every later client refused service until reboot. The + // per-instance instanceLive() test is immune to that. + // + // The comparison also means a LOST disconnect edge cannot strand the slot: if + // the owner's instance is no longer live in the table, the owner is gone, + // however many events coalesced away while loop() sat in a refresh. + const LinkId owner = linkOwnerId(); + bool ownerStillUp = false; + if (owner.who == OWNER_BLE) { + ownerStillUp = ble.instanceLive(owner.handle, owner.epoch); #ifdef OPENDISPLAY_HAS_WIFI - const bool lanOwnsSession = (transferSessionOrigin() != 0); // != ORIGIN_BLE - const bool ownerStillUp = lanOwnsSession ? wifiLanClientConnected() : ble.isConnected(); -#else - const bool lanOwnsSession = false; - const bool ownerStillUp = ble.isConnected(); + } else if (owner.who == OWNER_LAN) { + ownerStillUp = wifiLanClientConnected(); #endif + } if (ownerStillUp) { - od_log_info("Disconnect cleanup skipped: transfer still owned by a live %s session", - lanOwnsSession ? "LAN" : "BLE"); + od_log_info("Disconnect cleanup skipped: slot still held by a live %s session", + owner.who == OWNER_BLE ? "BLE" : "LAN"); return; } - // ACTIVE-only-teardown invariant: a WARM (post-successful-refresh) panel - // SURVIVES disconnect and keeps its keep-alive window, so the cleanups below - // no-op on power when WARM and only tear down a mid-transfer (PWR_ACTIVE) - // session. No logic change needed for keep-alive. - if (directWriteActive) cleanupDirectWriteState(true); - // Partial sessions (0x76 or pipe-partial) power the panel without setting - // directWriteActive; release it here instead of waiting on the 15-min watchdog. - cleanupPartialWriteOnDisconnect(); - resetPipeWriteState(); // clear any pipe transfer + reorder queue on disconnect + // Route the teardown through the ONE shared routine (R6). Keeping a second + // teardown path here is exactly how the direct-write watchdog once tore down a + // panel while leaving its pipe session live. + // + // dropLink=false: the link is already gone, which is what the test above + // establishes. The abort's own release is its last step, so the slot frees here + // rather than at the event -- and the WARM-panel survival the old inline code + // relied on is preserved inside the abort (cleanupDirectWriteState no-ops on + // WARM). With the slot already unowned this is still worth running: it is + // idempotent, and it is what clears a transfer left behind by a session whose + // token was released on some other path. + abortToKnownState("owner disconnected", false, owner); } // Deferral policy for re-arming the radio, formerly buried inside @@ -459,43 +516,73 @@ static void serviceBleAdvertisingRestart() { // s_advertisingRestartPending as its only trace of a connect+drop landing // entirely between two passes. static void serviceBleEvents() { - if (ble.takeConnectedEvent()) { + uint32_t connectedWord = 0; + (void)ble.takeConnectedEvent(&connectedWord); + // Connect-side work is driven by OWNERSHIP, not by the event. + // + // Only the owner's connect may drive it: a refused contender must not perturb + // incumbent-visible state (R3), and every item below is exactly that -- reboot + // state, an MSD rebuild that polls I2C and republishes the advertisement, and + // link tuning aimed at the owner's link. + // + // But gating on the EVENT's identity is not enough either. The event word is a + // single slot: if the owner connects and a contender connects before the loop + // consumes the flag, the word holds only the contender, and the owner's connect + // work would be lost for the life of the session -- no fast link, no MSD update. + // Comparing against the last owner this work ran for is immune to any number of + // coalesced edges, which is the same "state, not events" argument that made the + // instance table a table. + static uint32_t s_connectWorkDoneFor = 0; + const uint32_t owner = linkOwnerWord(); + const LinkId ownerId = linkUnpackWord(owner); + if (owner != 0 && ownerId.who == OWNER_BLE && owner != s_connectWorkDoneFor && + ble.instanceLive(ownerId.handle, ownerId.epoch)) { + s_connectWorkDoneFor = owner; rebootFlag = 0; s_msdUpdatePending = true; // SoftDevice PHY/DLE calls on nRF, no-op on ESP32. Deliberately here and - // not in the connect callback: the callback contract is copy-and-flag only. + // not in the connect callback: the callback contract is copy-and-flag only + // (plus the one claim CAS, which is a single word write). ble.requestFastLink(); } - uint8_t disconnectReason = 0; - uint8_t rxBoundary = 0; - if (ble.takeDisconnectedEvent(&disconnectReason, &rxBoundary)) { - od_log_info("Disconnect reason: %u", disconnectReason); - // Drop anything the departed client left in the RX ring. Without this, - // serviceBleRx() runs BEFORE serviceBleDisconnectCleanup() in the pass, so - // up to a full window of frames from a dead session would dispatch -- - // touching pipe/partial state that resetPipeWriteState() is about to - // discard anyway, and emitting responses that queueBleNotifyCopy() then - // drops for want of a connection. + uint16_t disconnectReason = 0; + uint32_t disconnectedWord = 0; + if (ble.takeDisconnectedEvent(&disconnectReason, &disconnectedWord)) { + // 0x%03X so a wrapped NimBLE HCI reason (0x213) and a host-layer one (0x007) + // are visibly distinct. Printed as decimal %u from a truncated byte, they + // used to collide on screen as well as in storage. + od_log_info("Disconnect reason: 0x%03X", (unsigned)disconnectReason); + // No RX flush here any more. Frames carry their writer's identity, so + // serviceBleRx() drops a departed session's frames at dispatch -- which also + // covers the case this site could never handle: a boundary lost because the + // stack reused the handle before the loop got here. // - // Bounded by rxBoundary, the ring head captured when that link went down. - // "Discard everything present now" was wrong: loop() can sit inside a ~16 s - // EPD refresh, and a disconnect, a reconnect, and the NEW client's first - // command all land before this event is serviced -- so the flush ate a frame - // from a session that had never disconnected. - // - // Deliberately here and NOT in serviceBleDisconnectCleanup(): that flag is - // raised by the LAN transport too, and a LAN drop must not discard queued - // BLE frames. Only a real BLE disconnect event invalidates this ring. - const uint8_t droppedRx = bleRxQueueDiscardTo(rxBoundary); - if (droppedRx > 0) { - od_log_warn("Dropped %u queued command(s) from the disconnected client", droppedRx); - } // Raise the flag; do NOT tear the session down here. The teardown belongs // in serviceBleDisconnectCleanup(), which holds it off while an EPD // refresh is mid-flight and checks whether LAN still owns the transfer. // Doing it inline would reintroduce the mid-refresh SPI teardown that // moving nRF off the callback task was meant to eliminate. - s_disconnectCleanupPending = true; + // + // Raised on OWNER DEPARTURE, not on any disconnect event. R3 requires a + // refused contender's disconnect to be inert, and refusal now produces a + // real disconnect event of its own -- so an unconditional raise here would + // make every refusal schedule a session teardown. That teardown is skipped + // a pass later by the live-owner guard, but "correct because something + // downstream catches it" is exactly the coupling R3 forbids. + // + // The test is state, not the event's identity, so it survives coalescing: + // if the token's BLE owner no longer has a live table entry, the owner is + // gone however many edges were lost. A LAN owner's departure raises the + // same flag from its own path (requestTransferSessionCleanup). + const LinkId tokenOwner = linkOwnerId(); + const bool ownerDeparted = + (tokenOwner.who == OWNER_BLE && + !ble.instanceLive(tokenOwner.handle, tokenOwner.epoch)); + if (ownerDeparted) { + requestTransferSessionCleanup(); // records the identity it is for + } else { + od_log_debug("Disconnect event from a non-owner instance; no cleanup scheduled"); + } // Raised unconditionally: serviceBleAdvertisingRestart() owns the // capability decision, so this site does not need to know whether the // stack re-arms the radio by itself. On such a target the flag is simply @@ -504,6 +591,155 @@ static void serviceBleEvents() { } } +// Disconnect every live BLE instance that is not the owner (CONNECTION_POLICY R3). +// +// SCOPE NOTE. The freeze-hardening plan assigns refusal to Phase 3, and this is +// Phase 2. It is here because Phase 2 is not safely shippable without it: admission +// is decided once per instance, at its connect hook, and never revisited, so a +// client that reconnects into a still-held slot -- the ordinary case when loop() +// was blocked in a ~16 s refresh -- becomes a permanent contender. On nRF, whose +// single peripheral link it now occupies, nothing else can connect either, so the +// device is unreachable until that client happens to leave. The two alternatives +// were worse: releasing the token in the disconnect callback admits a new owner +// while the departed session's transfer, crypto and TX ring are still live, and +// promoting a contender from this scan is exactly what 7a row 10 forbids. +// +// What is NOT here is the rest of Phase 3: no idle timeout, no reclaim of a held +// slot. This only makes refusal actually happen, which is what the "decided once" +// rule assumes. +// +// A TABLE SCAN, not an event handler: a refusal missed because two connects +// coalesced self-corrects on the next pass, where an event-driven version would +// leak the contender permanently. Refusal is idempotent and inert -- re-refusing an +// entry already tearing down costs nothing, and NimBLE reports "already gone" as +// success -- so no bookkeeping is needed to avoid repeats. +// +// Refusal touches NOTHING but the contender's own link: no abort, no +// s_disconnectCleanupPending, no linkRelease. The incumbent must be unable to +// observe that a contender arrived, which is why this is a separate helper rather +// than a branch inside the disconnect path it would otherwise resemble. +static void serviceContenderRefusal() { + // Refuse an entry only once its claim has been DECIDED and it is not the owner. + // + // Both halves of that test are load-bearing, and each replaces a wrong rule: + // + // - Testing ownership alone would disconnect the winner. The connect callback + // publishes its table entry BEFORE attempting the claim (R2's normative + // order), so an entry can be visible while its CAS has not run; comparing it + // against an owner word snapshotted moments earlier can refuse the very + // connection that is taking the slot. + // - Skipping the scan whenever the slot is unowned -- an intermediate fix -- + // leaves a decided loser attached forever. That is exactly the sequence this + // whole helper exists for: the owner departs, the abort releases the token, + // and the client that reconnected and lost its one-shot claim is then never + // reaped, because by the time anyone looks the slot is free. On nRF it holds + // the only peripheral link, so the device stops accepting clients entirely. + // + // An undecided entry is simply skipped; the next pass sees it resolved. That is + // safe because refusal has no deadline -- a contender's writes are already + // filtered and its frames already fail the dispatch tag check. + // ORDER OF THE THREE LOADS IS THE CORRECTNESS ARGUMENT. They cannot be taken + // atomically, so each is re-derived in the order that makes a stale read safe: + // + // 1. the entry word -- the candidate's identity + // 2. its decided-for word -- must EQUAL (1), which proves the claim + // resolved for this exact instance and that the + // slot was not retired and reused underneath us + // 3. the owner word, read FRESH and last -- if the candidate just won, this + // now names it and the entry is skipped + // + // Reading the owner once up front (as an earlier version did) is the bug: an + // entry can publish, win its claim and resolve between that snapshot and the + // per-entry loads, and would then be refused despite being the new owner. After + // step 3 the candidate can no longer become owner, because each instance + // attempts its claim exactly once and step 2 proved that attempt is over. + const uint8_t cap = BleTransport::instanceCapacity(); + for (uint8_t i = 0; i < cap; i++) { + const uint32_t w = ble.instanceWordAt(i); + if (w == 0) continue; + if (ble.instanceClaimDecidedWordAt(i) != w) continue; // in flight, or slot reused + if (w == linkOwnerWord()) continue; // it won; not a contender + const LinkId id = linkUnpackWord(w); + od_log_info("Refusing contender h=%u e=%u (slot held)", (unsigned)id.handle, + (unsigned)id.epoch); + (void)ble.disconnect(id.handle, id.epoch); + } +} + +// How long an ADMITTED client may stay silent before its link is dropped and the +// slot reclaimed (CONNECTION_POLICY R4, 7c row 1). +// +// CLIENT BEHAVIOUR THIS ASSUMES: py-opendisplay authenticates within one exchange +// of connecting and, during a transfer, never leaves more than a few seconds +// between frames. 120 s is therefore two orders of magnitude above any legitimate +// inter-command gap. If a client change ever pushes real silence toward this +// value, the assertion in py-opendisplay's suite is what should fail first, not a +// field report. +// +// WHY SO GENEROUS -- the direction of the error inverted when R4 landed, so this +// is not an oversight in the other direction: +// - While the drop was gated on !transferActive(), a short timeout could only +// ever kill an idle session, so erring short was cheap. +// - R4 removed that gate. This can now terminate an in-progress UPLOAD whose +// client went quiet, so erring short costs a legitimate transfer. +// The accepted cost is bounded and lands on one case: a returning client waits up +// to 120 s behind a stale-but-ALIVE incumbent. A client that is genuinely gone is +// reaped by the link layer in ~4-6 s (this firmware requests no supervision +// timeout, so the central's negotiated value applies), so the lockout never +// applies to a crashed or out-of-range peer. +// +// Firmware-local rather than a wire field, unlike its LAN cousin: OD_LAN_READ_TIMEOUT_S +// lives in opendisplay_protocol.h because it is a documented client-visible +// contract, and this plan may not touch that header. The asymmetry is deliberate. +#ifndef OD_BLE_IDLE_TIMEOUT_MS +#define OD_BLE_IDLE_TIMEOUT_MS 120000UL +#endif + +// Reclaim a slot held by a client that has gone silent (7c). This is the ONLY way +// a held slot is ever released short of the client leaving, because admission +// never evicts -- refusal and reclaim are deliberately independent mechanisms, so +// an incumbent's fate never depends on whether someone else happened to knock. +// +// Runs LAST in the pass (7d step 4) so traffic parsed earlier this pass counts. +// That ordering is load-bearing for LAN, where inbound bytes can be sitting in the +// socket when the deadline is evaluated. +static void serviceIdleTimeout() { + // 7c row 3: a refresh is not idleness. loop() does not run for its duration + // while wall-clock time passes, so this would otherwise fire the instant a + // long refresh ended. endRefresh() re-stamps the clock at the transition; this + // guard covers the pass in which the refresh is still running. + if (epdRefreshInProgress) return; + const LinkId owner = linkOwnerId(); + // 7c row 4: no owner, no timer. Also excludes OWNER_TERMINAL, where the device + // is on its way into deep sleep and there is nothing left to reclaim. + if (owner.who != OWNER_BLE && owner.who != OWNER_LAN) return; + + // R4's "each transport enforces its own timer and constant" is satisfied by the + // CONSTANTS differing, not by duplicating the clock -- one clock is what keeps + // the two from drifting apart in what they consider activity. + uint32_t limitMs = OD_BLE_IDLE_TIMEOUT_MS; +#ifdef OPENDISPLAY_HAS_WIFI + if (owner.who == OWNER_LAN) limitMs = (uint32_t)OD_LAN_READ_TIMEOUT_S * 1000UL; +#endif + + const uint32_t idleMs = linkMsSinceOwnerCommand(); + if (idleMs <= limitMs) return; // 7c row 2 + + // NO transferActive() GATE, and that is the whole point of R4. A client that + // goes silent DURING an upload is precisely the case that wedges the device, + // and a transfer gate would exempt exactly it. The partial transfer is + // discarded by the abort; partial upload state is never preserved across a + // drop. What remains uncaught is narrower -- a client that keeps sending + // recognised commands while its transfer never completes -- and that is still + // bounded only by the from-START transfer watchdog. + // + // WARN with the measured value, so field tuning has data rather than guesses. + od_log_warn("Idle timeout: %s owner silent %u ms (limit %u ms) - dropping", + owner.who == OWNER_BLE ? "BLE" : "LAN", + (unsigned)idleMs, (unsigned)limitMs); + abortToKnownState("idle timeout", true, owner); +} + // Bounded drain: service up to COMMAND_QUEUE_SIZE commands per pass so a // sustained W-deep PIPE_WRITE window burst isn't starved at one-per-loop, while // the rest of loop() still runs each pass. Responses are flushed BETWEEN @@ -515,17 +751,40 @@ static void serviceBleEvents() { // and corrupt multi-frame transfer state mid-stream. static void serviceBleRx() { uint8_t drained = 0; + uint16_t staleDropped = 0; while (drained < COMMAND_QUEUE_SIZE) { CommandQueueItem* item = bleRxQueuePeek(); if (item == nullptr) break; + // CONNECTION_POLICY R3 requirement 6, and the whole of it: a frame executes + // only if its writer is STILL the owner. The write callback already refused + // non-owners, so this catches the other half -- a frame that was legitimate + // on arrival but whose session ended before loop() drained it. That is + // reachable whenever loop() was blocked in a ~16 s refresh: the owner + // disconnects, a new client connects (possibly on the same reused handle), + // and the old frames are still sitting here. + // + // This replaced the RX-boundary flush, which could not survive the table + // entry being overwritten by handle reuse before the loop scanned. One + // compare per frame, and no boundary to lose. + if (!linkIsOwnerWord(item->tag)) { + bleRxQueueConsume(); + staleDropped++; + continue; + } + // Publish the frame's identity for the dispatcher's activity-clock test. + g_commandInstance = item->tag; // imageDataWritten (misleading name) actually services any BLE command. // The dispatch banner (commandName() in communication.cpp) already logs // which command runs, so no drain-start/-end framing line is needed here. imageDataWritten(0, nullptr, item->data, item->len); + g_commandInstance = 0; bleRxQueueConsume(); drained++; serviceBleTx(); } + if (staleDropped > 0) { + od_log_warn("Dropped %u queued command(s) from a departed session", (unsigned)staleDropped); + } } // Platform policy hook 1: work this target does before the shared body, with the @@ -537,7 +796,16 @@ static bool platformLoopPrologue() { pollActivity(); // THIS IS THE MAIN (FIRST) LOOP FOR A DEEP SLEEP ENABLED ESP32 if (woke_from_deep_sleep && advertising_timeout_active) { - if (ble.isConnected()) { + // An ADMITTED client, not merely a physical link. ble.isConnected() is the + // stack's aggregate peer count, so a contender -- including a client that + // reconnected into a still-held slot and lost its claim -- would trip this + // branch, run fullSetupAfterConnection(), close the wake window and return + // before the refusal scan ever gets to reap it. Application-visible setup + // work and a changed sleep decision, both driven by a connection that is + // never going to be serviced. + const LinkId prologueOwner = linkOwnerId(); + if (prologueOwner.who == OWNER_BLE && + ble.instanceLive(prologueOwner.handle, prologueOwner.epoch)) { od_log_info("BLE connection established - switching to full mode"); advertising_timeout_active = false; fullSetupAfterConnection(); @@ -547,6 +815,7 @@ static bool platformLoopPrologue() { // A connect+drop entirely inside one poll gap leaves the radio dark for the // rest of the window; the flags are otherwise only serviced past this return. serviceBleDisconnectCleanup(); // tear down before re-advertising + serviceContenderRefusal(); // reap a contender rather than idling behind it serviceBleAdvertisingRestart(); uint32_t advertising_timeout_ms = globalConfig.power_option.sleep_timeout_ms; if (advertising_timeout_ms == 0) { @@ -629,12 +898,42 @@ void loop() { if (platformLoopPrologue()) return; - // Drain commands, then service the deferred work the stack callbacks flagged. - // Cleanup runs before the advertising restart so a disconnected session is - // fully torn down before the radio re-arms. + // WITHIN-PASS ORDER IS NORMATIVE (CONNECTION_POLICY R7d), not incidental: + // + // 1. owner disconnects -- release + abort FIRST, so a slot freed this pass + // is available to an admission decision in the same + // pass, and so a departed session's state is gone + // before any frame is dispatched against it + // 2. refusals -- contenders reaped before they can linger + // 3. inbound traffic -- stamps the activity clock + // (4. idle timeout -- Phase 3, and it must run last so traffic parsed in + // step 3 counts) + // + // The cleanup used to run AFTER the RX drain. That ordering is what made the + // frame tag load-bearing rather than merely defensive, and reversing it removes + // a whole class of "old session's frames meet new session's state" hazard + // instead of relying on the tag to catch every instance of it. + // + // Note this order resolves ties WITHIN a pass only. The authoritative + // arbitration point is the earliest transport hook -- the connect callback's + // claim CAS -- because a BLE connect during a refresh and a LAN socket sitting + // in the listen backlog are not comparable by the time loop() resumes. +#ifdef OPENDISPLAY_HAS_WIFI + // Reap a LAN socket the peer has closed BEFORE the cleanup below, so the token + // is released in THIS pass and handleWiFiServer()'s accept -- later in the same + // pass -- sees a free slot (7d step 1 before step 2). + // + // Doing this inside handleWiFiServer, where it was first placed, is too late: + // the reap only raises the deferred cleanup, so the accept a few lines further + // on still tested the corpse's token and refused an ordinary reconnect. A + // client that closes and immediately reopens between two pushes is the common + // case, and one that does not retry would simply not be served. + wifiLanReapClosedSession(); +#endif + serviceBleDisconnectCleanup(); + serviceContenderRefusal(); serviceBleRx(); serviceBleTx(); - serviceBleDisconnectCleanup(); if (s_msdUpdatePending) { s_msdUpdatePending = false; updatemsdata(); @@ -675,6 +974,19 @@ void loop() { const bool wifiLanSession = false; #endif + // 7d step 4, and it must stay LAST of the four. Traffic parsed earlier in this + // pass -- BLE in serviceBleRx(), LAN in handleWiFiServer() just above -- has + // already stamped the activity clock, so a client whose command arrived this + // pass is never judged idle on the strength of it not having been read yet. + // Moving this above handleWiFiServer() would reintroduce exactly that for LAN. + serviceIdleTimeout(); + // After the idle check, and last of the session-policy steps. It ends in the + // abort like the idle drop does, so it must not run before inbound traffic has + // been parsed this pass -- an accepted command clears the rejection run, and + // dropping a client that just authenticated would be the worst possible + // outcome for a mechanism whose entire value is speed. + serviceBleAuthAbuseDisconnect(); + // Work in flight *this iteration* only. Every term is transient and most are // cleared earlier in this same pass, so this must never be the sole gate on // deep sleep — lastActivityMs supplies the quiet window. The terms that only @@ -802,6 +1114,28 @@ void enterDeepSleep(bool force, uint16_t overrideSleepSeconds) { return; } // Panel power-down MUST sit below every early-return above (including the + // ORDER IS NORMATIVE (CONNECTION_POLICY 7e row 3): gate admission, THEN abort, + // THEN take the stack down. + // + // linkMarkTerminal() first, or there is a race: the abort's last step frees the + // owner word while this link may still be up and advertising is still on, so a + // connect on the host task could win the freed word and the new owner would be + // destroyed by ble.end() below with no abort ever run for it. The terminal + // exchange makes every later claim fail, and it returns the identity it + // displaced -- which the abort must be handed, because from here on + // linkOwnerId() reads terminal rather than the departing owner. + // + // Why the abort at all, given wake reloads RAM: not because state survives + // (it does not -- only RTC_DATA_ATTR does), but because deep sleep is a + // MID-SESSION exit whose path otherwise hand-rolls a private teardown subset + // that has to be kept in sync with the real one forever. Forced sleep bypasses + // the live-link guard above and this path never arbitrates a LAN owner, so it + // can begin with a transfer in flight. dropLink=false because ble.end() takes + // the stack down immediately: there is no link left to drop politely, and no + // loop pass will service the resulting event. + const LinkId displaced = linkMarkTerminal(); + abortToKnownState("deep sleep", false, displaced); + // Panel power-down MUST sit below every early-return above (including the // min-wake hold): on mains (power_mode != 1) enterDeepSleep bails before here, // so a WARM panel stays warm and the keep-alive tick in idleDelay(2000) expires // it after the configured window. On battery this is the routine @@ -809,7 +1143,23 @@ void enterDeepSleep(bool force, uint16_t overrideSleepSeconds) { // window) and also closes the pre-existing "deep sleep never powers the panel // down" hazard. Net effect on battery ESP32: effective keep-alive = // min(configured window, idle-hold). + // + // Stays HERE, in the sleep path, and must never move into the abort: this kills + // every power state including PWR_WARM, whereas the abort deliberately lets a + // WARM keep-alive panel survive. Sleep is the one transition where no panel may + // stay powered. epdSessionForceOff(); + // Sleep quiescing, not session teardown: the abort deliberately leaves buzzer + // and LED running (they are user-facing effects, and a client that fires a buzz + // then drops the link is a normal pattern). But deep sleep cuts the clocks they + // run on -- buzzerService() never ticks again from here -- so a tone left on is + // not a melody finishing, it is a driven pin held through teardown and into + // sleep, sounding continuously and drawing current until the next wake. + // Silencing here rather than waiting means sleep is never delayed by an effect. + // + // Deep sleep ONLY: power-latch off deliberately plays a chirp on the way down. + buzzerStopForSleep(); + ledStopForSleep(); woke_from_deep_sleep = true; // Will be true on next boot ble.stopAdvertising(); delay(200); diff --git a/src/session_guard.cpp b/src/session_guard.cpp new file mode 100644 index 0000000..5f8e8e6 --- /dev/null +++ b/src/session_guard.cpp @@ -0,0 +1,162 @@ +// The shared session-teardown routine and the R3a link-down wait. +// See session_guard.h for the caller set and CONNECTION_POLICY R3a/R6 for the rules. + +#include + +#include "session_guard.h" +#include "ble_transport.h" +#include "communication.h" +#include "command_queue.h" +#include "config_parser.h" +#include "display_service.h" +#include "encryption.h" +#include "link_owner.h" +#include "od_log.h" +#include "structs.h" +#include "touch_input.h" +#ifdef OPENDISPLAY_HAS_WIFI +#include "wifi_service.h" +#endif + +extern bool directWriteActive; + +// How long to wait for a requested drop to actually take the link down. +// +// Sized against CLIENT BEHAVIOUR, not against a supervision timeout: an alive peer +// terminates within a few connection intervals -- tens of ms, since this firmware +// requests no interval and the central's negotiated value applies. A peer already +// gone is reaped by the link layer at ~4-6 s, far outside this bound, and that is +// deliberate: expiry here is not a failure needing recovery, just an early exit +// into an abort that runs regardless (R3a). This is the least load-bearing +// threshold in the freeze-hardening plan. +#ifndef OD_BLE_LINK_DOWN_WAIT_MS +#define OD_BLE_LINK_DOWN_WAIT_MS 150 +#endif + +// Tick granularity for the wait below. Deliberately a plain delay() rather than +// idleDelay(): see bleDropAndWait(). +#ifndef OD_BLE_LINK_DOWN_POLL_MS +#define OD_BLE_LINK_DOWN_POLL_MS 2 +#endif + +bool bleDropAndWait(uint16_t handle, uint16_t epoch) { + if (!ble.instanceLive(handle, epoch)) return true; // already down + ble.disconnect(handle, epoch); + const uint32_t deadline = millis() + OD_BLE_LINK_DOWN_WAIT_MS; + while ((int32_t)(millis() - deadline) < 0) { + // Per-handle, so a refused contender still attached cannot mask the owner's + // departure (the aggregate count would read 1 and never reach 0). + if (!ble.instanceLive(handle, epoch)) return true; + // A plain delay, NOT idleDelay(). idleDelay() early-outs on + // ble.eventPending(), and mid-teardown there is always an event pending -- + // the owner's own disconnect, deliberately left unconsumed so + // serviceBleEvents() still sees it -- so every call would return instantly + // and this loop would degrade into a busy spin for its full bound. A plain + // tick also services neither RX nor transport events, which is precisely the + // safety property wanted here. + delay(OD_BLE_LINK_DOWN_POLL_MS); + } + const bool down = !ble.instanceLive(handle, epoch); + if (!down) { + od_log_warn("Link-down wait expired for h=%u (%u ms); proceeding, stale link is inert", + (unsigned)handle, (unsigned)OD_BLE_LINK_DOWN_WAIT_MS); + } + return down; +} + + +void abortToKnownState(const char* reason, bool dropLink, LinkId ownerId) { + // 1. Log first, one line, so a teardown is always attributable even if a later + // step wedges. + od_log_info("[abort] %s (dropLink=%d owner=%u/h%u/e%u)", reason ? reason : "?", + dropLink ? 1 : 0, (unsigned)ownerId.who, (unsigned)ownerId.handle, + (unsigned)ownerId.epoch); + + // 2. Optional client NACK -- deliberately NOT implemented, and the plan's step + // list should be read with this note. It is specified as "skip when + // dropLink", and every caller either drops the link or is called because the + // link is ALREADY gone (disconnect cleanup) or about to be (deep sleep). So + // no caller is in a position to deliver one, and adding a NACK nobody can + // receive would only add a failure mode. Revisit only if a future caller + // aborts a session while intending to keep the link up. + // + // Note the asymmetry with Phase 4's auth-abuse drop, which must DELIVER its + // final FE -- it runs its own bounded TX barrier BEFORE calling this, rather + // than asking the abort to hold the link open. Two contradictory jobs in one + // routine is what that split avoids. + + // 3-5. Transfer state. cleanupDirectWriteState forces power off only for a + // mid-transfer (PWR_ACTIVE) session and no-ops on WARM, which preserves the + // ACTIVE-only-teardown invariant: a WARM keep-alive panel SURVIVES an abort, + // including an idle or watchdog drop while the panel is warm from a prior + // push. epdSessionForceOff() is deliberately NOT called here -- it powers off + // every state except PWR_OFF, WARM included, so it would kill exactly the + // panel that must survive. Deep sleep calls it separately, from the sleep + // path, because no panel may stay powered through sleep. + if (directWriteActive) cleanupDirectWriteState(true); + cleanupPartialWriteOnDisconnect(); + resetPipeWriteState(); + + // 6. Config chunked upload -- previously had no reset function at all, only + // open-coded inline assignments, so no teardown or watchdog touched it. + resetChunkedWriteState(); + + // 7. Touch: assert the suspend counter reaches 0 even when teardown bypassed + // cleanupDirectWriteState (which is the only place that used to clear + // directWriteTouchSuspended), so a partial-path teardown cannot leave touch + // suspended forever. + touchForceResume(); + + // 7b. The auth-abuse run. Every session end clears it, so a new client can + // never inherit its predecessor's rejections -- a defect the off-branch + // prototype shipped with, because it only reset on a successful command. + resetAuthAbuseCounter(); + + // 8. Crypto. NEW on the disconnect path: today crypto state survives a BLE link + // drop entirely -- clearEncryptionSession() runs on session-timeout-at-command, + // a new auth, config reload and LAN teardown, but no BLE disconnect path. + clearEncryptionSession(); + + // 9. Both rings. R6 requires RX and TX drained of the departed session's traffic; + // an earlier draft flushed TX only, which left the teardown window open. + // Draining RX is sound because callback filtering means every frame in it + // passed the owner check when written. A frame the owner writes AFTER this, + // during step 10's wait, is deliberately not re-flushed: it carries the + // departing instance's tag and fails the dispatch check once step 11 releases. + const uint8_t droppedRx = bleRxQueueReset(); + if (droppedRx > 0) { + od_log_warn("[abort] dropped %u queued command(s) from the departed session", + (unsigned)droppedRx); + } + bleTxQueueReset(); + + // 10. The drop, dispatched on the OWNER'S TRANSPORT. This routine is not + // BLE-only: the transfer watchdog that calls it is origin-agnostic, so + // dropping a BLE handle for a timed-out LAN owner would leave the owning + // socket alive while its token was released -- an R1 violation. + if (dropLink) { + if (ownerId.who == OWNER_BLE) { + (void)bleDropAndWait(ownerId.handle, ownerId.epoch); + } else if (ownerId.who == OWNER_LAN) { +#ifdef OPENDISPLAY_HAS_WIFI + // A TCP close is synchronous, so no wait bound applies on LAN -- the + // asynchrony R3a exists for is BLE-only. + wifiLanDropOwnedSocket(); +#endif + } + } + + // 11. Release, STRICTLY after step 10. Releasing at request time (an earlier + // draft) would let a new connection be admitted while the old link was still + // physically up. If the wait expired the release still happens -- the stale + // link is inert by construction. + // + // For the terminal caller this is naturally inert: the word was exchanged to + // OWNER_TERMINAL before the abort, so this full-identity CAS does not match + // and the admission gate stays shut. + linkRelease(ownerId); +} + +void abortToKnownState(const char* reason, bool dropLink) { + abortToKnownState(reason, dropLink, linkOwnerId()); +} diff --git a/src/session_guard.h b/src/session_guard.h new file mode 100644 index 0000000..37d8c6a --- /dev/null +++ b/src/session_guard.h @@ -0,0 +1,54 @@ +#ifndef SESSION_GUARD_H +#define SESSION_GUARD_H + +#include + +#include "link_owner.h" + +// One session teardown routine, shared by every path that ends a session -- +// CONNECTION_POLICY R6. The value of a single routine depends entirely on every +// teardown actually reaching it: keeping two paths is how the direct-write +// watchdog once tore down a panel while leaving its pipe session live. +// +// Callers (the complete set as of Phase 2): +// +// condition dropLink phase +// ------------------------------------------------ ---------- ----- +// BLE disconnect serviced, owner's link gone false 2 +// deep sleep, after linkMarkTerminal() false 2 +// checkTransferTimeouts() fires true 2 +// serviceIdleTimeout() (BLE owner silent) true 3 +// auth-abuse threshold, after the TX barrier true 4 +// +// NOT a caller: refusing a contender. Refusal disconnects the newcomer and does +// nothing else -- no abort, no cleanup flag, no release. The incumbent's session +// must be untouched by construction rather than by a guard that could be got +// wrong, and refusal/teardown sitting in the same handler is what makes this the +// case most likely to be confused in implementation. +// +// Loop task only, idempotent, and deferred by its callers while a refresh is in +// flight. +void abortToKnownState(const char* reason, bool dropLink, LinkId ownerId); + +// `ownerId` is a PARAMETER, not a re-derivation, and this overload is why: +// ordinary callers want "whoever owns the slot right now", so they get a snapshot +// taken before the teardown starts. The terminal caller must NOT use this -- after +// linkMarkTerminal() the owner word reads terminal, so a re-derivation would act +// for the wrong identity; it passes the displaced identity instead. +void abortToKnownState(const char* reason, bool dropLink); + +// Request termination of `handle`, then wait -- cooperatively and with a bound -- +// until that link is actually down. Returns true if it went down within the bound. +// +// The predicate is the OWNER'S INSTANCE-TABLE ENTRY, not the aggregate connection +// count: R1 permits a refused contender to be transiently attached, so dropping +// the owner takes the count 2->1 and never to 0, and a count-based wait would sit +// out its full bound on a link that is already gone. +// +// Expiry is an early exit, not a failure. The abort runs regardless and the stale +// link is inert by construction: its writes fail the owner filter, its queued +// frames fail the dispatch tag check, and its late disconnect is inert on stale +// epoch. That guarantee is what let R3a drop the cross-pass DROPPING state. +bool bleDropAndWait(uint16_t handle, uint16_t epoch); + +#endif // SESSION_GUARD_H diff --git a/src/touch_input.cpp b/src/touch_input.cpp index 5996b4b..530b1b7 100644 --- a/src/touch_input.cpp +++ b/src/touch_input.cpp @@ -413,6 +413,16 @@ static bool touch_light_resume_gt911(uint8_t idx, TouchController* tc, TouchRunt return true; } +void touchForceResume(void) { + if (s_epd_refresh_suspend == 0) { + return; // already resumed; idempotent by contract + } + // Collapse the counter to 1 and let the normal path do the actual resume work, + // so the re-init sequence lives in exactly one place. + s_epd_refresh_suspend = 1; + touchResumeAfterEpdRefresh(); +} + void touchResumeAfterEpdRefresh(void) { if (s_epd_refresh_suspend == 0) { return; diff --git a/src/touch_input.h b/src/touch_input.h index 89388d8..2dd1489 100644 --- a/src/touch_input.h +++ b/src/touch_input.h @@ -10,5 +10,15 @@ bool touch_input_gpio_is_touch_int(uint8_t pin); void touchSuspendForEpdRefresh(void); /** Resume touch after EPD refresh; re-inits I2C for active controllers. */ void touchResumeAfterEpdRefresh(void); +/** + * Drive the suspend counter to 0 unconditionally, resuming touch. Idempotent. + * + * For session teardown (abortToKnownState), where the balanced suspend/resume + * pairing cannot be relied on: a teardown routed through the partial path bypasses + * cleanupDirectWriteState() -- the only place that clears directWriteTouchSuspended + * -- and leaves the counter stuck above zero, so touch never comes back for the + * rest of the boot. Not for the refresh brackets, which stay balanced. + */ +void touchForceResume(void); #endif diff --git a/src/wifi_service.cpp b/src/wifi_service.cpp index ca44ab0..eb3062d 100644 --- a/src/wifi_service.cpp +++ b/src/wifi_service.cpp @@ -7,6 +7,7 @@ #include "structs.h" #include "od_log.h" #include "ble_transport.h" +#include "link_owner.h" #include #include #include @@ -95,7 +96,19 @@ static mbedtls_ssl_config tlsConf; static mbedtls_ctr_drbg_context tlsDrbg; static mbedtls_entropy_context tlsEntropy; -static uint32_t lastLanActivityMs = 0; // for the OD_LAN_READ_TIMEOUT_S idle drop +// lastLanActivityMs is GONE. LAN used to keep its own activity clock, stamped at +// connect, handshake completion, raw bytes read and frame dispatch, and checked +// inline in handleWiFiServer(). R4 requires the SAME definition of activity on +// every transport -- a recognised command from the owner, excluding refresh -- and +// two clocks implementing one rule is how they drift. The shared clock in +// link_owner.cpp is now the only one; serviceIdleTimeout() in main.cpp enforces it +// for both transports, with each keeping its own constant (BLE 120 s local, +// LAN OD_LAN_READ_TIMEOUT_S 30 s from the wire header). +// Epoch of the current LAN session's ownership claim, 0 when this session does not +// own the slot. Kept so the release matches the FULL identity: releasing on +// transport alone would let a stale LAN teardown free a slot a newer session (or a +// BLE client) had since taken. +static uint16_t s_lanEpoch = 0; static uint16_t lanBasePort(void) { return (wifiServerPort != 0) ? wifiServerPort : (uint16_t)OD_LAN_TCP_PORT; @@ -791,6 +804,28 @@ void initWiFi(bool waitForConnection) { } } +void wifiLanDropOwnedSocket(void) { + // The LAN arm of abortToKnownState()'s step 10. It exists as its own seam + // because tlsCloseSession() is file-static, so session_guard.cpp cannot reach + // the pieces directly. + // + // Deliberately the LAN-LOCAL subset of disconnectWiFiServer() below: it closes + // the socket and clears this file's session bookkeeping, but does NOT call + // clearEncryptionSession() or requestTransferSessionCleanup(), which are the + // abort's own steps 8 and 3-5. Calling them here would nest the two teardowns. + tlsCloseSession(); + if (wifiClient.connected()) { + lanLog("Closing LAN client (session abort)"); + wifiClient.stop(); + } + wifiServerConnected = false; + tcpReceiveBufferPos = 0; + // Deliberately NO linkRelease() here: this is the abort's drop step, and the + // abort releases the token itself as its final step (strictly after the drop). + // Releasing here would free the slot before the drop had completed. + s_lanEpoch = 0; +} + void disconnectWiFiServer() { tlsCloseSession(); if (wifiClient.connected()) { @@ -800,6 +835,18 @@ void disconnectWiFiServer() { } wifiServerConnected = false; tcpReceiveBufferPos = 0; + // The token is deliberately NOT released here, for the same reason the BLE + // disconnect callbacks do not release: the socket is closed but this session's + // transfer state is still live, and its teardown is DEFERRED to the loop below + // (requestTransferSessionCleanup). Releasing now would let a BLE connect claim + // the slot before that teardown runs -- and serviceBleDisconnectCleanup would + // then see the new BLE owner live, skip the abort entirely, and leave the new + // owner's frames executing against the departed LAN session's transfer state. + // + // Release stays the abort's final step (R3a), reached via the cleanup flag + // raised below. s_lanEpoch is left intact so the identity remains valid until + // then; a new accept cannot use it, because accept refuses while the slot is + // held. // F4: abort any in-flight direct-write / pipe / partial transfer + tear down a // mid-transfer panel session, DEFERRED to loop() so cleanup never races an // in-progress EPD refresh. Shared with the BLE disconnect path -- main.cpp @@ -845,6 +892,91 @@ static int lanReadIntoBuffer(void) { return (bytesRead > 0) ? bytesRead : 0; } +void wifiLanReapClosedSession(void) { + // Notice a peer-closed socket EARLY in the pass, so the deferred cleanup that + // this raises releases the token before handleWiFiServer()'s accept runs later + // in the same pass. See the call site in loop() for why "early" is the whole + // point: raising it from inside handleWiFiServer left the accept testing a + // corpse's token and refusing an ordinary reconnect. + if (wifiServerConnected && !wifiClient.connected()) { + lanLog("LAN: peer closed the socket, reaping the session"); + disconnectWiFiServer(); + } +} + +// Decide a freshly accepted socket's fate: admit it as the owner, or refuse it. +// +// Split out of handleWiFiServer() so the caller can CONTINUE servicing an existing +// session in the same pass whatever the outcome. When refusal returned early from +// handleWiFiServer, a host reconnecting every pass starved the incumbent -- its TLS +// handshake never advanced and its buffered frames were never dispatched, so the +// shared activity clock stopped being stamped and the end-of-pass idle timeout +// eventually dropped it with valid commands still unread. Refusal must be inert +// (R3), and inbound traffic must be parsed before the idle check (R7d step 3 before +// step 4); an early return broke both. +static void admitOrRefuseLanClient(WiFiClient& incoming) { + // REFUSE while the slot is held -- never evict. Two reasons, both concrete: + // + // - The eviction path this replaces closed the previous socket without + // releasing its ownership epoch, and the replacement then lost its own + // claim, so the departed session's identity became unreachable and the slot + // stayed held until reboot. + // - Eviction is unauthenticated by design: LAN-TLS bypasses app-layer auth, so + // any host on the network could kill an in-flight display push by opening a + // socket, with no credentials. + // + // The incumbent is untouched: no teardown, no crypto clear, no cleanup flag. + // That is R3's "refusal is inert", and it also removes a pre-existing bug in the + // eviction it replaces -- that path cleared TLS and crypto but never requested + // transfer cleanup, so an evicted client's in-flight transfer stayed live and + // the new client's frames (same ORIGIN_LAN, so frameOwnsSession could not tell + // them apart) landed in it. + const LinkId held = linkOwnerId(); + if (held.who != OWNER_NONE) { + lanLog("LAN: refusing new client, slot held by " + + String(held.who == OWNER_BLE ? "BLE" : "LAN")); + incoming.stop(); + return; + } + + wifiClient = incoming; + // TCP_NODELAY: every LAN write is a complete, self-delimited frame, so there is + // never a following write for Nagle to coalesce it with -- it can only hold a + // small frame until the peer's delayed ACK fires (40-200 ms). With per-chunk + // direct-write ACKs that lands on every frame of a transfer. + wifiClient.setNoDelay(true); + wifiClient.setTimeout(30000); + tcpReceiveBufferPos = 0; + wifiServerConnected = true; + + // Claim at TCP ACCEPT, before the TLS handshake (7a row 2): the handshake is + // driven incrementally across later passes, so deferring the claim until it + // completes would leave the slot free for a BLE connect or a second socket in + // the meantime. The accept is this transport's earliest hook, so it is the same + // "claim at the earliest hook" rule the BLE connect callback follows -- and the + // same CAS, which is what makes cross-transport arbitration the word itself + // rather than loop ordering. + // + // The slot was free a moment ago, so this normally wins. It can still lose to a + // BLE connect landing on the host task in between: the callback is the + // authoritative arbitration point, not this loop-side test (R7d). + s_lanEpoch = linkNextEpoch(); + if (!linkClaim((LinkId){OWNER_LAN, 0, s_lanEpoch})) { + lanLog("LAN: refusing new client, slot claimed concurrently"); + s_lanEpoch = 0; + incoming.stop(); + wifiClient = WiFiClient(); + wifiServerConnected = false; + return; + } + + lanLog("LAN client connected from " + wifiClient.remoteIP().toString()); + if (tlsMode && !tlsBeginSession()) { + lanLog("LAN: TLS session start failed, dropping"); + disconnectWiFiServer(); + } +} + void handleWiFiServer() { // Execute a queued roam (RSSI dropped below OD_LAN_ROAM_RSSI_THRESHOLD) before any // other work; it self-gates on idle, so this is a no-op mid-transfer. @@ -866,32 +998,20 @@ void handleWiFiServer() { return; } + // Accept-side refusal must NOT return from this function. Everything below -- + // driving the incumbent's TLS handshake, reading its socket, dispatching its + // frames -- is what stamps the shared activity clock, and the idle timeout is + // evaluated at the end of this same pass. An early return on refusal therefore + // lets a contender starve the incumbent: a host that reconnects every pass + // stops the incumbent being serviced at all, and after OD_LAN_READ_TIMEOUT_S + // the idle drop kills it -- with valid commands still sitting unread in its + // socket. That is both an R3 violation (refusal must be inert) and an R7d one + // (step 3 must precede step 4). So the refusal branch closes the contender and + // falls through. WiFiClient incoming = wifiServer.accept(); if (incoming) { - if (wifiClient.connected()) { - lanLog("LAN: new client, replacing previous"); - tlsCloseSession(); - clearEncryptionSession(); - wifiClient.stop(); - } - wifiClient = incoming; - // TCP_NODELAY: every LAN write is a complete, self-delimited frame, so there - // is never a following write for Nagle to coalesce it with -- it can only - // hold a small frame until the peer's delayed ACK fires (40-200 ms). With - // per-chunk direct-write ACKs that lands on every frame of a transfer. - wifiClient.setNoDelay(true); - wifiClient.setTimeout(30000); - tcpReceiveBufferPos = 0; - wifiServerConnected = true; - lastLanActivityMs = millis(); - lanLog("LAN client connected from " + wifiClient.remoteIP().toString()); - if (tlsMode) { - if (!tlsBeginSession()) { - lanLog("LAN: TLS session start failed, dropping"); - disconnectWiFiServer(); - return; - } - } + admitOrRefuseLanClient(incoming); + // Deliberately no return here on either outcome -- see the note above. } if (!wifiServerConnected || !wifiClient.connected()) { @@ -907,7 +1027,12 @@ void handleWiFiServer() { int hs = mbedtls_ssl_handshake(&tlsSsl); if (hs == 0) { tlsHandshakeDone = true; - lastLanActivityMs = millis(); + // The idle baseline starts HERE, not at TCP accept: handshake traffic + // is not a command (R4/7a). A handshake that never completes therefore + // leaves the clock at its accept-time stamp and the ordinary 30 s drop + // reclaims the socket -- which is why no separate handshake deadline is + // needed. + linkStampOwnerCommand(); lanLog("LAN: TLS handshake complete"); } else if (hs == MBEDTLS_ERR_SSL_WANT_READ || hs == MBEDTLS_ERR_SSL_WANT_WRITE) { // still handshaking; but honor the idle timeout below @@ -943,16 +1068,20 @@ void handleWiFiServer() { return; } if (got > 0) { - lastLanActivityMs = millis(); + // NOT an activity stamp. R4 defines activity as a RECOGNISED COMMAND + // from the owner, and this site fires on any bytes read -- before + // framing, opcode recognition, ownership or authentication -- so a + // plain-mode flooder could hold the slot indefinitely with garbage and + // defeat both the 30 s read timeout and anything built on this clock. + // That is the same defect the BLE clock had in its intake-stamping + // draft, and it is fixed the same way: the stamp moved to the dispatch + // site below, which is reached only by a framed, recognised command. drainedBytes += (uint32_t)got; } else if (drainedBytes == 0) { - // No traffic at all this tick: drop only after OD_LAN_READ_TIMEOUT_S - // of silence (persistent client is otherwise kept). Any valid frame - // below resets the timer. - if ((millis() - lastLanActivityMs) > (uint32_t)OD_LAN_READ_TIMEOUT_S * 1000UL) { - lanLog("LAN: idle timeout, dropping client"); - disconnectWiFiServer(); - } + // Nothing to read this tick. The idle DROP is not decided here any + // more: serviceIdleTimeout() owns it for both transports, and it must + // run after inbound traffic has been parsed (7d step 4) or a LAN client + // is dropped with its command already sitting in the buffer. return; } @@ -969,8 +1098,21 @@ void handleWiFiServer() { // F4: tag the frame's origin so the dispatcher bypasses app-layer CCM on // TLS (already-secure) and routes the response back over LAN only. g_commandOrigin = tlsMode ? ORIGIN_LAN_TLS : ORIGIN_LAN_PLAIN; - lastLanActivityMs = millis(); + // Instance identity for the R4 activity test. LAN frames never traverse + // the BLE ring, so there is no queued tag to carry: the socket is parsed + // and dispatched within this pass, and its buffer dies with the session. + // Publishing the live owner word directly is therefore exact -- if LAN + // does not own the slot, the word will not match and the frame stamps + // nothing. + { + const LinkId lanOwner = linkOwnerId(); + g_commandInstance = (lanOwner.who == OWNER_LAN) ? linkIdWord(lanOwner) : 0; + } + // No stamp here: imageDataWritten() stamps the shared clock itself, + // and only for a RECOGNISED command from the owner -- which is the + // whole of R4's definition and what this site could not enforce. imageDataWritten(NULL, NULL, tcpReceiveBuffer + 2, flen); + g_commandInstance = 0; g_commandOrigin = ORIGIN_BLE; // restore default for any subsequent BLE drain uint32_t consumed = 2u + (uint32_t)flen; uint32_t rem = tcpReceiveBufferPos - consumed; diff --git a/src/wifi_service.h b/src/wifi_service.h index 95d7d75..c6ad953 100644 --- a/src/wifi_service.h +++ b/src/wifi_service.h @@ -7,9 +7,12 @@ // server, TLS-PSK listener, RX reassembly buffer, LAN response framing). It is // defined only on ESP32 targets built with -DOPENDISPLAY_ENABLE_WIFI, which is // applied to every S3, C6, and C3 platformio env (esp32-s3-E1004 sets no flag of -// its own but inherits it from esp32-s3-N32R8-extuart). The classic esp32-N4 is -// the sole ESP32 env without it, so it does not compile the WiFi surface and -// reclaims the 16 KB RX buffer + WiFiServer/WiFiClient RAM. Call sites in +// its own but inherits it from esp32-s3-N32R8-extuart). TWO classic-ESP32 envs lack +// it -- esp32-N4 and esp32-wrover-e-N4R8 -- so neither compiles the WiFi surface, +// and both reclaim the 16 KB RX buffer + WiFiServer/WiFiClient RAM. Note +// esp32-wrover-e-N4R8 is NOT in platformio.ini's default_envs, so a bare `pio run` +// skips it: it ships via .github/firmware-targets.json, and it is the target most +// likely to catch a broken #ifndef OPENDISPLAY_HAS_WIFI path. Build it explicitly. Call sites in // main.cpp / communication.cpp / display_service.cpp / device_control.cpp / // config_parser.cpp are #ifdef-guarded on this macro. #if defined(TARGET_ESP32) && defined(OPENDISPLAY_ENABLE_WIFI) @@ -28,6 +31,24 @@ void od_tls_reserve_records(void); void initWiFi(bool waitForConnection = true); void disconnectWiFiServer(); +/** + * Close the owned LAN socket and its TLS context, without the crypto/transfer + * teardown that disconnectWiFiServer() also does. + * + * The LAN arm of abortToKnownState()'s drop step: the abort owns those other steps, + * so this must not repeat them. Synchronous -- a TCP close needs no wait bound, + * unlike a BLE disconnect. + */ +void wifiLanDropOwnedSocket(void); +/** + * Tear down a LAN session whose peer has already closed the socket. + * + * Called early in loop(), ahead of the deferred disconnect cleanup, so the token is + * released before the accept later in the same pass -- otherwise an ordinary + * reconnect is refused against the departed session's token (7d step 1 before + * step 2). No-op when there is no session or the peer is still connected. + */ +void wifiLanReapClosedSession(void); void handleWiFiServer(); // Re-associate to the strongest AP for the configured SSID after the link degrades diff --git a/tools/hostshim/Arduino.h b/tools/hostshim/Arduino.h new file mode 100644 index 0000000..bd0efe8 --- /dev/null +++ b/tools/hostshim/Arduino.h @@ -0,0 +1,33 @@ +// Minimal Arduino shim so src/link_owner.cpp -- and the real src/od_log.h it +// includes -- can be compiled and tested on the host. +// +// Deliberately the REAL od_log.h rather than a stub of it: that header is what +// link_owner.cpp actually includes, so shimming Arduino instead of shimming our own +// header keeps the test compiling the same code the firmware does. Only the two +// Arduino types od_log.h names are declared here, plus millis(). +// +// Test-only: never on an include path for a firmware build, which always gets the +// real Arduino core from the PlatformIO framework. +#ifndef OD_HOSTSHIM_ARDUINO_H +#define OD_HOSTSHIM_ARDUINO_H + +#include +#include + +// The test drives this directly, so it can place the clock wherever a case needs +// -- including just below a wrap boundary, which is not reachable by waiting. +// +// Read atomically because the concurrency cases advance it from one thread while +// the code under test reads it from another; a plain access would be a data race +// in the HARNESS and would mask the race being hunted for in the code. +extern volatile uint32_t od_test_millis; +static inline uint32_t millis(void) { + return __atomic_load_n(&od_test_millis, __ATOMIC_RELAXED); +} + +// od_log.h names these in declarations the test never calls; they only have to +// exist for the header to parse. +class Stream; +typedef void* TaskHandle_t; + +#endif // OD_HOSTSHIM_ARDUINO_H diff --git a/tools/test_link_owner.cpp b/tools/test_link_owner.cpp new file mode 100644 index 0000000..9d31afb --- /dev/null +++ b/tools/test_link_owner.cpp @@ -0,0 +1,389 @@ +// Host test for src/link_owner.{h,cpp} — the connection ownership arbiter. +// +// Build and run from the repo root (one line; no continuations, since a trailing +// backslash inside a // comment is itself a line continuation and -Wcomment +// rejects it): +// +// g++ -std=c++17 -Wall -Wextra -Werror -O1 -fsanitize=undefined,address -I tools/hostshim tools/test_link_owner.cpp src/link_owner.cpp -o /tmp/test_link_owner -pthread +// /tmp/test_link_owner +// +// Like tools/test_nonce_window.cpp, this is as much a written-down statement of +// the intended semantics as it is a test. It covers the parts of CONNECTION_POLICY +// R1/R2/R4 that a firmware build cannot check and that a bench run would only +// exercise by luck: +// +// - a claim is a CAS: exactly one winner under contention, no torn identity +// - the epoch is what makes a reused handle a DIFFERENT instance (7a row 4) +// - a stale-epoch release is inert (7b row 4), so a late abort cannot free a +// slot the next client already owns +// - the terminal gate is one-way, returns the identity it displaced, and cannot +// be reopened by the abort's release that follows it (7e row 3) +// - the activity clock is 0 when unowned, starts its window at admission, and +// is wrap-safe + +#include "../src/link_owner.h" + +#include +#include +#include +#include +#include +#include + +volatile uint32_t od_test_millis = 0; + +// --- tiny harness ------------------------------------------------------------ +static int g_checks = 0; +static int g_failures = 0; + +static void check(bool cond, const char* what) { + g_checks++; + if (!cond) { + g_failures++; + std::printf("FAIL: %s\n", what); + } +} + +// Make UBSan reports fatal rather than "print and keep going", so a regression +// that reintroduced signed/overflow arithmetic could not exit 0. Mirrors the +// same hook in test_nonce_window.cpp. +extern "C" void __ubsan_on_report(void) { + std::printf("FAIL: UBSan report\n"); + std::exit(1); +} + +// The arbiter deliberately exposes no reset -- one boot, one lifetime. Tests drive +// it back to unowned through the public API instead, which also proves release +// actually works. +static void forceUnowned() { + const LinkId owner = linkOwnerId(); + if (owner.who == OWNER_BLE || owner.who == OWNER_LAN) linkRelease(owner); + check(linkOwnerId().who == OWNER_NONE, "forceUnowned leaves the slot unowned"); +} + +// --- word encoding ----------------------------------------------------------- +static void test_word_encoding() { + // The all-zero word must mean unowned and nothing else; every other field + // combination has to round-trip, or an identity comparison silently aliases + // two different instances. + check(linkPackWord(OWNER_NONE, 0, 0) == 0, "unowned is the all-zero word"); + check(linkPackWord(OWNER_TERMINAL, 0, 0) == OD_LINK_WORD_TERMINAL, + "terminal is the reserved word"); + + const uint16_t handles[] = {0, 1, 2, 63, 255, 0x0EFF, 0x3FFF}; + const uint16_t epochs[] = {1, 2, 255, 256, 0x7FFF, 0xFFFF}; + for (uint16_t h : handles) { + for (uint16_t e : epochs) { + const uint32_t w = linkPackWord(OWNER_BLE, h, e); + const LinkId id = linkUnpackWord(w); + check(id.who == OWNER_BLE && id.handle == h && id.epoch == e, + "pack/unpack round-trips"); + check(w != 0, "a real instance never encodes as the unowned word"); + check(w != OD_LINK_WORD_TERMINAL, "a BLE instance never aliases terminal"); + } + } + // 0x0EFF is the spec cap on HCI connection handles; 14 bits must hold it. + check(linkUnpackWord(linkPackWord(OWNER_BLE, 0x0EFF, 1)).handle == 0x0EFF, + "the handle field holds the full HCI range"); +} + +// --- claim / release --------------------------------------------------------- +static void test_claim_release() { + forceUnowned(); + const LinkId a = {OWNER_BLE, 4, 100}; + check(linkClaim(a), "first claim on an unowned slot succeeds"); + check(linkIsOwner(a), "the claimant is the owner"); + + // R3: while the slot is held, every other instance is refused. Admission never + // evicts -- that is the whole governing decision, and it is enforced here by + // the CAS only ever succeeding against zero. + const LinkId b = {OWNER_BLE, 5, 101}; + check(!linkClaim(b), "a second BLE instance is refused while the slot is held"); + check(!linkIsOwner(b), "the refused contender is not the owner"); + check(linkIsOwner(a), "the incumbent is untouched by a refused claim"); + + const LinkId lan = {OWNER_LAN, 0, 102}; + check(!linkClaim(lan), "a LAN claim is refused while BLE owns (R1 is global)"); + check(linkIsOwner(a), "the incumbent survives a cross-transport refusal"); + + linkRelease(a); + check(linkOwnerId().who == OWNER_NONE, "release frees the slot"); + check(linkClaim(lan), "LAN may claim once the slot is free"); + check(linkIsOwner(lan), "LAN is now the owner"); + linkRelease(lan); +} + +// --- the epoch, which is the entire point of R2 ------------------------------ +static void test_epoch_discrimination() { + forceUnowned(); + const LinkId incumbent = {OWNER_BLE, 7, 200}; + check(linkClaim(incumbent), "incumbent claims"); + + // 7a row 4: a contender that REUSES the incumbent's handle after a stale link. + // Handle alone cannot tell these apart; this is the case a handle-only build + // passes by accident, and the reason the epoch is allocated for every instance + // rather than on successful claim. + const LinkId sameHandleNewEpoch = {OWNER_BLE, 7, 201}; + check(!linkIsOwner(sameHandleNewEpoch), + "a reused handle with a new epoch is NOT the owner"); + check(!linkClaim(sameHandleNewEpoch), "and it is refused"); + + // 7b row 4: the ABA case. A disconnect event serviced tens of seconds late -- + // loop() blocked in a refresh -- carries a stale epoch. It must not free a slot + // whose owner is a newer instance. + const LinkId staleSameHandle = {OWNER_BLE, 7, 199}; + linkRelease(staleSameHandle); + check(linkIsOwner(incumbent), "a stale-epoch release is inert"); + + const LinkId wrongTransport = {OWNER_LAN, 7, 200}; + linkRelease(wrongTransport); + check(linkIsOwner(incumbent), "a cross-transport release is inert"); + + linkRelease(incumbent); + check(linkOwnerId().who == OWNER_NONE, "the exact identity does release"); +} + +// --- epoch allocation -------------------------------------------------------- +static void test_epoch_allocation() { + // Epoch 0 is reserved so the all-zero word can mean unowned. The allocator must + // skip it at wrap, not merely start above it. + uint16_t prev = linkNextEpoch(); + check(prev != 0, "an allocated epoch is never 0"); + // 2^16 allocations walks the counter through its full range, including the wrap + // where a naive ++ would hand out 0. + for (int i = 0; i < 70000; i++) { + const uint16_t e = linkNextEpoch(); + check(e != 0, "no allocation yields the reserved epoch 0"); + prev = e; + } + (void)prev; +} + +// --- the terminal gate (7e row 3) ------------------------------------------- +static void test_terminal_gate() { + forceUnowned(); + const LinkId owner = {OWNER_BLE, 3, 300}; + check(linkClaim(owner), "owner claims before the terminal transition"); + + // linkMarkTerminal() must RETURN the displaced identity: after it, the word + // reads terminal, so the deep-sleep path cannot re-derive who it is aborting + // for. This is the plumbing the abort's ownerId parameter exists to carry. + const LinkId displaced = linkMarkTerminal(); + check(displaced.who == OWNER_BLE && displaced.handle == 3 && displaced.epoch == 300, + "the terminal exchange returns the displaced owner"); + check(linkOwnerId().who == OWNER_TERMINAL, "the word now reads terminal"); + + // The gate is what closes the deep-sleep race: between the abort's release and + // ble.end(), a connect on the host task must not be able to win the slot. + check(!linkClaim((LinkId){OWNER_BLE, 9, 301}), "a BLE claim fails against the gate"); + check(!linkClaim((LinkId){OWNER_LAN, 0, 302}), "a LAN claim fails against the gate"); + + // The abort runs AFTER the gate and ends in linkRelease(displaced). That must + // not reopen admission -- the full-identity CAS cannot match the terminal word. + linkRelease(displaced); + check(linkOwnerId().who == OWNER_TERMINAL, "the abort's release cannot reopen the gate"); + check(!linkClaim((LinkId){OWNER_BLE, 1, 303}), "still shut after the release"); + + // One-way by construction: nothing transitions out, and the terminal word is + // not a legal argument to release. + linkRelease((LinkId){OWNER_TERMINAL, 0, 0}); + check(linkOwnerId().who == OWNER_TERMINAL, "terminal is not releasable"); + + // Marking terminal on an unowned slot is legal (deep sleep with no client) and + // reports no displaced owner. + const LinkId again = linkMarkTerminal(); + check(again.who == OWNER_TERMINAL, "re-marking reports the prior terminal state"); +} + +// --- the activity clock (R4) ------------------------------------------------- +static void test_activity_clock() { + // Fresh process state is needed because the terminal gate above is one-way, so + // this runs in its own process (see main()). + od_test_millis = 1000; + check(linkMsSinceOwnerCommand() == 0, "the clock reads 0 when unowned"); + + const LinkId owner = {OWNER_BLE, 2, 400}; + check(linkClaim(owner), "owner claims"); + check(linkMsSinceOwnerCommand() == 0, "the window starts at admission, not at UINT32_MAX"); + + // The init fix: a freshly admitted but still-silent client must get the FULL + // window before its first command, not be instantly past any timeout. + od_test_millis = 1000 + 119000; + check(linkMsSinceOwnerCommand() == 119000, "silence accrues from admission"); + + linkStampOwnerCommand(); + check(linkMsSinceOwnerCommand() == 0, "a recognised owner command resets the clock"); + + od_test_millis += 50000; + check(linkMsSinceOwnerCommand() == 50000, "silence accrues from the last command"); + + // The refresh exclusion. loop() does not run for a refresh's duration while + // wall-clock time passes, so without the re-stamp at the transition the whole + // refresh accrues and an engaged client is dropped the instant loop() resumes. + od_test_millis += 16000; // a ~16 s refresh elapses + linkStampRefreshEnd(); + check(linkMsSinceOwnerCommand() == 0, "endRefresh() re-stamps the owner's clock"); + + // Re-stamping can only ever DELAY a drop, never cause one: the baseline is the + // LATER of the three stamps, so an old refresh-end cannot pull it backwards. + od_test_millis += 5000; + linkStampOwnerCommand(); // now the latest + od_test_millis += 1000; + linkStampRefreshEnd(); // later still + check(linkMsSinceOwnerCommand() == 0, "the baseline is the latest of the stamps"); + + // Wrap safety: millis() wraps every ~49.7 days, and a plain `>` comparison on + // the raw stamps would read as a ~49-day silence for one pass. + od_test_millis = 0xFFFFFF00u; + linkStampOwnerCommand(); + od_test_millis = 0x000000FFu; // wrapped; 511 ms of real elapsed time + check(linkMsSinceOwnerCommand() == 511, "the clock is wrap-safe across millis() rollover"); + + linkRelease(owner); + check(linkMsSinceOwnerCommand() == 0, "the clock reads 0 again once unowned"); +} + +// --- the baseline must belong to the CURRENT owner --------------------------- +static void test_clock_owner_versioning() { + // The bug this guards: linkClaim() publishes the new owner with its CAS and + // only then stores the baseline. A reader landing in that gap sees the NEW + // owner beside the PREVIOUS owner's baseline and reports an arbitrarily large + // silence -- on which Phase 3 would drop a client that just connected. + // + // Re-reading the owner word around the load does NOT catch it (both reads see + // the same new owner), which is why the baseline carries an owner tag. This + // test reaches the state directly: a session that ran long, released, and was + // replaced must not lend its baseline to the newcomer. + forceUnowned(); + od_test_millis = 5000; + const LinkId first = {OWNER_BLE, 1, 700}; + check(linkClaim(first), "first owner claims"); + od_test_millis += 300000; // five minutes of silence + check(linkMsSinceOwnerCommand() == 300000, "the first owner's silence accrues"); + linkRelease(first); + + const LinkId second = {OWNER_BLE, 1, 701}; // same handle, new instance + check(linkClaim(second), "a new instance claims the freed slot"); + check(linkMsSinceOwnerCommand() == 0, + "the newcomer does NOT inherit the previous owner's stale baseline"); + + // And the tag must not make a legitimately silent owner read as fresh forever. + od_test_millis += 90000; + check(linkMsSinceOwnerCommand() == 90000, "the newcomer's own silence still accrues"); + linkRelease(second); + + // NOTE: everything above passes with or without the owner tag, because a + // single-threaded claim always stores its baseline before anything can read it. + // The tag earns its keep only in the window BETWEEN the claim's CAS and its + // baseline store, which is reachable only concurrently -- see + // test_clock_versioning_race() below, which is what actually discriminates it. +} + +// NOT TESTED HERE, and deliberately so: the window between linkClaim()'s CAS and +// its baseline store. +// +// A stress harness for it was written and removed. It could not distinguish the +// defect from correct behaviour: to make a leaked baseline observable the harness +// must advance the test clock between sessions, but then a reader that samples +// millis() just after validating the owner legitimately sees a full stride of age, +// which is exactly the signature the bug would produce. It failed on the FIXED code +// as often as on the mutant, and a test that fails on correct code is worse than no +// test. +// +// Making it deterministic needs an interleaving hook inside linkClaim(), i.e. +// test scaffolding in firmware on the connection path. That was judged not worth +// it: the fix rests on a publication-order argument (baseline stored, THEN its +// owner tag release-stored, so a reader either sees the tag and a matching baseline +// or no tag at all) plus ThreadSanitizer over the concurrent claim cases below, +// which covers the data race but not this ordering window. Stated plainly so the +// gap is known rather than assumed covered. + +// --- contention: the claim really is atomic ---------------------------------- +static void test_concurrent_claims() { + forceUnowned(); + // BLE claims on the stack callback task, LAN on the loop task, and two centrals + // can connect inside one blocked-loop window. Exactly one must win, and the + // winner's identity must be one that was actually offered -- a torn word would + // name an instance that never existed. + const int kThreads = 8; + std::atomic wins{0}; + std::vector threads; + std::atomic go{false}; + for (int i = 0; i < kThreads; i++) { + threads.emplace_back([i, &wins, &go]() { + while (!go.load(std::memory_order_acquire)) { /* line them up */ } + const LinkId id = {OWNER_BLE, (uint16_t)(i + 1), (uint16_t)(500 + i)}; + if (linkClaim(id)) wins.fetch_add(1, std::memory_order_relaxed); + }); + } + go.store(true, std::memory_order_release); + for (auto& t : threads) t.join(); + + check(wins.load() == 1, "exactly one of eight racing claims wins"); + const LinkId owner = linkOwnerId(); + check(owner.who == OWNER_BLE, "the winner is a BLE instance"); + const bool plausible = owner.handle >= 1 && owner.handle <= kThreads && + owner.epoch == (uint16_t)(500 + owner.handle - 1); + check(plausible, "the owner word is one of the offered identities, not a torn mix"); + linkRelease(owner); +} + +static void test_concurrent_epochs() { + // Epoch allocation races the same two tasks. Every allocation must be unique, + // or two live instances could share an identity. + const int kThreads = 4; + const int kPerThread = 4000; + std::vector> got(kThreads); + std::vector threads; + for (int i = 0; i < kThreads; i++) { + got[i].reserve(kPerThread); + threads.emplace_back([i, &got]() { + for (int n = 0; n < kPerThread; n++) got[i].push_back(linkNextEpoch()); + }); + } + for (auto& t : threads) t.join(); + + // The counter is 16-bit and this allocates 16000, so uniqueness is checked + // within the run rather than globally -- duplicates would mean a lost + // increment, which is what a non-atomic ++ produces under contention. + std::vector seen(65536, 0); + int dupes = 0; + for (int i = 0; i < kThreads; i++) { + for (uint16_t e : got[i]) { + if (e == 0) { dupes++; continue; } + if (seen[e]++) dupes++; + } + } + check(dupes == 0, "16000 concurrent epoch allocations are all distinct"); +} + +int main(int argc, char** argv) { + // The terminal gate is one-way and process-wide, so the cases that run after it + // would see a permanently shut arbiter. Re-exec for that group instead of + // adding a test-only reset the firmware would then carry. + const bool terminalPhase = (argc > 1 && std::strcmp(argv[1], "--terminal") == 0); + + if (!terminalPhase) { + test_word_encoding(); + test_claim_release(); + test_epoch_discrimination(); + test_epoch_allocation(); + test_activity_clock(); + test_clock_owner_versioning(); + + test_concurrent_claims(); + test_concurrent_epochs(); + + // Child process for the terminal-gate group. + char cmd[1024]; + std::snprintf(cmd, sizeof(cmd), "%s --terminal", argv[0]); + const int rc = std::system(cmd); + check(rc == 0, "the terminal-gate phase passes"); + } else { + test_terminal_gate(); + } + + std::printf("%s: %d checks, %d failures\n", + terminalPhase ? "terminal phase" : "link_owner", g_checks, g_failures); + return g_failures == 0 ? 0 : 1; +}