Skip to content

Add WebSocket health monitoring and reconnection#133

Merged
kwsantiago merged 11 commits into
mainfrom
WebSocket-health
Jul 10, 2026
Merged

Add WebSocket health monitoring and reconnection#133
kwsantiago merged 11 commits into
mainfrom
WebSocket-health

Conversation

@wksantiago

@wksantiago wksantiago commented Jan 23, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds connection health monitoring, automatic reconnection with exponential backoff, event buffering with replay, and session-recovery timeouts to the FROST relay coordinator.

Closes #84.

Design

All esp_websocket_client contact now lives behind a small transport seam (main/ws_transport.h), implemented by ws_transport_esp.c on device and by a mock in native tests. frost_coordinator.c no longer references the websocket client directly.

Keepalive is delegated to the transport. esp_websocket_client already sends pings on ping_interval_sec and drops the connection when no pong arrives within pingpong_timeout_sec. That drop surfaces as a disconnect event, so there is no hand-rolled ping loop or missed-pong counter. Its built-in retry is disabled (disable_auto_reconnect = true) because it offers neither exponential backoff nor a max-attempt cap; the coordinator owns reconnection instead.

Locking. Two mutexes with one hard invariant:

  • state_mutex guards all coordinator and relay state, held only for short non-blocking critical sections.
  • op_mutex serializes transport lifecycle and sends, so a handle can never be destroyed while another caller is sending on it.
  • No ws_transport_* call is ever made while state_mutex is held. esp_websocket_client_stop() blocks until the websocket task drains, and that task takes state_mutex in its event callback. Holding it across a stop would deadlock permanently. The native mock asserts this at every transport entry point, so the invariant cannot regress silently.

Reconnect waits are 1s, 2s, 4s, 8s, 16s (capped at 30s) over at most 5 attempts. Events published while no relay is reachable are buffered (8 slots, oldest evicted) and replayed in order once a relay returns; a replay that fails stops so later events cannot overtake it. If nothing reconnects within 60s the session fails cleanly. Relay health, connection counts, and reconnect attempts are exposed via frost_coordinator_get_status().

Notable fixes over the first revision

  • Reconnect called stop/destroy while holding the coordinator mutex, deadlocking against the websocket task's own event callback.
  • publish_event, subscribe, and unsubscribe read relay->ws and sent on it without the lock, racing poll()'s teardown (use-after-free); disconnect() could double-free.
  • The coordinator's manual reconnect fought the client's auto_reconnect, which defaults to on.
  • A relay that exhausted its attempts leaked its transport handle.
  • Both ERROR and DISCONNECTED fire per failure, double-counting fail_count in relay health scores.
  • Failed replays and failed (re)subscribes were dropped silently instead of being retried.
  • Events from a relay were dispatched without checking they belonged to our subscription.
  • The rx buffer was smaller than the largest frame we ourselves publish, so a peer's max-size sign request arrived fragmented and was discarded. Now enforced by a compile-time assertion.
  • Session-recovery timer was armed on disconnect_time != 0, which never fires for a disconnect at uptime 0.
  • wss:// is now required; plaintext ws:// is rejected.

Test plan

New test_coordinator (23 tests) drives the real coordinator state machine against a mock transport, covering each acceptance criterion in #84 plus every fix above: backoff schedule and cap, max attempts, handle lifecycle, buffering/replay ordering, resubscribe-on-reconnect, subscription-ID filtering, and failure accounting.

New test_coordinator_race runs a publisher, a poller, and a transport thread concurrently under ThreadSanitizer. Removing op_mutex makes it report a use-after-free; with it, the run is clean.

Each fix was mutation-tested: the bug was reintroduced one at a time and the corresponding test confirmed to fail. That exercise found two assertions that could not fail and one real bug the suite had missed.

  • just test — all suites pass, including the TSan race test
  • AddressSanitizer + UndefinedBehaviorSanitizer + LeakSanitizer — clean
  • cppcheck, clang-tidy, scan-build, clang-format — clean
  • idf.py build for esp32s3 — clean, no warnings

Known limitation

