Skip to content

Commit 6f8e8e8

Browse files
runway-github[bot]abretonc7sclaude
authored
chore(runway): cherry-pick fix(perps): reduce WebSocket subscription overhead and prevent leaks cp-7.63.0 cp-7.64.0 (#25504)
- fix(perps): reduce WebSocket subscription overhead and prevent leaks cp-7.63.0 cp-7.64.0 (#25496) ## **Description** This PR addresses WebSocket subscription issues identified during the rate limiting incident investigation. The fixes reduce subscription message volume by ~75% and prevent subscription leaks. ### Root Causes Identified 1. **Subscriptions to unregistered DEXs** - System subscribed to ALL 8 DEXs from API instead of only the 2 in our allowlist (main + xyz) 2. **Duplicate DEX subscriptions from race conditions** - Concurrent calls created 2× subscriptions per DEX 3. **Candle subscription leaks** - Cleanup failed when component unmounted before async subscription resolved ### Fixes Implemented #### 1. Filter DEXs by Allowlist on Mainnet (HIGH PRIORITY) **Files:** `hyperLiquidConfig.ts`, `HyperLiquidProvider.ts` - Added `MAINNET_HIP3_CONFIG` with `AutoDiscoverAll: false` - DEX filtering is now dynamic - extracts DEX names from the `allowlistMarkets` feature flag patterns - Added `extractDexsFromAllowlist()` method that parses patterns like `xyz:*`, `xyz:TSLA`, or `xyz` - **Impact:** Reduces from 8 DEXs to 2 (main + xyz), ~75% reduction in subscription messages #### 2. Prevent Duplicate DEX Subscriptions (HIGH PRIORITY) **File:** `HyperLiquidSubscriptionService.ts` - Added `pendingClearinghouseSubscriptions` and `pendingOpenOrdersSubscriptions` Maps - Refactored `ensureClearinghouseStateSubscription()` and `ensureOpenOrdersSubscription()` to check for pending promises - Concurrent calls now wait for the pending promise instead of creating duplicate subscriptions - **Impact:** Prevents 50% redundant subscriptions from race conditions #### 3. Fix Candle Subscription Cleanup (HIGH PRIORITY) **File:** `HyperLiquidClientService.ts` - Store subscription promise to enable cleanup even when pending - Updated cleanup function to wait for pending promise and unsubscribe - **Impact:** Prevents WebSocket subscription leaks when component unmounts before subscription resolves ### Test Results | Metric | Before | After | Improvement | |--------|--------|-------|-------------| | DEXs subscribed | 8 | 2 | 75% reduction | | clearinghouseState subscriptions | 16 | 2 | 87% reduction | | openOrders subscriptions | 16 | 2 | 87% reduction | | Candle subscription leaks | Yes | No | Fixed | ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: Rate limiting incident from WebSocket over-subscription Related: [WebSocket Subscription Investigation Report](docs/perps/perps-websocket-subscription-investigation.md) ## **Manual testing steps** ```gherkin Feature: WebSocket subscription optimization Scenario: User connects to perps and only allowlisted DEXs are subscribed Given user has the app installed with perps feature enabled And WebSocket logging is enabled in dev mode When user navigates to Perps home screen Then WebSocket logs show subscriptions only for main and xyz DEXs And no subscriptions for flx, vntl, hyna, km, abcd, cash DEXs Scenario: User navigates between markets without duplicate subscriptions Given user is connected to perps And WebSocket logging is enabled When user navigates from Home to xyz:XYZ100 market details And user returns to Home And user navigates back to xyz:XYZ100 Then WebSocket logs show no duplicate clearinghouseState subscriptions And WebSocket logs show no duplicate openOrders subscriptions Scenario: User views candle chart and subscriptions are properly cleaned up Given user is on a market details screen with chart visible When user quickly navigates away from the screen And user waits for 2 seconds Then candle subscriptions are properly unsubscribed And no orphaned candle subscriptions exist ``` ## **Screenshots/Recordings** ### **Before** WebSocket subscription breakdown (full trading flow): - clearinghouseState: 16 subscriptions (8 DEXs × 2 duplicates) - openOrders: 16 subscriptions (8 DEXs × 2 duplicates) - candle: 4 subscriptions, 0 unsubscriptions (leak) ### **After** WebSocket subscription breakdown (full trading flow): - clearinghouseState: 2 subscriptions (main + xyz only) - openOrders: 2 subscriptions (main + xyz only) - candle: 2 subscriptions, 2 unsubscriptions (balanced) **Total outgoing messages:** 44 **Total incoming messages:** 402 (down from ~750) ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- ### Subscription Breakdown - `clearinghouseState`: 2 (main + xyz) - `openOrders`: 2 (main + xyz) - `userFills`: 1 - `webData3`: 1 - `allMids`: 1 - `assetCtxs`: 2 (xyz only, subscribed/unsubscribed on navigation) - `activeAssetCtx`: 2 (xyz:XYZ100, subscribed/unsubscribed on navigation) - `candle`: 2 (1h + 15m intervals) - `bbo`: 2 (subscribed/unsubscribed during order flow) ### Future Optimization Opportunity Trading operations (order placement, cancellation, modification) currently use **HTTP transport**: - `ExchangeClient` is configured with `httpTransport` to avoid 429 rate limiting - `InfoClient` uses `wsTransport` for info queries (multiplexed over single WS connection) Now that subscription volume is reduced by 75%, we could consider moving `ExchangeClient` to WebSocket transport (see follow-up investigation). <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches core perps WebSocket subscription logic (DEX discovery/filtering and subscription lifecycle), so mistakes could drop market/user updates or leave subscriptions running. Changes are targeted and add guards against race conditions and cleanup failures. > > **Overview** > Reduces HyperLiquid HIP-3 WebSocket load by **filtering mainnet DEX discovery** based on the `allowlistMarkets` patterns, falling back to *main DEX only* when no HIP-3 DEXs are implied. Adds `MAINNET_HIP3_CONFIG` and an allowlist parser (`extractDexsFromAllowlist`) to avoid subscribing to non-allowlisted HIP-3 DEXs. > > Prevents **duplicate per-DEX subscriptions** by deduplicating concurrent `clearinghouseState` and `openOrders` subscription attempts via pending-promise tracking, and ensures these pending entries are cleared during teardown. > > Fixes a **candle subscription leak** by tracking the pending subscription promise and unsubscribing even if cleanup happens before the async subscription resolves. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 9fcf758. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude <noreply@anthropic.com> [7a0c2a1](7a0c2a1) Co-authored-by: abretonc7s <107169956+abretonc7s@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0cf3191 commit 6f8e8e8

4 files changed

Lines changed: 205 additions & 9 deletions

File tree

app/components/UI/Perps/constants/hyperLiquidConfig.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -370,8 +370,6 @@ export const HIP3_ASSET_MARKET_TYPES: Record<
370370
* On testnet, there are many HIP-3 DEXs (test deployments from various builders).
371371
* Subscribing to all of them causes connection/subscription overload and instability.
372372
* This configuration limits which DEXs are discovered and subscribed to on testnet.
373-
*
374-
* On mainnet, full DEX discovery continues unchanged.
375373
*/
376374
export const TESTNET_HIP3_CONFIG = {
377375
/**
@@ -388,6 +386,25 @@ export const TESTNET_HIP3_CONFIG = {
388386
AutoDiscoverAll: false,
389387
} as const;
390388

389+
/**
390+
* Mainnet-specific HIP-3 DEX configuration
391+
*
392+
* On mainnet, DEX filtering is dynamically determined from the allowlist markets
393+
* feature flag. This avoids hardcoding DEX names and ensures consistency with
394+
* the market filtering logic.
395+
*
396+
* When AutoDiscoverAll is false and no allowlist is provided, only the main DEX is used.
397+
* When an allowlist is provided, DEXs are extracted from the allowlist patterns.
398+
*/
399+
export const MAINNET_HIP3_CONFIG = {
400+
/**
401+
* Set to true to enable full HIP-3 discovery on mainnet
402+
* When false, DEXs are filtered based on the allowlist markets feature flag
403+
* (recommended for production to reduce subscription overhead)
404+
*/
405+
AutoDiscoverAll: false,
406+
} as const;
407+
391408
/**
392409
* HIP-3 margin management configuration
393410
* Controls margin buffers and auto-rebalance behavior for HIP-3 DEXes with isolated margin

app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
HIP3_FEE_CONFIG,
1313
HIP3_MARGIN_CONFIG,
1414
HYPERLIQUID_WITHDRAWAL_MINUTES,
15+
MAINNET_HIP3_CONFIG,
1516
REFERRAL_CONFIG,
1617
TESTNET_HIP3_CONFIG,
1718
TRADING_DEFAULTS,
@@ -1025,9 +1026,53 @@ export class HyperLiquidProvider implements PerpsProvider {
10251026
'HyperLiquidProvider: Testnet - AUTO_DISCOVER_ALL enabled, using all DEXs',
10261027
{ totalDexCount: availableHip3Dexs.length + 1 },
10271028
);
1029+
} else {
1030+
// Mainnet-specific filtering: Extract allowed DEXs from the allowlist patterns
1031+
// This reduces WebSocket subscription overhead dynamically based on feature flags
1032+
const { AutoDiscoverAll } = MAINNET_HIP3_CONFIG;
1033+
1034+
if (!AutoDiscoverAll) {
1035+
// Extract unique DEX names from allowlist patterns
1036+
// Patterns like "xyz:*", "xyz:TSLA", or "xyz" all indicate DEX "xyz"
1037+
const allowedDexsFromAllowlist = this.extractDexsFromAllowlist();
1038+
1039+
if (allowedDexsFromAllowlist.length === 0) {
1040+
// No HIP-3 DEXs in allowlist - main DEX only
1041+
this.deps.debugLogger.log(
1042+
'HyperLiquidProvider: Mainnet - using main DEX only (no HIP-3 DEXs in allowlist)',
1043+
{
1044+
availableHip3Dexs: availableHip3Dexs.length,
1045+
allowlistMarkets: this.allowlistMarkets,
1046+
},
1047+
);
1048+
this.cachedValidatedDexs = [null];
1049+
return this.cachedValidatedDexs;
1050+
}
1051+
1052+
// Filter to DEXs that are both available AND in the allowlist
1053+
const filteredDexs = availableHip3Dexs.filter((dex) =>
1054+
allowedDexsFromAllowlist.includes(dex),
1055+
);
1056+
this.deps.debugLogger.log(
1057+
'HyperLiquidProvider: Mainnet - filtered to allowlist DEXs',
1058+
{
1059+
allowedDexsFromAllowlist,
1060+
filteredDexs,
1061+
availableHip3Dexs: availableHip3Dexs.length,
1062+
},
1063+
);
1064+
this.cachedValidatedDexs = [null, ...filteredDexs];
1065+
return this.cachedValidatedDexs;
1066+
}
1067+
1068+
// AUTO_DISCOVER_ALL is true - proceed with all DEXs
1069+
this.deps.debugLogger.log(
1070+
'HyperLiquidProvider: Mainnet - AUTO_DISCOVER_ALL enabled, using all DEXs',
1071+
{ totalDexCount: availableHip3Dexs.length + 1 },
1072+
);
10281073
}
10291074

1030-
// Mainnet (or testnet with AUTO_DISCOVER_ALL): Return all DEXs
1075+
// Fallback: Return all DEXs (when AUTO_DISCOVER_ALL is true)
10311076
// Market filtering is applied at subscription data layer
10321077
this.deps.debugLogger.log(
10331078
'HyperLiquidProvider: All DEXs enabled (market filtering at data layer)',
@@ -1041,6 +1086,41 @@ export class HyperLiquidProvider implements PerpsProvider {
10411086
return this.cachedValidatedDexs;
10421087
}
10431088

1089+
/**
1090+
* Extract unique DEX names from allowlist market patterns
1091+
* Patterns can be: "xyz:*" (wildcard), "xyz:TSLA" (exact), or "xyz" (DEX shorthand)
1092+
*
1093+
* @returns Array of unique DEX names from the allowlist
1094+
*/
1095+
private extractDexsFromAllowlist(): string[] {
1096+
if (this.allowlistMarkets.length === 0) {
1097+
return [];
1098+
}
1099+
1100+
const dexNames = new Set<string>();
1101+
1102+
for (const pattern of this.allowlistMarkets) {
1103+
// Pattern formats:
1104+
// - "xyz:*" -> DEX "xyz" (wildcard)
1105+
// - "xyz:TSLA" -> DEX "xyz" (exact match)
1106+
// - "xyz" -> DEX "xyz" (shorthand)
1107+
const colonIndex = pattern.indexOf(':');
1108+
if (colonIndex > 0) {
1109+
// Has colon - extract DEX prefix
1110+
const dex = pattern.substring(0, colonIndex);
1111+
dexNames.add(dex);
1112+
} else if (pattern.length > 0 && !pattern.includes('*')) {
1113+
// No colon and not a wildcard - could be DEX shorthand
1114+
// Only add if it looks like a valid DEX name (lowercase alphanumeric)
1115+
if (/^[a-z][a-z0-9]*$/i.test(pattern)) {
1116+
dexNames.add(pattern.toLowerCase());
1117+
}
1118+
}
1119+
}
1120+
1121+
return Array.from(dexNames);
1122+
}
1123+
10441124
/**
10451125
* Get cached meta response for a DEX, fetching from API if not cached
10461126
* This helper consolidates cache logic to avoid redundant API calls across the provider

app/components/UI/Perps/services/HyperLiquidClientService.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,9 @@ export class HyperLiquidClientService {
526526
let currentCandleData: CandleData | null = null;
527527
let wsUnsubscribe: (() => void) | null = null;
528528
let isUnsubscribed = false;
529+
// Store the subscription promise to enable cleanup even when pending
530+
// This fixes a race condition where component unmounts before subscription resolves
531+
let subscriptionPromise: Promise<{ unsubscribe: () => void }> | null = null;
529532

530533
// Calculate initial fetch size dynamically based on duration and interval
531534
// Match main branch behavior: up to 500 candles initially
@@ -548,7 +551,8 @@ export class HyperLiquidClientService {
548551

549552
// 2. Subscribe to WebSocket for new candles
550553
// HyperLiquid SDK uses 'coin' terminology
551-
const subscription = subscriptionClient.candle(
554+
// Store the promise so cleanup can wait for it if needed
555+
subscriptionPromise = subscriptionClient.candle(
552556
{ coin: symbol, interval }, // Map to HyperLiquid SDK's 'coin' parameter
553557
(candleEvent) => {
554558
// Don't process events if already unsubscribed
@@ -598,12 +602,12 @@ export class HyperLiquidClientService {
598602
},
599603
);
600604

601-
// Store cleanup function
602-
subscription
605+
// Store cleanup function when subscription resolves
606+
subscriptionPromise
603607
.then((sub) => {
604608
wsUnsubscribe = () => sub.unsubscribe();
605609
// If already unsubscribed while waiting, clean up immediately
606-
if (isUnsubscribed && wsUnsubscribe) {
610+
if (isUnsubscribed) {
607611
wsUnsubscribe();
608612
wsUnsubscribe = null;
609613
}
@@ -663,8 +667,19 @@ export class HyperLiquidClientService {
663667
return () => {
664668
isUnsubscribed = true;
665669
if (wsUnsubscribe) {
670+
// Subscription already resolved - unsubscribe directly
666671
wsUnsubscribe();
667672
wsUnsubscribe = null;
673+
} else if (subscriptionPromise) {
674+
// Subscription promise still pending - wait for it and clean up
675+
// This prevents WebSocket subscription leaks when component unmounts
676+
// before the subscription promise resolves
677+
subscriptionPromise
678+
.then((sub) => sub.unsubscribe())
679+
.catch(() => {
680+
// Ignore errors during cleanup - subscription may have failed
681+
});
682+
subscriptionPromise = null;
668683
}
669684
};
670685
}

app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,18 @@ export class HyperLiquidSubscriptionService {
158158
>(); // Key: dex name ('' for main)
159159
private readonly openOrdersSubscriptions = new Map<string, ISubscription>(); // Key: dex name ('' for main)
160160

161+
// Pending subscription promises to prevent race conditions
162+
// When multiple calls to ensure*Subscription happen concurrently, this ensures
163+
// only one subscription is created per DEX (others wait for the pending promise)
164+
private readonly pendingClearinghouseSubscriptions = new Map<
165+
string,
166+
Promise<void>
167+
>();
168+
private readonly pendingOpenOrdersSubscriptions = new Map<
169+
string,
170+
Promise<void>
171+
>();
172+
161173
// Meta cache per DEX - populated by metaAndAssetCtxs, used by createAssetCtxsSubscription
162174
// This avoids redundant meta() API calls since metaAndAssetCtxs already returns meta data
163175
private readonly dexMetaCache = new Map<
@@ -1258,15 +1270,49 @@ export class HyperLiquidSubscriptionService {
12581270

12591271
/**
12601272
* Ensure clearinghouseState subscription exists for a DEX
1273+
* Uses pending promise tracking to prevent race conditions where multiple
1274+
* concurrent calls could create duplicate subscriptions
12611275
*/
12621276
private async ensureClearinghouseStateSubscription(
12631277
userAddress: string,
12641278
dexName: string,
12651279
): Promise<void> {
1280+
// Already subscribed
12661281
if (this.clearinghouseStateSubscriptions.has(dexName)) {
1267-
return; // Already subscribed
1282+
return;
1283+
}
1284+
1285+
// Another call is already in progress - wait for it instead of creating duplicate
1286+
const pending = this.pendingClearinghouseSubscriptions.get(dexName);
1287+
if (pending) {
1288+
this.deps.debugLogger.log(
1289+
`[ensureClearinghouseStateSubscription] Waiting for pending subscription for DEX: ${dexName || 'main'}`,
1290+
);
1291+
return pending;
12681292
}
12691293

1294+
// Create subscription promise and track it
1295+
const subscriptionPromise = this.createClearinghouseSubscription(
1296+
userAddress,
1297+
dexName,
1298+
);
1299+
this.pendingClearinghouseSubscriptions.set(dexName, subscriptionPromise);
1300+
1301+
try {
1302+
await subscriptionPromise;
1303+
} finally {
1304+
this.pendingClearinghouseSubscriptions.delete(dexName);
1305+
}
1306+
}
1307+
1308+
/**
1309+
* Create the actual clearinghouseState subscription
1310+
* Separated from ensureClearinghouseStateSubscription to enable promise deduplication
1311+
*/
1312+
private async createClearinghouseSubscription(
1313+
userAddress: string,
1314+
dexName: string,
1315+
): Promise<void> {
12701316
const subscriptionClient = this.clientService.getSubscriptionClient();
12711317
if (!subscriptionClient) {
12721318
throw new Error('Subscription client not available');
@@ -1351,15 +1397,49 @@ export class HyperLiquidSubscriptionService {
13511397

13521398
/**
13531399
* Ensure openOrders subscription exists for a DEX
1400+
* Uses pending promise tracking to prevent race conditions where multiple
1401+
* concurrent calls could create duplicate subscriptions
13541402
*/
13551403
private async ensureOpenOrdersSubscription(
13561404
userAddress: string,
13571405
dexName: string,
13581406
): Promise<void> {
1407+
// Already subscribed
13591408
if (this.openOrdersSubscriptions.has(dexName)) {
1360-
return; // Already subscribed
1409+
return;
1410+
}
1411+
1412+
// Another call is already in progress - wait for it instead of creating duplicate
1413+
const pending = this.pendingOpenOrdersSubscriptions.get(dexName);
1414+
if (pending) {
1415+
this.deps.debugLogger.log(
1416+
`[ensureOpenOrdersSubscription] Waiting for pending subscription for DEX: ${dexName || 'main'}`,
1417+
);
1418+
return pending;
1419+
}
1420+
1421+
// Create subscription promise and track it
1422+
const subscriptionPromise = this.createOpenOrdersSubscription(
1423+
userAddress,
1424+
dexName,
1425+
);
1426+
this.pendingOpenOrdersSubscriptions.set(dexName, subscriptionPromise);
1427+
1428+
try {
1429+
await subscriptionPromise;
1430+
} finally {
1431+
this.pendingOpenOrdersSubscriptions.delete(dexName);
13611432
}
1433+
}
13621434

1435+
/**
1436+
* Create the actual openOrders subscription
1437+
* Separated from ensureOpenOrdersSubscription to enable promise deduplication
1438+
*/
1439+
private async createOpenOrdersSubscription(
1440+
userAddress: string,
1441+
dexName: string,
1442+
): Promise<void> {
13631443
const subscriptionClient = this.clientService.getSubscriptionClient();
13641444
if (!subscriptionClient) {
13651445
throw new Error('Subscription client not available');
@@ -1569,6 +1649,10 @@ export class HyperLiquidSubscriptionService {
15691649
this.openOrdersSubscriptions.clear();
15701650
}
15711651

1652+
// Clear pending subscription promises (race condition prevention)
1653+
this.pendingClearinghouseSubscriptions.clear();
1654+
this.pendingOpenOrdersSubscriptions.clear();
1655+
15721656
// Clear subscriber counts
15731657
this.positionSubscriberCount = 0;
15741658
this.orderSubscriberCount = 0;

0 commit comments

Comments
 (0)