Skip to content

docs: move obsolete/stale plans and findings out of docs/ - #138

Open
davelee98 wants to merge 11 commits into
OpenDisplay:mainfrom
davelee98:chore/prune-obsolete-docs
Open

docs: move obsolete/stale plans and findings out of docs/#138
davelee98 wants to merge 11 commits into
OpenDisplay:mainfrom
davelee98:chore/prune-obsolete-docs

Conversation

@davelee98

Copy link
Copy Markdown
Contributor

Summary

  • Removes 10 stale docs/ files whose plans have already shipped, been superseded, or drifted out of sync with the code: PLAN_BLE_TRANSPORT_ABSTRACTION, TEST_PLAN_UNIFY_NRF_ESP32, PLAN_EPD_KEEPALIVE_CONFIG, PLAN_NONBLOCKING_LOG, PLAN_TINFL_INFLATE_SWAP, PLAN_UNIFY_NRF_ESP32_LOOP_BLE, PLAN_WAKE_ON_BUTTON, PLAN_WORK_GATE_TRANSFER_TERMS, both FINDINGS_* notes, and DESIGN_COOPERATIVE_REFRESH_WAIT.
  • Files were moved to ../old_docs (outside this repo) rather than deleted outright, so history/context isn't lost.
  • PLAN_FREEZE_HARDENING_2026-07-31.md and PLAN_PHASE0_LINK_DROP_SEAM_2026-07-31.md were kept as live/current docs.

Test plan

  • N/A — docs-only change, no code touched.

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.
…frame identity

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.
…refusal races

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.
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.
…ports

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.
…ed command

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.
…onfigured

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.
… 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.
…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.
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

Moves 10 dated docs/ files out of the repo: PLAN_* docs superseded or
already implemented, the two FINDINGS_* notes, and DESIGN_COOPERATIVE_REFRESH_WAIT,
per the workspace convention that only PLAN_FREEZE_HARDENING (2026-07-31) and
later remain as live/reference docs.
@davelee98
davelee98 requested a review from jonasniesner as a code owner August 2, 2026 12:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant