From fc1f9610bb9ee06e5e19dc2073c244dae02b107d Mon Sep 17 00:00:00 2001 From: David Lee <247393336+davelee98@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:14:20 -0400 Subject: [PATCH 01/10] docs: freeze-hardening plan and CONNECTION_POLICY Squashes 16 successive revisions of the freeze-hardening planning docs into their settled state. They were written and re-cut against the tree as the phases were designed, so the intermediate versions record drafting churn -- plan restructurings, threshold decisions later moved to their point of use, and two rounds of external-review discharge -- rather than anything a reader or a bisect would want to land on. CONNECTION_POLICY.md is the normative ruleset the Phase 2-4 commits implement; the PLAN_* documents are the working notes behind it. --- docs/CONNECTION_POLICY.md | 1013 ++++++++++ docs/PLAN_FREEZE_HARDENING_2026-07-31.md | 1621 +++++++++++++++++ docs/PLAN_PHASE0_LINK_DROP_SEAM_2026-07-31.md | 159 ++ 3 files changed, 2793 insertions(+) create mode 100644 docs/CONNECTION_POLICY.md create mode 100644 docs/PLAN_FREEZE_HARDENING_2026-07-31.md create mode 100644 docs/PLAN_PHASE0_LINK_DROP_SEAM_2026-07-31.md diff --git a/docs/CONNECTION_POLICY.md b/docs/CONNECTION_POLICY.md new file mode 100644 index 0000000..eac237a --- /dev/null +++ b/docs/CONNECTION_POLICY.md @@ -0,0 +1,1013 @@ +# 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 and be **recognised as +a command from the current owner** — 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, but the baseline starts at **TLS handshake completion** +(see R7a note), since handshake traffic is not a command. + +**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..7b1bd1a --- /dev/null +++ b/docs/PLAN_FREEZE_HARDENING_2026-07-31.md @@ -0,0 +1,1621 @@ +# 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, activity clock, abort-to-known-state | — | Partially present (`serviceBleDisconnectCleanup`) | +| 3 | Connection-exclusivity **policy** + idle drop | Phase 2 | Not started | +| 4 | Auth-abuse disconnect | Phase 2, Phase 3 | Prototype exists off-branch, not here | + +**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). + +**And Phase 3 depends on *it* for one case**, which is worth stating because the division +is easy to get backwards. Under R4 the activity clock stamps a *recognised* command +before the auth gate — it has to, or a client could not complete a handshake without +racing the clock. So a peer that floods **recognised but never-authenticating** commands +keeps its clock fresh and the idle drop never fires on it. That peer is Phase 4's job, +not Phase 3's. The two together are exhaustive: garbage that is not a recognised command +never stamps the clock and is dropped by the idle timeout; recognised commands that never +authenticate are dropped by the counter. + +### 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. From 57471840a8566f312010eeb09acd952a32c21550 Mon Sep 17 00:00:00 2001 From: David Lee <247393336+davelee98@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:48:09 -0400 Subject: [PATCH 02/10] =?UTF-8?q?feat(ble):=20Phase=202=20BLE-HAL=20founda?= =?UTF-8?q?tion=20=E2=80=94=20owner=20token,=20instance=20table,=20frame?= =?UTF-8?q?=20identity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Phase 2 of docs/PLAN_FREEZE_HARDENING_2026-07-31.md: the transport/HAL mechanisms the later phases stand on. No idle timeout and no reclaim of a held slot — those stay Phase 3. Mechanisms: - src/link_owner.{h,cpp}: the owner token as ONE 32-bit atomic word (transport:2|handle:14|epoch:16), CAS-claimed at the earliest transport hook so stack callbacks can read it; atomic epoch allocator that never yields the reserved 0; linkMarkTerminal() returning the displaced identity; the R4 activity clock, whose baseline carries an owner tag so a newly admitted client can never inherit a prior session's silence. - Per-handle instance table in both transports. Liveness IS the packed identity word, so identity and liveness are read in one atomic load. - Callback-side filtering: per-link write filter, per-link subscribe state, and handle-targeted notify — the last closes a LIVE LEAK where every response, authentication traffic included, went to all subscribed clients. - Frame identity: CommandQueueItem carries its writer's identity word, stamped before the release-store; serviceBleRx drops frames whose tag is no longer the owner. This retires the RX-boundary mechanism (bleRxQueueDiscardTo and the capture that fed it), which could lose its boundary to handle reuse. - session_guard: abortToKnownState(reason, dropLink, ownerId) with the 11 ordered steps, transport-dispatched drop, release strictly last; bleDropAndWait() polling per-handle liveness (not the aggregate count) on a plain delay tick. - endRefresh() closes the refresh bracket on both paths and re-stamps the clock. - Deep sleep gates admission with linkMarkTerminal() BEFORE the abort, then does its own sleep quiescing (panel force-off incl. WARM, buzzer/LED silence). The abort itself never silences effects and never kills a WARM panel. - The 15-minute transfer watchdog routes through the abort. SCOPE ADJUSTMENT — contender refusal is pulled forward from Phase 3, because Phase 2 is not safely shippable without it. Admission is decided once per instance and never revisited, so a client reconnecting into a still-held slot (the ordinary case when loop() was blocked in a refresh) becomes a permanent contender — and on nRF it occupies the only peripheral link, leaving the device unreachable until it happens to leave. Releasing the token in the disconnect callback instead was tried and reverted: it admits a new owner while the departed session's transfer, crypto and TX ring are still live. LAN accept likewise refuses rather than evicts, which also removes a path that stranded the token until reboot and closes an unauthenticated eviction (LAN-TLS bypasses app auth). Loop order is now R7d-normative: disconnect cleanup and refusal run BEFORE the RX drain, so a departed session's state is gone before any frame dispatches. Verification: all 11 PlatformIO envs build; tools/test_link_owner.cpp (70,170 checks) passes under ASan+UBSan and ThreadSanitizer, and kills five mutants (handle-only identity, epoch-0 allocation, unconditional release, missing admission baseline, releasable terminal gate). Not verified: any hardware. Known gaps, stated rather than assumed covered: the notify handle race is narrowed but not closed (NimBLE can reassign a numeric handle at any time and notify() takes no epoch); the window between linkClaim's CAS and its baseline store is not deterministically testable without a firmware-side test hook, so it rests on publication order plus TSan. --- src/ble_transport.h | 76 ++++++- src/ble_transport_esp32.cpp | 304 +++++++++++++++++++++++----- src/ble_transport_nrf.cpp | 136 +++++++++++-- src/buzzer_control.cpp | 10 + src/buzzer_control.h | 9 + src/command_queue.cpp | 55 +++-- src/command_queue.h | 53 +++-- src/communication.cpp | 41 ++++ src/communication.h | 8 + src/device_control.cpp | 7 + src/device_control.h | 5 + src/display_service.cpp | 75 +++++-- src/display_service.h | 7 + src/link_owner.cpp | 144 +++++++++++++ src/link_owner.h | 115 +++++++++++ src/main.cpp | 255 ++++++++++++++++++----- src/session_guard.cpp | 161 +++++++++++++++ src/session_guard.h | 54 +++++ src/touch_input.cpp | 10 + src/touch_input.h | 10 + src/wifi_service.cpp | 108 +++++++++- src/wifi_service.h | 9 + tools/hostshim/Arduino.h | 33 +++ tools/test_link_owner.cpp | 389 ++++++++++++++++++++++++++++++++++++ 24 files changed, 1888 insertions(+), 186 deletions(-) create mode 100644 src/link_owner.cpp create mode 100644 src/link_owner.h create mode 100644 src/session_guard.cpp create mode 100644 src/session_guard.h create mode 100644 tools/hostshim/Arduino.h create mode 100644 tools/test_link_owner.cpp diff --git a/src/ble_transport.h b/src/ble_transport.h index c77af49..413a30d 100644 --- a/src/ble_transport.h +++ b/src/ble_transport.h @@ -33,9 +33,55 @@ 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; + 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. + bool disconnect(uint16_t handle); + + // 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 +116,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..a915ba1 100644 --- a/src/ble_transport_esp32.cpp +++ b/src/ble_transport_esp32.cpp @@ -16,6 +16,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 +30,88 @@ 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; +}; +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); + 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].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].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,29 +147,60 @@ 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. + 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); + od_log_info("=== BLE CLIENT CONNECTED (ESP32) h=%u e=%u %s ===", + (unsigned)handle, (unsigned)epoch, + admitted ? "[owner]" : "[contender - refused service]"); + s_connectedWord = word; + // 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. s_connectedEvent = true; } 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. + s_disconnectReason = (uint16_t)reason; + s_disconnectedWord = word; + // 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 @@ -126,14 +224,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 +263,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 +385,103 @@ 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); +} + +uint8_t BleTransport::instanceCapacity() { return OD_BLE_MAX_INSTANCES; } + +bool BleTransport::disconnect(uint16_t handle) { + if (s_server == nullptr) return false; + // 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 +517,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 @@ -338,17 +551,18 @@ bool BleTransport::eventPending() const { return s_connectedEvent || s_disconnectedEvent; } -bool BleTransport::takeConnectedEvent() { +bool BleTransport::takeConnectedEvent(uint32_t* instanceWord) { if (!s_connectedEvent) return false; s_connectedEvent = false; + if (instanceWord != nullptr) *instanceWord = s_connectedWord; return true; } -bool BleTransport::takeDisconnectedEvent(uint8_t* reason, uint8_t* rxBoundary) { +bool BleTransport::takeDisconnectedEvent(uint16_t* reason, uint32_t* instanceWord) { if (!s_disconnectedEvent) return false; s_disconnectedEvent = false; if (reason != nullptr) *reason = s_disconnectReason; - if (rxBoundary != nullptr) *rxBoundary = s_rxBoundaryAtDisconnect; + if (instanceWord != nullptr) *instanceWord = s_disconnectedWord; return true; } diff --git a/src/ble_transport_nrf.cpp b/src/ble_transport_nrf.cpp index 424a55e..f68f60e 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,23 @@ 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; // --- advertising interval policy -------------------------------------------- static uint32_t s_advBoostUntil = 0; @@ -123,21 +135,47 @@ static void armLinkDiag(uint16_t conn_handle) { // loop() task. Anything added below that is not a push or a flag store // reintroduces the cross-task races this phase exists to remove. static void onConnectCb(uint16_t conn_handle) { - od_log_info("=== BLE CLIENT CONNECTED (nRF) ==="); s_connHandle = conn_handle; + // 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_instanceWord, word, __ATOMIC_RELEASE); + s_instanceSubscribed = false; + const LinkId id = { OWNER_BLE, conn_handle, epoch }; + const bool admitted = linkClaim(id); + od_log_info("=== BLE CLIENT CONNECTED (nRF) h=%u e=%u %s ===", + (unsigned)conn_handle, (unsigned)epoch, + admitted ? "[owner]" : "[contender - refused service]"); + s_connectedWord = word; s_connectedEvent = true; } 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(); + const uint32_t word = __atomic_load_n(&s_instanceWord, __ATOMIC_ACQUIRE); + s_disconnectedWord = word; + // 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_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. s_disconnectedEvent = true; } @@ -146,13 +184,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 +288,61 @@ 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); +} + +uint8_t BleTransport::instanceCapacity() { return 1; } + +bool BleTransport::disconnect(uint16_t handle) { + // 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); @@ -320,17 +417,18 @@ bool BleTransport::eventPending() const { return s_connectedEvent || s_disconnectedEvent; } -bool BleTransport::takeConnectedEvent() { +bool BleTransport::takeConnectedEvent(uint32_t* instanceWord) { if (!s_connectedEvent) return false; s_connectedEvent = false; + if (instanceWord != nullptr) *instanceWord = s_connectedWord; return true; } -bool BleTransport::takeDisconnectedEvent(uint8_t* reason, uint8_t* rxBoundary) { +bool BleTransport::takeDisconnectedEvent(uint16_t* reason, uint32_t* instanceWord) { if (!s_disconnectedEvent) return false; s_disconnectedEvent = false; if (reason != nullptr) *reason = s_disconnectReason; - if (rxBoundary != nullptr) *rxBoundary = s_rxBoundaryAtDisconnect; + if (instanceWord != nullptr) *instanceWord = s_disconnectedWord; 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..6e397e0 100644 --- a/src/communication.cpp +++ b/src/communication.cpp @@ -20,6 +20,8 @@ #include "wifi_service.h" #endif +#include "link_owner.h" + bool isAuthenticated(); extern struct GlobalConfig globalConfig; @@ -35,6 +37,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 @@ -547,6 +561,33 @@ void imageDataWritten(BLEConnHandle conn_hdl, BLECharPtr chr, uint8_t* data, uin } uint16_t command = (data[0] << 8) | data[1]; + + // The R4 activity stamp. Two tests, and both matter: + // + // - RECOGNISED: commandName() != nullptr. Unknown opcodes fall to the switch + // default's "Unknown command" error, so they are not activity. Reusing the + // existing recognition predicate avoids a second, drift-prone one. This is + // what an earlier draft got wrong by stamping at RX intake instead: + // bleRxQueuePush accepts ANY non-empty payload within the size cap, so a + // two-byte malformed frame counted as activity and a garbage flooder could + // hold the slot indefinitely -- precisely the failure the idle drop exists + // to prevent. + // + // - FROM THE OWNER: g_commandInstance (set by the caller from the frame's tag, + // or from the LAN owner's identity) still equals the live owner word. NOT a + // transport comparison: transport alone cannot tell a delayed frame from a + // dead BLE instance apart from the new BLE owner, and such a frame would + // stamp the new owner's clock. + // + // Deliberately BEFORE the auth gate: CMD_AUTHENTICATE must count as activity or + // a client could not complete a handshake without racing the clock. A peer that + // floods recognised-but-never-authenticating commands is Phase 4's job (the + // auth-abuse counter), not this clock's -- the counter can tell "wrong + // credentials" from "silent", which the clock cannot. + if (commandName(command) != nullptr && linkIsOwnerWord(g_commandInstance)) { + linkStampOwnerCommand(); + } + // 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(); diff --git a/src/communication.h b/src/communication.h index fb4c453..282049b 100644 --- a/src/communication.h +++ b/src/communication.h @@ -36,6 +36,14 @@ 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; + // --- 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/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..dcf61bf 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,49 @@ 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. They can differ: LAN sessions are admitted unowned in Phase 2 when + // BLE already holds the slot, so a timed-out LAN transfer with a BLE owner + // would otherwise drop the innocent BLE client's link. Teardown still runs + // either way -- only the drop is withheld. + 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 +2503,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 +3386,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/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..22413f7 --- /dev/null +++ b/src/link_owner.h @@ -0,0 +1,115 @@ +#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. Loop-task-only single-writer, so plain globals -- the same +// argument that lets g_commandOrigin be a bare global. +// +// 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 +void linkStampOwnerCommand(void); // dispatcher, on a recognised owner command +void linkStampRefreshEnd(void); // endRefresh(), both bracket sites + +#endif // LINK_OWNER_H diff --git a/src/main.cpp b/src/main.cpp index 6450ee5..0804fd8 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) @@ -389,37 +391,53 @@ static void serviceBleDisconnectCleanup() { if (!s_disconnectCleanupPending || epdRefreshInProgress) return; s_disconnectCleanupPending = false; // 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 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 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 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,37 +477,47 @@ 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. - // - // 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. + 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. // - // 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. @@ -504,6 +532,46 @@ 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() { + const uint32_t owner = linkOwnerWord(); + const uint8_t cap = BleTransport::instanceCapacity(); + for (uint8_t i = 0; i < cap; i++) { + const uint32_t w = ble.instanceWordAt(i); + if (w == 0 || w == owner) continue; + 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); + } +} + // 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 +583,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 @@ -629,12 +720,30 @@ 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. + serviceBleDisconnectCleanup(); + serviceContenderRefusal(); serviceBleRx(); serviceBleTx(); - serviceBleDisconnectCleanup(); if (s_msdUpdatePending) { s_msdUpdatePending = false; updatemsdata(); @@ -802,6 +911,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 +940,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..e85d5ed --- /dev/null +++ b/src/session_guard.cpp @@ -0,0 +1,161 @@ +// 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 "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; +extern chunked_write_state_t chunkedWriteState; + +// 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); + 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 resetChunkedWriteState(void) { + // A real primitive replacing the open-coded inline clears in communication.cpp. + // The buffer itself 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. + chunkedWriteState.active = false; + chunkedWriteState.totalSize = 0; + chunkedWriteState.receivedSize = 0; + chunkedWriteState.expectedChunks = 0; + chunkedWriteState.receivedChunks = 0; +} + +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: skipped when dropLink, since the link is about to go. + // 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(); + + // 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..8550f57 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 @@ -96,6 +97,11 @@ 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 +// 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 +797,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 +828,14 @@ void disconnectWiFiServer() { } wifiServerConnected = false; tcpReceiveBufferPos = 0; + // Release the slot if this session held it -- the LAN analogue of the BLE + // disconnect callback's release. The link is already closed above, so R3a's + // ordering holds. Full-identity release: a stale teardown whose epoch no longer + // matches cannot free a slot that a newer session has since claimed. + if (s_lanEpoch != 0) { + linkRelease((LinkId){OWNER_LAN, 0, s_lanEpoch}); + s_lanEpoch = 0; + } // 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 @@ -868,11 +904,30 @@ void handleWiFiServer() { WiFiClient incoming = wifiServer.accept(); if (incoming) { - if (wifiClient.connected()) { - lanLog("LAN: new client, replacing previous"); - tlsCloseSession(); - clearEncryptionSession(); - wifiClient.stop(); + // REFUSE while the slot is held -- do not evict. Same scope note as + // serviceContenderRefusal() in main.cpp: refusal belongs to Phase 3, but + // Phase 2 cannot ship the owner token without it. Two concrete reasons here: + // + // - The old eviction path 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 was held + // until reboot. Refusing removes the path rather than patching it. + // - Eviction is unauthenticated by design: LAN-TLS bypasses app-layer auth, + // so today ANY host on the network can kill an in-flight display push + // just by opening a socket, with no credentials. + // + // The incumbent is untouched: no teardown, no crypto clear, no cleanup flag. + // That is the whole of R3's "refusal is inert", and it also fixes 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 @@ -884,6 +939,29 @@ void handleWiFiServer() { tcpReceiveBufferPos = 0; wifiServerConnected = true; lastLanActivityMs = millis(); + // Claim the slot at TCP ACCEPT, before the TLS handshake (7a row 2). The + // handshake is driven incrementally across later loop 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 the LAN transport's earliest hook, so this 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 at the check above, so this claim 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) -- in which case the socket is refused for the same reason. + 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) { if (!tlsBeginSession()) { @@ -943,7 +1021,14 @@ 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 @@ -969,8 +1054,19 @@ 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; + // 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; + } lastLanActivityMs = millis(); 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..45ca197 100644 --- a/src/wifi_service.h +++ b/src/wifi_service.h @@ -28,6 +28,15 @@ 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); 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; +} From aaa9ae3cc5132c50be862b77b9887a00c6e9cfce Mon Sep 17 00:00:00 2001 From: David Lee <247393336+davelee98@users.noreply.github.com> Date: Fri, 31 Jul 2026 19:08:01 -0400 Subject: [PATCH 03/10] =?UTF-8?q?fix(ble):=20close=20the=20Phase=202=20rev?= =?UTF-8?q?iew=20findings=20=E2=80=94=20LAN=20teardown=20ordering,=20refus?= =?UTF-8?q?al=20races?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four review rounds against the Phase 2 implementation (dbec776). Each fix below replaced an earlier one that a later round showed to be wrong; the reasoning is recorded at each site so the rejected shapes are not re-tried. - LAN released its token before the deferred abort, on every ordinary exit path (TLS failure, peer close, read error, idle timeout, config restart). A BLE connect could claim the freed slot first, at which point the cleanup saw a live BLE owner and skipped the LAN abort entirely -- leaving the new owner's frames running against the departed LAN session's transfer state. disconnectWiFiServer no longer releases; the abort's final step does, exactly as on BLE. - Contender refusal could disconnect the winner. The connect callback publishes its table entry BEFORE its claim CAS, so an entry seen against a stale owner snapshot may belong to the connection taking the slot. Skipping the scan while unowned (the first attempt) then left a decided loser attached forever -- the original nRF unavailability, reintroduced. Fixed by publishing the claim DISPOSITION: each entry carries the identity word its claim was decided for, so the scan can tell in-flight from decided-and-lost. The identity binding (rather than a bool) is what excludes pairing one entry's identity with another's disposition after a slot is reused. - The scan's three loads are now ordered so a stale read is safe: entry word, then its decided-for word (must match, proving the claim resolved for THIS instance), then the owner word read fresh and last. - disconnect() takes (handle, epoch) and re-validates against the table before asking the stack, so a caller acting on a slightly stale scan cannot drop whoever inherited the numeric handle. - Refusal no longer raises the shared cleanup flag indirectly: serviceBleEvents schedules teardown only when the token's BLE owner has no live table entry (state-based, so it survives coalesced events). - The deep-sleep wake prologue tested aggregate ble.isConnected(), so a contender triggered fullSetupAfterConnection() and closed the wake window. It now tests for a live BLE owner, and reaps contenders in that branch. Residuals, stated rather than implied: notify() and disconnect() are both handle-addressed, so a host-task handle reassignment between validation and the stack call remains possible -- narrowed to a few instructions, and the exposure is a spuriously dropped client that reconnects, never stranded ownership. 11 envs build; host test 70,170 checks passes under ASan+UBSan and TSan. --- src/ble_transport.h | 27 ++++++++++++- src/ble_transport_esp32.cpp | 37 +++++++++++++++++- src/ble_transport_nrf.cpp | 18 ++++++++- src/main.cpp | 75 ++++++++++++++++++++++++++++++++++--- src/session_guard.cpp | 2 +- src/wifi_service.cpp | 20 ++++++---- 6 files changed, 162 insertions(+), 17 deletions(-) diff --git a/src/ble_transport.h b/src/ble_transport.h index 413a30d..b24ca57 100644 --- a/src/ble_transport.h +++ b/src/ble_transport.h @@ -58,6 +58,20 @@ class BleTransport { // 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) --- @@ -76,7 +90,18 @@ class BleTransport { // // Loop task only. A callback that severs its own link mid-dispatch is exactly // the class of bug the unified-loop work removed. - bool disconnect(uint16_t handle); + // 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 diff --git a/src/ble_transport_esp32.cpp b/src/ble_transport_esp32.cpp index a915ba1..952c401 100644 --- a/src/ble_transport_esp32.cpp +++ b/src/ble_transport_esp32.cpp @@ -62,6 +62,20 @@ struct BleInstance { // 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]; @@ -84,6 +98,7 @@ static uint32_t instancePublish(uint16_t handle, uint16_t epoch) { 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; } } @@ -97,12 +112,14 @@ 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); } } @@ -167,6 +184,15 @@ class OdServerCallbacks : public BLEServerCallbacks { // 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]"); @@ -460,10 +486,19 @@ uint32_t BleTransport::instanceWordAt(uint8_t index) const { 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) { +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); diff --git a/src/ble_transport_nrf.cpp b/src/ble_transport_nrf.cpp index f68f60e..b4ff6bd 100644 --- a/src/ble_transport_nrf.cpp +++ b/src/ble_transport_nrf.cpp @@ -53,6 +53,10 @@ static volatile uint32_t s_disconnectedWord = 0; // 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; @@ -139,10 +143,14 @@ static void onConnectCb(uint16_t conn_handle) { // 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]"); @@ -161,6 +169,7 @@ static void onDisconnectCb(uint16_t conn_handle, uint8_t reason) { // 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 @@ -318,9 +327,16 @@ uint32_t BleTransport::instanceWordAt(uint8_t index) const { 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) { +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 diff --git a/src/main.cpp b/src/main.cpp index 0804fd8..b8983e3 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -523,7 +523,27 @@ static void serviceBleEvents() { // 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) { + s_disconnectCleanupPending = true; + } 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 @@ -560,15 +580,50 @@ static void serviceBleEvents() { // 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() { - const uint32_t owner = linkOwnerWord(); + // 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 || w == owner) continue; + 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); + (void)ble.disconnect(id.handle, id.epoch); } } @@ -628,7 +683,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(); @@ -638,6 +702,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) { diff --git a/src/session_guard.cpp b/src/session_guard.cpp index e85d5ed..54f591e 100644 --- a/src/session_guard.cpp +++ b/src/session_guard.cpp @@ -41,7 +41,7 @@ extern chunked_write_state_t chunkedWriteState; bool bleDropAndWait(uint16_t handle, uint16_t epoch) { if (!ble.instanceLive(handle, epoch)) return true; // already down - ble.disconnect(handle); + 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 diff --git a/src/wifi_service.cpp b/src/wifi_service.cpp index 8550f57..a215552 100644 --- a/src/wifi_service.cpp +++ b/src/wifi_service.cpp @@ -828,14 +828,18 @@ void disconnectWiFiServer() { } wifiServerConnected = false; tcpReceiveBufferPos = 0; - // Release the slot if this session held it -- the LAN analogue of the BLE - // disconnect callback's release. The link is already closed above, so R3a's - // ordering holds. Full-identity release: a stale teardown whose epoch no longer - // matches cannot free a slot that a newer session has since claimed. - if (s_lanEpoch != 0) { - linkRelease((LinkId){OWNER_LAN, 0, s_lanEpoch}); - s_lanEpoch = 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 From 4acd259b6776dca39058829c45a0383cb7e6b29b Mon Sep 17 00:00:00 2001 From: David Lee <247393336+davelee98@users.noreply.github.com> Date: Fri, 31 Jul 2026 19:30:11 -0400 Subject: [PATCH 04/10] ci: gate the link-owner host test; record that refusal moved to Phase 2 The Phase 2 work added tools/test_link_owner.cpp but never wired it into CI, so 70k checks covering the owner token, epoch discrimination, the terminal gate and the activity clock were not a repo gate. Run it in the existing host-tests job, twice: ASan+UBSan for the single-threaded semantics and TSan for the concurrent claim/epoch paths, since the two sanitizers cannot be combined and the cross-task claim is exactly what needs the second one. Also reconcile the plan with what shipped: contender refusal is in Phase 2, not Phase 3, because Phase 2 is not safely shippable without it (a client that reconnects into a held slot becomes a permanent contender, and on nRF occupies the only link). The phase table said otherwise, which would have misled the next reader about where that mechanism lives. --- docs/PLAN_FREEZE_HARDENING_2026-07-31.md | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/docs/PLAN_FREEZE_HARDENING_2026-07-31.md b/docs/PLAN_FREEZE_HARDENING_2026-07-31.md index 7b1bd1a..bb86220 100644 --- a/docs/PLAN_FREEZE_HARDENING_2026-07-31.md +++ b/docs/PLAN_FREEZE_HARDENING_2026-07-31.md @@ -76,10 +76,27 @@ and R4's no-transfer-gate was reconciled in the previous revision. | # | 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, activity clock, abort-to-known-state | — | Partially present (`serviceBleDisconnectCleanup`) | -| 3 | Connection-exclusivity **policy** + idle drop | Phase 2 | Not started | +| 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 | Not started | | 4 | Auth-abuse disconnect | Phase 2, Phase 3 | Prototype exists off-branch, not here | +> **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 From 86ba23f2989a3dbaf8aa31039ccb5ad213af3964 Mon Sep 17 00:00:00 2001 From: David Lee <247393336+davelee98@users.noreply.github.com> Date: Fri, 31 Jul 2026 19:55:22 -0400 Subject: [PATCH 05/10] =?UTF-8?q?feat(ble):=20Phase=203=20=E2=80=94=20idle?= =?UTF-8?q?=20drop,=20and=20one=20activity=20clock=20for=20both=20transpor?= =?UTF-8?q?ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Phase 3 of docs/PLAN_FREEZE_HARDENING_2026-07-31.md. Contender refusal already landed in Phase 2 (it was not safely separable), so what remains here is the reclaim path and the LAN half of R4's clock semantics. - serviceIdleTimeout() reclaims a slot whose owner has gone silent (7c). Called LAST in the pass (7d step 4), after BLE RX and handleWiFiServer, so traffic parsed this pass counts. Excludes refresh (7c row 3) and an unowned or terminal slot (7c row 4). Since admission never evicts, this is the ONLY way a held slot is released short of the client leaving. - NO transferActive() gate, per R4. A client that goes silent mid-upload is exactly the case that wedges the device, and a transfer gate would exempt it. The partial transfer is discarded by the abort. - OD_BLE_IDLE_TIMEOUT_MS = 120 s, #ifndef-guarded beside the code that services it, with the client behaviour it assumes recorded on the define. Deliberately generous: R4 inverted the direction of the error, since erring short now costs a legitimate upload rather than a stale session. - LAN's separate activity clock is RETIRED. lastLanActivityMs and its four stamp sites are gone, as is the inline 30 s check inside handleWiFiServer. Both transports now use the shared clock, differing only in their constant (BLE's local 120 s vs LAN's OD_LAN_READ_TIMEOUT_S, which is a client-visible wire-header contract). Two clocks for one rule is how they drift. The TLS handshake completion site stamps the clock, so the idle baseline starts there rather than at TCP accept -- which is why no separate handshake deadline is needed. - Accept/refuse is extracted into admitOrRefuseLanClient() so handleWiFiServer CONTINUES servicing the incumbent after a refusal. Returning early there let a contender starve the incumbent: its handshake never advanced and its frames were never dispatched, so the clock stopped being stamped and the new end-of-pass idle drop killed it with valid commands still unread -- an R3 violation (refusal must be inert) and an R7d one (step 3 before step 4). Found in review. 11 envs build; host tests pass under ASan+UBSan. No hardware: landed, not closed. --- docs/PLAN_FREEZE_HARDENING_2026-07-31.md | 2 +- src/main.cpp | 81 ++++++++++ src/wifi_service.cpp | 182 +++++++++++++---------- 3 files changed, 188 insertions(+), 77 deletions(-) diff --git a/docs/PLAN_FREEZE_HARDENING_2026-07-31.md b/docs/PLAN_FREEZE_HARDENING_2026-07-31.md index bb86220..e8030ff 100644 --- a/docs/PLAN_FREEZE_HARDENING_2026-07-31.md +++ b/docs/PLAN_FREEZE_HARDENING_2026-07-31.md @@ -77,7 +77,7 @@ and R4's no-transfer-gate was reconciled in the previous revision. |---|---|---|---| | 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 | Not started | +| 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 | Prototype exists off-branch, not here | > **Refusal moved from Phase 3 to Phase 2 during implementation.** Phase 2 is not diff --git a/src/main.cpp b/src/main.cpp index b8983e3..6d60fae 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -627,6 +627,80 @@ static void serviceContenderRefusal() { } } +// 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 @@ -849,6 +923,13 @@ 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(); + // 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 diff --git a/src/wifi_service.cpp b/src/wifi_service.cpp index a215552..14799f3 100644 --- a/src/wifi_service.cpp +++ b/src/wifi_service.cpp @@ -96,7 +96,14 @@ 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 @@ -885,6 +892,79 @@ static int lanReadIntoBuffer(void) { return (bytesRead > 0) ? bytesRead : 0; } +// 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. @@ -906,74 +986,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) { - // REFUSE while the slot is held -- do not evict. Same scope note as - // serviceContenderRefusal() in main.cpp: refusal belongs to Phase 3, but - // Phase 2 cannot ship the owner token without it. Two concrete reasons here: - // - // - The old eviction path 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 was held - // until reboot. Refusing removes the path rather than patching it. - // - Eviction is unauthenticated by design: LAN-TLS bypasses app-layer auth, - // so today ANY host on the network can kill an in-flight display push - // just by opening a socket, with no credentials. - // - // The incumbent is untouched: no teardown, no crypto clear, no cleanup flag. - // That is the whole of R3's "refusal is inert", and it also fixes 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; - lastLanActivityMs = millis(); - // Claim the slot at TCP ACCEPT, before the TLS handshake (7a row 2). The - // handshake is driven incrementally across later loop 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 the LAN transport's earliest hook, so this 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 at the check above, so this claim 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) -- in which case the socket is refused for the same reason. - 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) { - 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()) { @@ -989,7 +1015,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 @@ -1035,13 +1066,10 @@ void handleWiFiServer() { // 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; } @@ -1068,7 +1096,9 @@ void handleWiFiServer() { const LinkId lanOwner = linkOwnerId(); g_commandInstance = (lanOwner.who == OWNER_LAN) ? linkIdWord(lanOwner) : 0; } - lastLanActivityMs = millis(); + // 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 From 2e65cbd84a1d7ea3667fdc31e41f30d47852db9a Mon Sep 17 00:00:00 2001 From: David Lee <247393336+davelee98@users.noreply.github.com> Date: Sun, 2 Aug 2026 08:00:31 -0400 Subject: [PATCH 06/10] =?UTF-8?q?fix(ble):=20close=20the=20full-audit=20fi?= =?UTF-8?q?ndings=20=E2=80=94=20activity=20requires=20an=20accepted=20comm?= =?UTF-8?q?and?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An independent audit of the whole Phase 2+3 stack (fresh review context, not the incremental ones) found eight issues. Fixed here, except two dropped as hostile-DoS only; one of those came BACK in scope on its merits and is the headline fix. - ACTIVITY NOW REQUIRES AN ACCEPTED COMMAND, not merely a recognised one. The old rule let an UNAUTHENTICATED session hold the exclusive slot forever, and not only under abuse: CMD_FIRMWARE_VERSION dispatches before the auth gate, so it never draws RESP_AUTH_REQUIRED and would never have incremented Phase 4's auth-abuse counter either -- the two mechanisms were documented as exhaustive and were not. The benign triggers are a monitoring integration polling firmware version and a client retrying auth with stale credentials. A handshake still cannot race the clock: the window runs from admission, so a client gets the whole 120 s to authenticate; it just may not extend it by retrying. - The deferred cleanup flag carried no identity, so a stale one could run a destructive teardown against whoever held the slot later -- resetting a freshly admitted client's crypto and rings. It now records the owner it was raised for and acts only if the slot still holds exactly that. A cleanup raised while UNOWNED is skipped outright: zero is not a session identity and must not authorise destruction. - A peer-closed LAN socket is now reaped early in the pass (wifiLanReapClosedSession) rather than inside handleWiFiServer. Reaping there was too late: it only raises the deferred cleanup, so the accept a few lines on still tested the corpse's token and refused an ordinary reconnect -- 7d wants release before admission. - BLE event publication is atomic on both targets: payload stored before the flag, flag RELEASE-stored, consumers using __atomic_exchange_n(ACQUIRE). The ESP32 connect site had been missed by the first attempt at this and was still a plain store paired against an atomic consumer. - Comment corrections where the text had become false: the transports' "callbacks only copy and flag" contract (they claim ownership now), command_queue.h's "nRF carries these unused", link_owner.h's "loop-task-only" activity clock, and the LAN baseline description. CONNECTION_POLICY now describes the provisional accept-time window restarted at handshake completion, which is what the code does, and no longer states the supervision timeout as categorically 4-6 s (the central chooses it; the spec permits up to 32 s). - CI/docs said eleven environments. There are twelve: esp32-wrover-e-N4R8 ships via firmware-targets.json but is absent from default_envs, so a bare `pio run` skips it -- and it is a no-WiFi target, the one most likely to catch a broken #ifndef OPENDISPLAY_HAS_WIFI path. Recorded in CLAUDE.md and built explicitly. Out of scope by direction (hostile DoS): a peer that ignores link termination holding controller capacity, which the refusal scan retries every pass anyway. All 12 envs build; host tests pass under ASan+UBSan and TSan. # Conflicts: # .github/workflows/main.yaml --- CLAUDE.md | 2 +- docs/CONNECTION_POLICY.md | 13 ++++++--- src/ble_transport.h | 18 ++++++++---- src/ble_transport_esp32.cpp | 56 ++++++++++++++++++++++++++----------- src/ble_transport_nrf.cpp | 56 ++++++++++++++++++++++++------------- src/communication.cpp | 50 +++++++++++++++++++++------------ src/display_service.cpp | 10 ++++--- src/link_owner.h | 17 +++++++++-- src/main.cpp | 53 ++++++++++++++++++++++++++++++++++- src/wifi_service.cpp | 12 ++++++++ src/wifi_service.h | 18 ++++++++++-- 11 files changed, 229 insertions(+), 76 deletions(-) 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 index eac237a..7846298 100644 --- a/docs/CONNECTION_POLICY.md +++ b/docs/CONNECTION_POLICY.md @@ -538,8 +538,9 @@ incumbent holds the slot. A client that is genuinely gone is reaped by the link 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 and be **recognised as -a command from the current owner** — where "from the current owner" is full instance +**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 @@ -587,8 +588,12 @@ 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, but the baseline starts at **TLS handshake completion** -(see R7a note), since handshake traffic is not a command. +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)), diff --git a/src/ble_transport.h b/src/ble_transport.h index b24ca57..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 --- diff --git a/src/ble_transport_esp32.cpp b/src/ble_transport_esp32.cpp index 952c401..4cebd6f 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 @@ -196,13 +201,17 @@ class OdServerCallbacks : public BLEServerCallbacks { od_log_info("=== BLE CLIENT CONNECTED (ESP32) h=%u e=%u %s ===", (unsigned)handle, (unsigned)epoch, admitted ? "[owner]" : "[contender - refused service]"); - s_connectedWord = word; + // 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. - s_connectedEvent = true; + __atomic_store_n(&s_connectedEvent, true, __ATOMIC_RELEASE); } void onDisconnect(NimBLEServer* pServer, NimBLEConnInfo& connInfo, int reason) override { (void)pServer; @@ -212,8 +221,8 @@ class OdServerCallbacks : public BLEServerCallbacks { 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. - s_disconnectReason = (uint16_t)reason; - s_disconnectedWord = word; + __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. @@ -232,7 +241,7 @@ class OdServerCallbacks : public BLEServerCallbacks { // 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 @@ -583,21 +592,36 @@ 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(uint32_t* instanceWord) { - if (!s_connectedEvent) return false; - s_connectedEvent = false; - if (instanceWord != nullptr) *instanceWord = s_connectedWord; + // 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 pair one event's flag with another's word. ACQUIRE pairs with + // the callback's RELEASE store of the flag, which it makes after writing the + // payload, so a true return guarantees the payload below is the one that flag + // was raised for. + 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(uint16_t* reason, uint32_t* instanceWord) { - if (!s_disconnectedEvent) return false; - s_disconnectedEvent = false; - if (reason != nullptr) *reason = s_disconnectReason; - if (instanceWord != nullptr) *instanceWord = s_disconnectedWord; + // See takeConnectedEvent(): atomic exchange so the flag and its payload cannot + // be torn apart, and so an event landing in the old check-then-clear window is + // not silently dropped. + 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 b4ff6bd..56d7526 100644 --- a/src/ble_transport_nrf.cpp +++ b/src/ble_transport_nrf.cpp @@ -131,13 +131,13 @@ 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) { s_connHandle = conn_handle; // Same normative order as ESP32 (R2): epoch, then table entry, then claim. @@ -154,17 +154,18 @@ static void onConnectCb(uint16_t conn_handle) { od_log_info("=== BLE CLIENT CONNECTED (nRF) h=%u e=%u %s ===", (unsigned)conn_handle, (unsigned)epoch, admitted ? "[owner]" : "[contender - refused service]"); - s_connectedWord = word; - s_connectedEvent = true; + // 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) reason=0x%03X ===", (unsigned)reason); s_connHandle = BLE_CONN_HANDLE_INVALID; - s_disconnectReason = reason; + __atomic_store_n(&s_disconnectReason, (uint16_t)reason, __ATOMIC_RELAXED); const uint32_t word = __atomic_load_n(&s_instanceWord, __ATOMIC_ACQUIRE); - s_disconnectedWord = word; + __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). @@ -185,7 +186,7 @@ static void onDisconnectCb(uint16_t conn_handle, uint8_t reason) { // 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. - s_disconnectedEvent = true; + __atomic_store_n(&s_disconnectedEvent, true, __ATOMIC_RELEASE); } // Adapter: Bluefruit's write_callback_t is BLECharacteristic*-shaped, whereas the @@ -430,21 +431,36 @@ 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(uint32_t* instanceWord) { - if (!s_connectedEvent) return false; - s_connectedEvent = false; - if (instanceWord != nullptr) *instanceWord = s_connectedWord; + // 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 pair one event's flag with another's word. ACQUIRE pairs with + // the callback's RELEASE store of the flag, which it makes after writing the + // payload, so a true return guarantees the payload below is the one that flag + // was raised for. + 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(uint16_t* reason, uint32_t* instanceWord) { - if (!s_disconnectedEvent) return false; - s_disconnectedEvent = false; - if (reason != nullptr) *reason = s_disconnectReason; - if (instanceWord != nullptr) *instanceWord = s_disconnectedWord; + // See takeConnectedEvent(): atomic exchange so the flag and its payload cannot + // be torn apart, and so an event landing in the old check-then-clear window is + // not silently dropped. + 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/communication.cpp b/src/communication.cpp index 6e397e0..de8ec2a 100644 --- a/src/communication.cpp +++ b/src/communication.cpp @@ -562,29 +562,43 @@ void imageDataWritten(BLEConnHandle conn_hdl, BLECharPtr chr, uint8_t* data, uin uint16_t command = (data[0] << 8) | data[1]; - // The R4 activity stamp. Two tests, and both matter: + // The R4 activity stamp. THREE tests, and each closes a way of holding the + // exclusive slot without doing any real work: // // - RECOGNISED: commandName() != nullptr. Unknown opcodes fall to the switch // default's "Unknown command" error, so they are not activity. Reusing the - // existing recognition predicate avoids a second, drift-prone one. This is - // what an earlier draft got wrong by stamping at RX intake instead: - // bleRxQueuePush accepts ANY non-empty payload within the size cap, so a - // two-byte malformed frame counted as activity and a garbage flooder could - // hold the slot indefinitely -- precisely the failure the idle drop exists - // to prevent. + // existing recognition predicate avoids a second, drift-prone one. An + // earlier draft stamped at RX intake instead, where bleRxQueuePush accepts + // ANY non-empty payload within the size cap -- so a two-byte malformed frame + // counted as activity and a garbage flooder held the slot indefinitely. // - // - FROM THE OWNER: g_commandInstance (set by the caller from the frame's tag, - // or from the LAN owner's identity) still equals the live owner word. NOT a - // transport comparison: transport alone cannot tell a delayed frame from a - // dead BLE instance apart from the new BLE owner, and such a frame would - // stamp the new owner's clock. + // - FROM THE OWNER: the frame's instance identity (g_commandInstance, set by + // the transport from the queued frame's tag or the LAN owner's identity) + // still equals the live owner word. NOT a transport comparison: transport + // alone cannot tell a delayed frame from a dead BLE instance apart from the + // new BLE owner, and such a frame would stamp the new owner's clock. // - // Deliberately BEFORE the auth gate: CMD_AUTHENTICATE must count as activity or - // a client could not complete a handshake without racing the clock. A peer that - // floods recognised-but-never-authenticating commands is Phase 4's job (the - // auth-abuse counter), not this clock's -- the counter can tell "wrong - // credentials" from "silent", which the clock cannot. - if (commandName(command) != nullptr && linkIsOwnerWord(g_commandInstance)) { + // - ACCEPTED, not merely parsed: while encryption is on, only an + // AUTHENTICATED command counts. This is the correction that matters most in + // practice. Stamping every recognised opcode let an UNAUTHENTICATED session + // hold the slot forever, and not only under deliberate abuse -- the two + // realistic triggers are entirely benign: a monitoring integration that + // polls CMD_FIRMWARE_VERSION (dispatched before the auth gate entirely), and + // a misconfigured client retrying CMD_AUTHENTICATE with stale credentials. + // Either refreshes the clock forever, so the idle timeout never fires, while + // every legitimate client is refused the slot. + // + // A handshake still cannot race the clock, which is what the old rule was + // protecting: the window runs from ADMISSION, so a client has the full idle + // timeout -- 120 s on BLE, against a handshake of one exchange -- to + // authenticate. It simply does not get to extend that window by retrying. + // With encryption disabled there is no gate to pass, so every recognised + // owner command counts. + const bool commandAccepted = !isEncryptionEnabled() || + g_commandOrigin == ORIGIN_LAN_TLS || // TLS is the auth + isAuthenticated(); + if (commandAccepted && commandName(command) != nullptr && + linkIsOwnerWord(g_commandInstance)) { linkStampOwnerCommand(); } diff --git a/src/display_service.cpp b/src/display_service.cpp index dcf61bf..04a9c8a 100644 --- a/src/display_service.cpp +++ b/src/display_service.cpp @@ -628,10 +628,12 @@ void checkTransferTimeouts(void) { // 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. They can differ: LAN sessions are admitted unowned in Phase 2 when - // BLE already holds the slot, so a timed-out LAN transfer with a BLE owner - // would otherwise drop the innocent BLE client's link. Teardown still runs - // either way -- only the drop is withheld. + // 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 = diff --git a/src/link_owner.h b/src/link_owner.h index 22413f7..8d1abec 100644 --- a/src/link_owner.h +++ b/src/link_owner.h @@ -98,8 +98,14 @@ static inline bool linkIsOwnerWord(uint32_t w) { return w != 0 && w == linkOwner // --- activity clock (CONNECTION_POLICY R4) ----------------------------------- // "Idle" is: no inbound command from the owner on the owning transport, AND no -// refresh in progress. Loop-task-only single-writer, so plain globals -- the same -// argument that lets g_commandOrigin be a bare global. +// 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 @@ -109,7 +115,12 @@ static inline bool linkIsOwnerWord(uint32_t w) { return w != 0 && w == linkOwner // accrue the whole refresh and drop an actively engaged client the instant loop() // resumes. uint32_t linkMsSinceOwnerCommand(void); // 0 when unowned -void linkStampOwnerCommand(void); // dispatcher, on a recognised owner command +// 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 6d60fae..a8a1918 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -290,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; } @@ -389,7 +405,30 @@ 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 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 @@ -540,7 +579,7 @@ static void serviceBleEvents() { (tokenOwner.who == OWNER_BLE && !ble.instanceLive(tokenOwner.handle, tokenOwner.epoch)); if (ownerDeparted) { - s_disconnectCleanupPending = true; + requestTransferSessionCleanup(); // records the identity it is for } else { od_log_debug("Disconnect event from a non-owner instance; no cleanup scheduled"); } @@ -879,6 +918,18 @@ void loop() { // 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(); diff --git a/src/wifi_service.cpp b/src/wifi_service.cpp index 14799f3..eb3062d 100644 --- a/src/wifi_service.cpp +++ b/src/wifi_service.cpp @@ -892,6 +892,18 @@ 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 diff --git a/src/wifi_service.h b/src/wifi_service.h index 45ca197..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) @@ -37,6 +40,15 @@ void disconnectWiFiServer(); * 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 From c7bdbfcb2a2d3debb166b5a34cbe46b86f9a010e Mon Sep 17 00:00:00 2001 From: David Lee <247393336+davelee98@users.noreply.github.com> Date: Fri, 31 Jul 2026 20:41:35 -0400 Subject: [PATCH 07/10] fix(ble): the activity rule must not depend on authentication being configured The previous fix required an ACCEPTED command, which on a device with encryption enabled correctly stops an unauthenticated session pinning the exclusive slot. But encryption_enabled defaults to 0 -- documented in opendisplay_structs.h as "all commands work unauthenticated" -- and with no gate to pass, "accepted" degrades to "recognised" and the rule becomes a no-op. The hole was therefore still open on the default configuration, which is likely the common one in the field. Split the rule so it behaves the same either way: - CMD_AUTHENTICATE and CMD_FIRMWARE_VERSION never count as activity in ANY configuration. Both dispatch ahead of the auth gate and neither is work the device does for a client, so a session sending only those is idle by any useful definition. This is the half that carries the auth-off case. - Where a gate does exist, the command must also be past it, so an unauthenticated peer's refused commands cannot outlive a working client. Behaviour is unchanged for real clients in both configurations: they send real commands, which stamp. A handshake still cannot race the clock, because the window runs from admission -- 120 s against a one-exchange handshake. 12 envs build. --- src/communication.cpp | 47 ++++++++++++++++++++++++++----------------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/src/communication.cpp b/src/communication.cpp index de8ec2a..6771caf 100644 --- a/src/communication.cpp +++ b/src/communication.cpp @@ -578,26 +578,37 @@ void imageDataWritten(BLEConnHandle conn_hdl, BLECharPtr chr, uint8_t* data, uin // alone cannot tell a delayed frame from a dead BLE instance apart from the // new BLE owner, and such a frame would stamp the new owner's clock. // - // - ACCEPTED, not merely parsed: while encryption is on, only an - // AUTHENTICATED command counts. This is the correction that matters most in - // practice. Stamping every recognised opcode let an UNAUTHENTICATED session - // hold the slot forever, and not only under deliberate abuse -- the two - // realistic triggers are entirely benign: a monitoring integration that - // polls CMD_FIRMWARE_VERSION (dispatched before the auth gate entirely), and - // a misconfigured client retrying CMD_AUTHENTICATE with stale credentials. - // Either refreshes the clock forever, so the idle timeout never fires, while - // every legitimate client is refused the slot. + // - NOT A HANDSHAKE/DISCOVERY OPCODE, and ACCEPTED where there is a gate to + // pass. Together these stop a session holding the exclusive slot without + // doing any real work, and they are deliberately split so the rule behaves + // the same whether or not authentication is configured: // - // A handshake still cannot race the clock, which is what the old rule was + // * CMD_AUTHENTICATE and CMD_FIRMWARE_VERSION never count, in ANY + // configuration. Both are dispatched before the auth gate (they are the + // handshake), and neither represents work the device is doing for a + // client. Excluding them unconditionally is what makes this work on a + // device with encryption DISABLED -- which is the default + // (encryption_enabled = 0 means "all commands work unauthenticated"), so + // an auth-conditional rule alone would be a no-op on most devices. + // * Where a gate exists, the command must also be past it. With encryption + // on, an unauthenticated peer's other commands are refused, so counting + // them as activity would let a rejected client outlive a working one. + // + // The two realistic pinning triggers are benign, which is why this is not + // filed under abuse: a monitoring integration polling firmware version, and + // a client retrying authentication with stale credentials. Either used to + // refresh the clock forever while every other client was refused the slot. + // + // A handshake still cannot race the clock, which is what the weaker rule was // protecting: the window runs from ADMISSION, so a client has the full idle - // timeout -- 120 s on BLE, against a handshake of one exchange -- to - // authenticate. It simply does not get to extend that window by retrying. - // With encryption disabled there is no gate to pass, so every recognised - // owner command counts. - const bool commandAccepted = !isEncryptionEnabled() || - g_commandOrigin == ORIGIN_LAN_TLS || // TLS is the auth - isAuthenticated(); - if (commandAccepted && commandName(command) != nullptr && + // timeout -- 120 s on BLE, against a handshake of one exchange -- to get + // through it. It simply may not extend that window by retrying. + const bool handshakeOpcode = (command == CMD_AUTHENTICATE || + command == CMD_FIRMWARE_VERSION); + const bool pastAuthGate = !isEncryptionEnabled() || + g_commandOrigin == ORIGIN_LAN_TLS || // TLS is the auth + isAuthenticated(); + if (!handshakeOpcode && pastAuthGate && commandName(command) != nullptr && linkIsOwnerWord(g_commandInstance)) { linkStampOwnerCommand(); } From a3f165499a4e464154013712b9a2704a7502398b Mon Sep 17 00:00:00 2001 From: David Lee <247393336+davelee98@users.noreply.github.com> Date: Fri, 31 Jul 2026 20:51:12 -0400 Subject: [PATCH 08/10] docs: correct an overclaiming memory-ordering comment; Phase 4 is now optional Two loose ends found while auditing what remains. - takeConnectedEvent/takeDisconnectedEvent claimed the acquire exchange guarantees the payload belongs to the flag just consumed. It does not: release/acquire orders writes PRECEDING the release and nothing freezes the payload afterwards, so a second event landing between the exchange and the load hands us its word. Tolerable only because nothing decides on it -- teardown and connect-side work both derive from the owner token and instance table -- so the comment now says the payload is diagnostic and warns against building a decision on it. - Phase 4's stated premise is stale. It claimed Phase 3 depended on the auth-abuse counter, because the clock stamped any recognised command before the auth gate, so a never-authenticating peer kept it fresh forever. Phase 3's narrower activity rule removed that dependency: handshake/discovery opcodes never stamp, so such a peer now ages normally and the idle timeout drops it. Phase 4 is demoted from correctness requirement to optimisation -- it shortens a 120 s reclaim to about one exchange and gives the client an explicit reason instead of a silent timeout. It should be scheduled on that value, not on the old argument. --- docs/PLAN_FREEZE_HARDENING_2026-07-31.md | 28 +++++++++++++++++------- src/ble_transport_esp32.cpp | 21 +++++++++++++----- src/ble_transport_nrf.cpp | 21 +++++++++++++----- 3 files changed, 50 insertions(+), 20 deletions(-) diff --git a/docs/PLAN_FREEZE_HARDENING_2026-07-31.md b/docs/PLAN_FREEZE_HARDENING_2026-07-31.md index e8030ff..911f4d2 100644 --- a/docs/PLAN_FREEZE_HARDENING_2026-07-31.md +++ b/docs/PLAN_FREEZE_HARDENING_2026-07-31.md @@ -1480,14 +1480,26 @@ the placement. Phase 2 (the seam and its R3a wait, `abortToKnownState`, the owner token) and Phase 3 (it slots into the same admission/idle policy layer). -**And Phase 3 depends on *it* for one case**, which is worth stating because the division -is easy to get backwards. Under R4 the activity clock stamps a *recognised* command -before the auth gate — it has to, or a client could not complete a handshake without -racing the clock. So a peer that floods **recognised but never-authenticating** commands -keeps its clock fresh and the idle drop never fires on it. That peer is Phase 4's job, -not Phase 3's. The two together are exhaustive: garbage that is not a recognised command -never stamps the clock and is dropped by the idle timeout; recognised commands that never -authenticate are dropped by the counter. +**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 diff --git a/src/ble_transport_esp32.cpp b/src/ble_transport_esp32.cpp index 4cebd6f..2df8d80 100644 --- a/src/ble_transport_esp32.cpp +++ b/src/ble_transport_esp32.cpp @@ -602,10 +602,17 @@ bool BleTransport::eventPending() const { 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 pair one event's flag with another's word. ACQUIRE pairs with + // -- 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 a true return guarantees the payload below is the one that flag - // was raised for. + // 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); @@ -614,9 +621,11 @@ bool BleTransport::takeConnectedEvent(uint32_t* instanceWord) { } bool BleTransport::takeDisconnectedEvent(uint16_t* reason, uint32_t* instanceWord) { - // See takeConnectedEvent(): atomic exchange so the flag and its payload cannot - // be torn apart, and so an event landing in the old check-then-clear window is - // not silently dropped. + // 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) { diff --git a/src/ble_transport_nrf.cpp b/src/ble_transport_nrf.cpp index 56d7526..9921d21 100644 --- a/src/ble_transport_nrf.cpp +++ b/src/ble_transport_nrf.cpp @@ -441,10 +441,17 @@ bool BleTransport::eventPending() const { 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 pair one event's flag with another's word. ACQUIRE pairs with + // -- 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 a true return guarantees the payload below is the one that flag - // was raised for. + // 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); @@ -453,9 +460,11 @@ bool BleTransport::takeConnectedEvent(uint32_t* instanceWord) { } bool BleTransport::takeDisconnectedEvent(uint16_t* reason, uint32_t* instanceWord) { - // See takeConnectedEvent(): atomic exchange so the flag and its payload cannot - // be torn apart, and so an event landing in the old check-then-clear window is - // not silently dropped. + // 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) { From 77c88cf6a1160e4c93eaed3a28e0eb2da4c70c64 Mon Sep 17 00:00:00 2001 From: David Lee <247393336+davelee98@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:02:52 -0400 Subject: [PATCH 09/10] fix(config): make resetChunkedWriteState the single primitive it was specified as The freeze-hardening plan's Phase 2 step 6 called for "a real primitive replacing the open-coded inline clears in communication.cpp; call it here AND from those sites". Only half landed: the primitive was defined inside session_guard.cpp with no declaration in any header, and the three inline clears stayed. That left the exact drift the step existed to remove. The three sites each cleared a different subset -- one set only `active`, one also the counters, none the totals -- so a config upload aborted down one path kept a stale totalSize and expectedChunks, and the abort's own reset was the only complete one. Move the definition beside the state it owns (config_parser.cpp, declared in config_parser.h) and convert all three sites. abortToKnownState keeps calling it. Also record why the abort's step 2 (client NACK) has no code: every caller either drops the link or runs because the link is already gone, so none can deliver one. Written down so it reads as a decision rather than an omission. 12 envs build. --- src/communication.cpp | 8 +++----- src/config_parser.cpp | 12 ++++++++++++ src/config_parser.h | 16 ++++++++++++++++ src/session_guard.cpp | 21 ++++++++------------- 4 files changed, 39 insertions(+), 18 deletions(-) diff --git a/src/communication.cpp b/src/communication.cpp index 6771caf..a6dd53f 100644 --- a/src/communication.cpp +++ b/src/communication.cpp @@ -482,7 +482,7 @@ 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(); uint8_t response[] = {RESP_ACK, (uint8_t)(CMD_CONFIG_CHUNK & 0xFF), RESP_AUTH_REQUIRED}; sendResponseUnencrypted(response, sizeof(response)); return; @@ -490,7 +490,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; @@ -506,9 +506,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)); 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/session_guard.cpp b/src/session_guard.cpp index 54f591e..a2c01e4 100644 --- a/src/session_guard.cpp +++ b/src/session_guard.cpp @@ -18,7 +18,6 @@ #endif extern bool directWriteActive; -extern chunked_write_state_t chunkedWriteState; // How long to wait for a requested drop to actually take the link down. // @@ -64,17 +63,6 @@ bool bleDropAndWait(uint16_t handle, uint16_t epoch) { return down; } -void resetChunkedWriteState(void) { - // A real primitive replacing the open-coded inline clears in communication.cpp. - // The buffer itself 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. - chunkedWriteState.active = false; - chunkedWriteState.totalSize = 0; - chunkedWriteState.receivedSize = 0; - chunkedWriteState.expectedChunks = 0; - chunkedWriteState.receivedChunks = 0; -} void abortToKnownState(const char* reason, bool dropLink, LinkId ownerId) { // 1. Log first, one line, so a teardown is always attributable even if a later @@ -83,7 +71,14 @@ void abortToKnownState(const char* reason, bool dropLink, LinkId ownerId) { dropLink ? 1 : 0, (unsigned)ownerId.who, (unsigned)ownerId.handle, (unsigned)ownerId.epoch); - // 2. Optional client NACK: skipped when dropLink, since the link is about to go. + // 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 From cdc38a76796a783affcae9e72b92cddf76455084 Mon Sep 17 00:00:00 2001 From: David Lee <247393336+davelee98@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:30:55 -0400 Subject: [PATCH 10/10] =?UTF-8?q?feat(ble):=20Phase=204=20=E2=80=94=20auth?= =?UTF-8?q?-abuse=20disconnect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops a BLE link after OD_AUTH_ABUSE_THRESHOLD (10) consecutive commands answered RESP_AUTH_REQUIRED, so a session that cannot authenticate stops holding the globally-exclusive slot while it retries. An OPTIMISATION, not a hole-closer, and built to that standard: Phase 3's narrower activity rule already means such a peer ages normally and the idle timeout reclaims at 120 s. This adds speed and a reason -- about one exchange instead of two minutes, and an explicit final RESP_AUTH_REQUIRED before a deliberate drop rather than a silent timeout. It may fail safe without reopening anything. - Counter is BLE-only (the same auth gate is reachable from plaintext LAN, and counting those would let LAN traffic drop a BLE client), identifies the offender from the frame's own instance tag rather than "whichever peer the stack lists first", and is cleared at every session end via the abort and on a successful handshake. - Threshold 10 chosen deliberately BELOW py-opendisplay's 16-frame pipe window: when a session dies mid-upload every in-flight frame is doomed, so dropping at 10 beats waiting out a full window of pointless round trips. An earlier prototype inherited this number by accident; it is now a decision. - Best-effort delivery of the final FE: drain TX, then dwell one negotiated connection interval, both inside a 500 ms hard bound, then abort with dropLink=true. An empty ring proves stack acceptance of an unacknowledged notification, not receipt, so a deadline-truncated attempt forfeits it by design. THE ACTIVITY DECISION MOVED TWICE UNDER REVIEW, and both earlier positions were wrong the same way -- anything that predicts acceptance is wrong at whatever layer rejects next: - gated on isAuthenticated() at the top: an authenticated client sending a too-short plaintext frame stamped, then got RESP_AUTH_REQUIRED from the length check below it -- defeating the idle timeout AND pinning the rejection run at one, so neither mechanism could ever fire. - just before the dispatch switch: TLS-LAN frames bypass the CCM gate but the config-write handlers apply their own app-layer auth, so a TLS client repeating CMD_CONFIG_WRITE stamped on every rejected attempt. It now sits AFTER the switch and reads an outcome flag set by every rejection site on every transport. That position has nothing below it. The servicer also defers one pass while RX is pending, so an authentication frame that arrived after this pass's drain is dispatched and can cancel the drop. 12 envs build; host tests pass. No hardware: landed, not closed. --- docs/PLAN_FREEZE_HARDENING_2026-07-31.md | 2 +- src/communication.cpp | 227 ++++++++++++++++++----- src/communication.h | 17 ++ src/encryption.cpp | 8 + src/main.cpp | 6 + src/session_guard.cpp | 6 + 6 files changed, 215 insertions(+), 51 deletions(-) diff --git a/docs/PLAN_FREEZE_HARDENING_2026-07-31.md b/docs/PLAN_FREEZE_HARDENING_2026-07-31.md index 911f4d2..5467fef 100644 --- a/docs/PLAN_FREEZE_HARDENING_2026-07-31.md +++ b/docs/PLAN_FREEZE_HARDENING_2026-07-31.md @@ -78,7 +78,7 @@ and R4's no-transfer-gate was reconciled in the previous revision. | 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 | Prototype exists off-branch, not here | +| 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 diff --git a/src/communication.cpp b/src/communication.cpp index a6dd53f..4101e3f 100644 --- a/src/communication.cpp +++ b/src/communication.cpp @@ -21,6 +21,7 @@ #endif #include "link_owner.h" +#include "session_guard.h" bool isAuthenticated(); extern struct GlobalConfig globalConfig; @@ -65,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). " @@ -421,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; @@ -483,6 +620,7 @@ void handleWriteConfigChunk(uint8_t* data, uint16_t len) { bool rewriteAllowed = (securityConfig.flags & (1 << 0)) != 0; if (!rewriteAllowed) { resetChunkedWriteState(); + noteAuthRejected(); uint8_t response[] = {RESP_ACK, (uint8_t)(CMD_CONFIG_CHUNK & 0xFF), RESP_AUTH_REQUIRED}; sendResponseUnencrypted(response, sizeof(response)); return; @@ -560,56 +698,6 @@ void imageDataWritten(BLEConnHandle conn_hdl, BLECharPtr chr, uint8_t* data, uin uint16_t command = (data[0] << 8) | data[1]; - // The R4 activity stamp. THREE tests, and each closes a way of holding the - // exclusive slot without doing any real work: - // - // - RECOGNISED: commandName() != nullptr. Unknown opcodes fall to the switch - // default's "Unknown command" error, so they are not activity. Reusing the - // existing recognition predicate avoids a second, drift-prone one. An - // earlier draft stamped at RX intake instead, where bleRxQueuePush accepts - // ANY non-empty payload within the size cap -- so a two-byte malformed frame - // counted as activity and a garbage flooder held the slot indefinitely. - // - // - FROM THE OWNER: the frame's instance identity (g_commandInstance, set by - // the transport from the queued frame's tag or the LAN owner's identity) - // still equals the live owner word. NOT a transport comparison: transport - // alone cannot tell a delayed frame from a dead BLE instance apart from the - // new BLE owner, and such a frame would stamp the new owner's clock. - // - // - NOT A HANDSHAKE/DISCOVERY OPCODE, and ACCEPTED where there is a gate to - // pass. Together these stop a session holding the exclusive slot without - // doing any real work, and they are deliberately split so the rule behaves - // the same whether or not authentication is configured: - // - // * CMD_AUTHENTICATE and CMD_FIRMWARE_VERSION never count, in ANY - // configuration. Both are dispatched before the auth gate (they are the - // handshake), and neither represents work the device is doing for a - // client. Excluding them unconditionally is what makes this work on a - // device with encryption DISABLED -- which is the default - // (encryption_enabled = 0 means "all commands work unauthenticated"), so - // an auth-conditional rule alone would be a no-op on most devices. - // * Where a gate exists, the command must also be past it. With encryption - // on, an unauthenticated peer's other commands are refused, so counting - // them as activity would let a rejected client outlive a working one. - // - // The two realistic pinning triggers are benign, which is why this is not - // filed under abuse: a monitoring integration polling firmware version, and - // a client retrying authentication with stale credentials. Either used to - // refresh the clock forever while every other client was refused the slot. - // - // A handshake still cannot race the clock, which is what the weaker rule was - // protecting: the window runs from ADMISSION, so a client has the full idle - // timeout -- 120 s on BLE, against a handshake of one exchange -- to get - // through it. It simply may not extend that window by retrying. - const bool handshakeOpcode = (command == CMD_AUTHENTICATE || - command == CMD_FIRMWARE_VERSION); - const bool pastAuthGate = !isEncryptionEnabled() || - g_commandOrigin == ORIGIN_LAN_TLS || // TLS is the auth - isAuthenticated(); - if (!handshakeOpcode && pastAuthGate && commandName(command) != nullptr && - linkIsOwnerWord(g_commandInstance)) { - linkStampOwnerCommand(); - } // 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. @@ -645,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; @@ -652,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; @@ -693,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 @@ -765,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 282049b..eac34b6 100644 --- a/src/communication.h +++ b/src/communication.h @@ -44,6 +44,23 @@ uint8_t commandOrigin(void); */ 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/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/main.cpp b/src/main.cpp index a8a1918..3352ae0 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -980,6 +980,12 @@ void loop() { // 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 diff --git a/src/session_guard.cpp b/src/session_guard.cpp index a2c01e4..5f8e8e6 100644 --- a/src/session_guard.cpp +++ b/src/session_guard.cpp @@ -5,6 +5,7 @@ #include "session_guard.h" #include "ble_transport.h" +#include "communication.h" #include "command_queue.h" #include "config_parser.h" #include "display_service.h" @@ -106,6 +107,11 @@ void abortToKnownState(const char* reason, bool dropLink, LinkId ownerId) { // 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.