Skip to content

Commit 74d34ca

Browse files
committed
feat(websocket): implement automatic reconnect and subscription replay with enhanced tracking
1 parent 7ba5754 commit 74d34ca

7 files changed

Lines changed: 616 additions & 51 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
---
99

10+
## [0.2.1] - 2026-06-23
11+
12+
### Added
13+
- Add automatic `WebsocketManager` reconnect attempts with bounded exponential backoff after disconnects or transport errors.
14+
- Replay active WebSocket subscriptions after reconnect so `Info::subscribe()` callbacks resume without manual re-subscription.
15+
16+
### Fixed
17+
- Keep WebSocket subscription replay lock-free and synchronized with unsubscribe by tracking per-identifier subscription state, in-flight subscribe/replay sends, and final unsubscribe phases with atomics.
18+
- Avoid stale server-side subscriptions when a reconnect replay overlaps with the last local `unsubscribe()` for a channel.
19+
- Coalesce multiple callbacks on the same channel into one wire subscription until the last callback unsubscribes.
20+
21+
### Tests
22+
- Add offline `SubscriptionTracking` tests covering subscribe while disconnected, callback routing after unsubscribe, concurrent subscribe/unsubscribe, wire subscription coalescing, and unsubscribe-before-resubscribe ordering using a local in-process WebSocket server.
23+
1024
## [0.2.0] -2026-06-19
1125

1226
### Added

CMakeLists.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
cmake_minimum_required(VERSION 3.21)
2-
project(hyperliquid-cpp VERSION 0.2.0 LANGUAGES CXX)
2+
project(hyperliquid-cpp VERSION 0.2.1 LANGUAGES CXX)
33

44
set(CMAKE_CXX_STANDARD 20)
55
set(CMAKE_CXX_STANDARD_REQUIRED ON)

README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ All authenticated actions are signed locally using EIP-712 / secp256k1; your pri
2525

2626
- Read-only market data (`Info`): universe metadata, mid prices, order books, candles, funding history, account state
2727
- Authenticated trading (`Exchange`): limit/market/trigger orders, bulk ops, cancel, modify, leverage, margin, USD/spot transfers, bridge withdrawal
28-
- Real-time WebSocket subscriptions via `Info::subscribe` with multi-callback fan-out per channel
28+
- Real-time WebSocket subscriptions via `Info::subscribe` with multi-callback fan-out, automatic reconnect, and active-subscription replay
2929
- Optional caller-thread WebSocket dispatch via `Info::dispatch()` for applications that own their event loop
3030
- Optional shared-memory WebSocket buffers backed by `slick::stream_buffer_multiplexer`
3131
- Native EIP-712 signing for both L1 actions (orders, leverage) and user-signed actions (transfers) using OpenSSL secp256k1
@@ -191,8 +191,10 @@ info->unsubscribe({{"type", "l2Book"}, {"coin", "ETH"}}, sid);
191191
info->unsubscribe({{"type", "l2Book"}, {"coin", "ETH"}}, sid2);
192192
```
193193
194-
The WebSocket manager sends periodic pings to keep connections alive and uses
195-
bounded atomic shutdown checks so teardown does not wait for the full ping interval.
194+
The WebSocket manager sends periodic pings, reconnects with bounded exponential
195+
backoff after disconnects or transport errors, and replays active subscriptions
196+
after reconnect. Multiple callbacks on the same channel share one server-side
197+
subscription until the last callback unsubscribes.
196198
197199
For applications that need callbacks on their own thread, enable caller-thread dispatch
198200
and poll queued WebSocket records:
@@ -507,7 +509,7 @@ cmake --build build --config Debug --target hyperliquid_tests
507509
ctest --test-dir build -C Debug -R hyperliquid_tests -V
508510
```
509511

510-
Covers: Keccak-256 vectors, EIP-712 signing round-trips, type serialisation (`float_to_wire`, `Cloid`, `Tif`), WebSocket URL conversion, channel identifier generation, caller-thread WebSocket dispatch routing, and shared-memory stream-buffer attachment.
512+
Covers: Keccak-256 vectors, EIP-712 signing round-trips, type serialisation (`float_to_wire`, `Cloid`, `Tif`), WebSocket URL conversion, channel identifier generation, caller-thread WebSocket dispatch routing, lock-free reconnect subscription tracking, and shared-memory stream-buffer attachment.
511513