The firmware has no WiFi stack and nothing calls the coordinator, so this module cannot open a socket on device today; the linker garbage-collects it and keep.bin is byte-identical with and without this change. It is therefore verified by native tests and a real cross-compile, not on hardware. Wiring it up (WiFi provisioning, a coordinator task, RPC to drive it) is tracked separately.

@coderabbitai

coderabbitai Bot commented Jan 23, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The Frost coordinator now manages concurrent WebSocket relays with health tracking, reconnect backoff, session recovery, buffered event replay, subscription validation, relay scoring, public status APIs, and native tests for lifecycle and race safety.

Changes

Coordinator relay recovery

Layer / File(s) Summary
WebSocket transport contract and ESP implementation
main/ws_transport.h, main/ws_transport_esp.c, main/CMakeLists.txt
Adds the transport API, ESP WebSocket event mapping, text-frame filtering, lifecycle operations, timed sends, and component compilation wiring.
Coordinator state and relay lifecycle
main/frost_coordinator.h, main/frost_coordinator.c
Adds relay health/reconnect state, locking, URL and subscription validation, reconnect scheduling, subscription handling, status APIs, and session-aware polling.
Buffered publishing and recovery
main/frost_coordinator.c
Buffers events during relay outages, replays them in order after reconnection, requeues failed sends, and resubscribes relays when required.
Native mocks and concurrency validation
test/native/*, Justfile
Adds deterministic cryptographic and event stubs, a concurrency-safe transport mock, coordinator recovery tests, race tests, CMake targets, and test recipe integration.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

Sequence Diagram(s)

sequenceDiagram
    participant App as Application
    participant Coord as frost_coordinator
    participant Buffer as Event buffer
    participant Relay as WebSocket relay

    App->>Coord: Publish serialized event
    Coord->>Relay: Send to connected relays
    Relay-->>Coord: Connection or data event
    Coord->>Buffer: Buffer when relays are reconnecting
    Coord->>Relay: Reconnect and resubscribe
    Coord->>Relay: Replay buffered events in order
Loading

Suggested reviewers: kwsantiago

Poem

A rabbit guards the relay gate,
Buffers hops while links await,
Reconnects beneath the moon,
Replays every message soon,
And keeps the WebSocket safe and bright.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.53% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the primary change: adding WebSocket health monitoring and automatic reconnection.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch WebSocket-health

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@wksantiago
wksantiago requested a review from kwsantiago January 23, 2026 13:56
@wksantiago wksantiago self-assigned this Jan 23, 2026
@wksantiago wksantiago linked an issue Jan 23, 2026 that may be closed by this pull request
5 tasks

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
main/frost_coordinator.c (1)

678-720: Guard event buffer mutations with the mutex.

buffer_event is called without COORDINATOR_LOCK, but replay_buffered_events/clear_event_buffer mutate the same buffer under lock. This can race and corrupt/free the buffer.

🔒 Suggested fix
     if (published == 0 && any_reconnecting) {
+        COORDINATOR_LOCK();
         buffer_event(msg);
+        COORDINATOR_UNLOCK();
         ESP_LOGI(TAG, "Buffered event during reconnection");
     }
🤖 Fix all issues with AI agents
In `@main/frost_coordinator.c`:
- Around line 494-513: The code currently truncates overlong relay URLs in
frost_coordinator_add_relay by using strncpy, which can silently connect to the
wrong endpoint; update frost_coordinator_add_relay to first check the length of
url against RELAY_URL_LEN (e.g. if (strlen(url) >= RELAY_URL_LEN) return an
error like -4), log an error via ESP_LOGE mentioning the URL is too long, and
only then copy the string safely into relay->url (or use a bounded copy after
the length check); reference validate_websocket_url,
frost_coordinator_add_relay, relay_connection_t, and RELAY_URL_LEN so the length
check and failure path are added before the current memset/strncpy block.
- Around line 601-613: The validate_subscription_id function allows IDs of
length WS_MAX_SUBSCRIPTION_ID due to an off-by-one check (if (len >
WS_MAX_SUBSCRIPTION_ID)), which then get truncated by copy_subscription_id;
change the length check to reject IDs with length >= WS_MAX_SUBSCRIPTION_ID so
valid IDs cannot exceed the buffer (update the condition in
validate_subscription_id to use >=), and keep the existing checks for control
chars and quotes; reference validate_subscription_id and copy_subscription_id
and the WS_MAX_SUBSCRIPTION_ID constant when making the change.
🧹 Nitpick comments (1)
main/frost_coordinator.c (1)

790-865: Avoid holding the mutex while performing WS I/O.

send_ping and replay_buffered_events perform esp_websocket_client_send_* while COORDINATOR_LOCK is held, which can block and starve other operations. Consider copying the needed state under lock, then sending outside the lock.

Comment thread main/frost_coordinator.c Outdated
Comment thread main/frost_coordinator.c Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
main/frost_coordinator.c (2)

621-655: Thread safety: frost_coordinator_subscribe modifies shared state without lock.

This function modifies g_ctx.current_subscription and g_ctx.has_subscription (lines 629-631) without holding the mutex. These fields are read by handle_ws_connected under lock (lines 236-240), creating a potential data race.

🔒 Suggested fix: protect shared state modification
 int frost_coordinator_subscribe(const char *subscription_id) {
     if (!g_initialized || !g_ctx.has_group)
         return -1;
     if (!validate_subscription_id(subscription_id)) {
         ESP_LOGE(TAG, "Invalid subscription ID");
         return -2;
     }

+    COORDINATOR_LOCK();
     copy_subscription_id(g_ctx.current_subscription, sizeof(g_ctx.current_subscription),
                          subscription_id);
     g_ctx.has_subscription = true;
+    COORDINATOR_UNLOCK();

     char pubkey_hex[65];
     ...

657-682: Thread safety: frost_coordinator_unsubscribe modifies shared state without lock.

Similar to frost_coordinator_subscribe, this function modifies g_ctx.has_subscription and g_ctx.current_subscription without holding the mutex.

🤖 Fix all issues with AI agents
In `@main/frost_coordinator.c`:
- Around line 308-322: handle_ws_error may double-increment relay->fail_count
because it increments on the max-attempts path and also calls
save_reconnect_state (which itself increments fail_count) on the other path;
change the control so fail_count is incremented exactly once by either moving
the increment into save_reconnect_state only, or by adding a flag/parameter to
save_reconnect_state (e.g., save_reconnect_state(relay, is_final)) and call it
with is_final=false on transient path and true on final path, or skip calling
save_reconnect_state when attempt_count >= WS_RECONNECT_MAX_ATTEMPTS; update
handle_ws_error and save_reconnect_state accordingly and ensure
relay->reconnect.attempt_count/WS_RECONNECT_MAX_ATTEMPTS logic remains
consistent.
- Around line 97-115: buffer_event currently mutates shared state
(g_ctx.event_buffer, buffer_head, buffer_count) without synchronization; ensure
these mutations occur under COORDINATOR_LOCK by acquiring the same lock used in
frost_coordinator_poll before calling buffer_event from publish_event (or
alternatively, add lock/unlock inside buffer_event itself). Update publish_event
to lock COORDINATOR_LOCK around the call to buffer_event (and any other accesses
to g_ctx.event_buffer) so replay_buffered_events (which runs under the same
lock) cannot race, referencing the buffer_event, publish_event,
replay_buffered_events, COORDINATOR_LOCK, and frost_coordinator_poll symbols
when making the change.
- Around line 117-126: clear_event_buffer is being invoked from
frost_coordinator_disconnect without holding the context mutex, risking races
with concurrent event-handler callbacks; modify frost_coordinator_disconnect to
acquire the same mutex used to guard g_ctx.event_buffer (e.g., lock g_ctx.mutex
or g_ctx.event_mutex) before calling clear_event_buffer and release it after the
call, ensuring all buffer mutations are protected; alternatively, if
clear_event_buffer is called from multiple call sites where locking is not
guaranteed, add locking inside clear_event_buffer itself and document that it
performs its own locking to avoid double-locks.
🧹 Nitpick comments (1)
main/frost_coordinator.c (1)

92-95: Potential integer overflow before the cap is applied.

WS_RECONNECT_BASE_MS << attempt can overflow before the comparison with WS_RECONNECT_MAX_MS if attempt is large. For example, shifting a 32-bit value left by 22+ positions causes undefined behavior in C.

Consider capping early or using multiplication with overflow check:

♻️ Suggested fix
 static uint32_t calculate_backoff(uint8_t attempt) {
+    if (attempt > 20) {
+        return WS_RECONNECT_MAX_MS;
+    }
     uint32_t delay = WS_RECONNECT_BASE_MS << attempt;
     return delay > WS_RECONNECT_MAX_MS ? WS_RECONNECT_MAX_MS : delay;
 }

Comment thread main/frost_coordinator.c Outdated
Comment thread main/frost_coordinator.c Outdated
Comment thread main/frost_coordinator.c Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
main/frost_coordinator.c (1)

627-697: Make subscription state updates thread-safe.

frost_coordinator_subscribe/unsubscribe update g_ctx.current_subscription, g_ctx.has_subscription, and g_ctx.state without COORDINATOR_LOCK. With reconnect paths invoking subscribe from the WS event thread, this becomes a data race. Lock these state mutations (and consider snapshotting relay targets if you need to avoid holding the lock during sends).

🔒 Proposed fix
-    copy_subscription_id(g_ctx.current_subscription, sizeof(g_ctx.current_subscription),
-                         subscription_id);
-    g_ctx.has_subscription = true;
+    COORDINATOR_LOCK();
+    copy_subscription_id(g_ctx.current_subscription, sizeof(g_ctx.current_subscription),
+                         subscription_id);
+    g_ctx.has_subscription = true;
+    COORDINATOR_UNLOCK();
@@
-    g_ctx.state = COORDINATOR_STATE_ACTIVE;
+    COORDINATOR_LOCK();
+    g_ctx.state = COORDINATOR_STATE_ACTIVE;
+    COORDINATOR_UNLOCK();
@@
-    g_ctx.has_subscription = false;
-    memset(g_ctx.current_subscription, 0, sizeof(g_ctx.current_subscription));
+    COORDINATOR_LOCK();
+    g_ctx.has_subscription = false;
+    memset(g_ctx.current_subscription, 0, sizeof(g_ctx.current_subscription));
+    COORDINATOR_UNLOCK();
♻️ Duplicate comments (1)
main/frost_coordinator.c (1)

603-611: Guard disconnect state resets with the mutex.

frost_coordinator_disconnect mutates shared coordinator state without COORDINATOR_LOCK, while other paths read it under lock. This can race with WS callbacks or poll().

🔒 Proposed fix
-    clear_event_buffer();
-    g_ctx.has_subscription = false;
-    g_ctx.disconnect_time = 0;
-    g_ctx.state = COORDINATOR_STATE_IDLE;
+    COORDINATOR_LOCK();
+    clear_event_buffer_unlocked();
+    g_ctx.has_subscription = false;
+    g_ctx.disconnect_time = 0;
+    g_ctx.state = COORDINATOR_STATE_IDLE;
+    COORDINATOR_UNLOCK();
🧹 Nitpick comments (2)
main/frost_coordinator.c (2)

708-745: Consider buffering when any relay is reconnecting.

Right now buffering happens only when published == 0, so reconnecting relays miss events if at least one relay is connected. If the intent is replay for any disconnected relay, consider buffering whenever any_reconnecting is true (and dedupe as needed).


816-890: Reduce time under COORDINATOR_LOCK in the poll path.

frost_coordinator_poll holds the mutex while invoking ping/reconnect/replay helpers. Consider snapshotting work under the lock, then performing I/O outside it to reduce contention.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (1)
test/native/mocks/ws_transport_mock.c (1)

191-225: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Make start-failure injection fail ws_transport_start().

ws_mock_fail_next_starts() is consumed by ws_transport_create(), so tests never exercise the coordinator’s distinct start-failure cleanup path. Move this counter check into ws_transport_start() and add a separate create-failure control if both paths are needed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/native/mocks/ws_transport_mock.c` around lines 191 - 225, The
g_fail_starts injection is currently consumed by ws_transport_create(),
preventing tests from exercising ws_transport_start() failure cleanup. Remove
that check from ws_transport_create(), add the counter check in
ws_transport_start() before marking the handle started, and decrement it when
returning failure; introduce a separate create-failure control if creation
failures are still required.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@main/frost_coordinator.c`:
- Around line 126-133: Update op_lock() so the non-ESP_PLATFORM pthread branch
uses a bounded acquisition, such as pthread_mutex_trylock() or a timed lock
based on WS_OP_LOCK_TIMEOUT_MS, instead of blocking indefinitely; preserve the
existing boolean success behavior and ESP_PLATFORM implementation.
- Around line 330-347: Ignore callbacks from detached WebSocket transports by
adding an early return in both handle_ws_connected() and handle_ws_failure()
while holding state_mutex: if relay->ws == NULL, unlock and return before
logging or changing relay state. Ensure the guard precedes all mutations,
including enter_reconnecting().
- Around line 209-216: Ensure failed event delivery is never reported as
successful: in the send/publish logic, buffer whenever published == 0 and (count
> 0 || any_reconnecting), including when all connected sends fail; update
buffer_event_copy() to return a success/failure status, return failure when
malloc fails, and propagate that status through its callers so allocation
failure returns -1 instead of logging successful buffering.
- Around line 681-710: frost_coordinator_subscribe clears resubscription flags
before sending and ignores per-relay failures, leaving failed initial
subscriptions inactive. Update snapshot_connected usage to retain each relay’s
handle and association, send the filter per relay, and restore that relay’s
needs_resubscribe flag when its send fails; preserve successful relays as
subscribed and handle aggregate failures appropriately.
- Around line 997-1007: Replay must stop at the first failed event to preserve
ordering. In the replay loop handling drained events, after send_to_handles
fails and buffer_event_take re-buffers the event, break out of the loop so later
events remain queued rather than overtaking it.
- Around line 301-324: Validate the subscription ID in handle_nostr_message
before dispatching any event: ensure arr[1] is a string, capture a locked
snapshot of g_ctx.has_subscription and g_ctx.current_subscription, and require
the received ID to match that snapshot. Reject and clean up frames when there is
no active subscription or the IDs differ, then call dispatch_frost_event only
for matching events.

---

Nitpick comments:
In `@test/native/mocks/ws_transport_mock.c`:
- Around line 191-225: The g_fail_starts injection is currently consumed by
ws_transport_create(), preventing tests from exercising ws_transport_start()
failure cleanup. Remove that check from ws_transport_create(), add the counter
check in ws_transport_start() before marking the handle started, and decrement
it when returning failure; introduce a separate create-failure control if
creation failures are still required.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 628e79bd-a6e6-45c4-b828-69c97ef77e90

📥 Commits

Reviewing files that changed from the base of the PR and between 759b04b and 13f80aa.

📒 Files selected for processing (14)
  • Justfile
  • main/CMakeLists.txt
  • main/frost_coordinator.c
  • main/frost_coordinator.h
  • main/ws_transport.h
  • main/ws_transport_esp.c
  • test/native/CMakeLists.txt
  • test/native/mocks/noscrypt.h
  • test/native/mocks/nostr_frost_stubs.c
  • test/native/mocks/nostr_frost_stubs.h
  • test/native/mocks/ws_transport_mock.c
  • test/native/mocks/ws_transport_mock.h
  • test/native/test_coordinator.c
  • test/native/test_coordinator_race.c
✅ Files skipped from review due to trivial changes (3)
  • Justfile
  • test/native/mocks/noscrypt.h
  • main/CMakeLists.txt
🚧 Files skipped from review as they are similar to previous changes (1)
  • main/frost_coordinator.h

Comment thread main/frost_coordinator.c Outdated
Comment thread main/frost_coordinator.c Outdated
Comment thread main/frost_coordinator.c Outdated
Comment thread main/frost_coordinator.c
Comment thread main/frost_coordinator.c
Comment thread main/frost_coordinator.c Outdated
@wksantiago
wksantiago requested a review from kwsantiago July 9, 2026 23:47
@kwsantiago
kwsantiago merged commit cd0232a into main Jul 10, 2026
8 checks passed
@kwsantiago
kwsantiago deleted the WebSocket-health branch July 10, 2026 00:07
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.

Add WebSocket health monitoring and reconnection to coordinator

2 participants