512514
### Integration tests (requires testnet)
513515

include/hyperliquid/websocket_manager.hpp

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#pragma once
22

33
#include <atomic>
4+
#include <cstdint>
45
#include <functional>
56
#include <limits>
67
#include <memory>
@@ -101,6 +102,9 @@ class WebsocketManager {
101102
bool dispatch(uint32_t producer_id, const char* data, std::size_t length);
102103

103104
private:
105+
struct SubscriptionState;
106+
using SubscriptionStatePtr = std::shared_ptr<SubscriptionState>;
107+
104108
void init(
105109
uint32_t read_buffer_size,
106110
uint32_t read_control_size,
@@ -112,33 +116,73 @@ class WebsocketManager {
112116
void on_disconnected();
113117
void on_message(const char* data, std::size_t len);
114118
void on_error(std::string err);
119+
void resubscribe_all(); // re-sends all subscribe messages; called from on_connected()
120+
void send_subscribe_if_active(const SubscriptionStatePtr& state);
121+
void activate_subscription(const SubscriptionStatePtr& state);
122+
void deactivate_subscription(const SubscriptionStatePtr& state,
123+
const nlohmann::json& subscription);
124+
SubscriptionStatePtr subscription_state_for(
125+
const std::string& identifier,
126+
const nlohmann::json& subscription);
115127

116-
void ping_loop(); // runs in ping_thread_
128+
void ping_loop(); // runs in ping_thread_; also manages reconnection
117129

118130
std::string ws_url_;
119131
std::unique_ptr<Websocket> ws_;
120132

121133
std::unique_ptr<slick::stream_buffer_multiplexer> owning_mux_;
122134
slick::stream_buffer_multiplexer &mux_;
123135

136+
enum class SubscriptionPhase : std::uint8_t {
137+
Inactive,
138+
Active,
139+
Unsubscribing,
140+
};
141+
142+
// Per-identifier state is shared by all callbacks for that subscription.
143+
// It serializes reconnect replay and final unsubscribe with atomics only:
144+
// final unsubscribe waits for in-flight subscribe/replay sends before
145+
// sending the server-side unsubscribe.
146+
struct SubscriptionState {
147+
std::shared_ptr<const nlohmann::json> subscription;
148+
std::atomic<unsigned int> handler_count{0};
149+
std::atomic<unsigned int> send_in_flight{0};
150+
std::atomic<SubscriptionPhase> phase{SubscriptionPhase::Inactive};
151+
152+
explicit SubscriptionState(const nlohmann::json& sub)
153+
: subscription(std::make_shared<const nlohmann::json>(sub)) {}
154+
};
155+
156+
using SubStateMap = std::unordered_map<std::string, SubscriptionStatePtr>;
157+
124158
// Handler snapshots are published with copy-on-write. Each snapshot holds
125159
// shared per-callback state so unsubscribe() can deactivate exactly one
126160
// callback and wait for only that callback's in-flight invocation to drain.
127161
struct Handler {
128162
int subscription_id;
129163
std::function<void(const nlohmann::json&)> callback;
164+
SubscriptionStatePtr subscription_state;
130165
std::atomic<bool> active{true};
131166
std::atomic<unsigned int> in_flight{0};
132167

133-
Handler(int id, std::function<void(const nlohmann::json&)> cb)
134-
: subscription_id(id), callback(std::move(cb)) {}
168+
Handler(int id,
169+
std::function<void(const nlohmann::json&)> cb,
170+
SubscriptionStatePtr state)
171+
: subscription_id(id)
172+
, callback(std::move(cb))
173+
, subscription_state(std::move(state)) {}
135174
};
136175

137176
using HandlerPtr = std::shared_ptr<Handler>;
138177
using HandlerMap = std::unordered_map<std::string, std::vector<HandlerPtr>>;
139178

140179
std::shared_ptr<const HandlerMap> handlers_;
141180

181+
// Subscription states keyed by identifier, used to re-subscribe after reconnect.
182+
// States may remain as inactive tombstones so a new subscribe can safely
183+
// synchronize with a concurrent final unsubscribe for the same identifier.
184+
std::shared_ptr<const SubStateMap> sub_state_map_{std::make_shared<SubStateMap>()};
185+
142186
std::atomic<bool> connected_{false};
143187
std::atomic<bool> running_{true};
144188
std::atomic<int> next_id_{1};

0 commit comments

Comments
 (0)