fix(perps): ensure SDK clients before provider metadata read - #9865
Merged
abretonc7s merged 2 commits intoAug 13, 2026
Conversation
placeOrder resolves asset info before it ensures trading readiness, so the InfoClient read in #getCachedMeta is the first client touch on the write path. During cold start, or on the first action after a disconnect, that read threw CLIENT_NOT_INITIALIZED and failed the order even once the controller had finished initializing. Ensure the clients on the cache-miss path in #getCachedMeta. A warm cache hit returns before the check, so an initialized provider does no extra work.
abretonc7s
force-pushed
the
fix/perps-ensure-clients-before-meta-read
branch
from
August 13, 2026 12:16
91e7a54 to
32e3a60
Compare
abretonc7s
merged commit Aug 13, 2026
e7be777
into
fix/perps-client-not-initialized-reconnection
39 checks passed
4 tasks
abretonc7s
added a commit
that referenced
this pull request
Aug 13, 2026
## Explanation Addresses the reconnect-readiness finding raised in review on #9032; stacked on that branch. `#handleConnectionDrop` constructs all four SDK clients *before* `await newWsTransport.ready()`. The catch path left them in place, so a reconnect whose readiness rejected kept `isInitialized()` returning `true`. Callers gated on `isInitialized()` / `ensureInitialized()` then issued WebSocket-backed reads (`getInfoClient`, `getSubscriptionClient`) over a socket that never opened, instead of taking the uninitialized path. Writes were unaffected — `ExchangeClient` runs on the HTTP transport. This clears the clients and both transports on failure, mirroring the cleanup `initialize()` already performs, before the retry is scheduled. The retry then rebuilds them from scratch as it already did. ## Scope of the regression Measured with a rejected `ready()` on the reconnect path: | Scenario | `0837703` (before #9032) | branch tip `322eda6` | with this change | | --- | --- | --- | --- | | plain drop after `initialize()` | `isInitialized()` **true** | **true** | **false** | | after `disconnect()` | `isInitialized()` **false** | **true** | **false** | So the post-disconnect row is the behaviour change introduced by recreating all four clients on the reconnect path; the plain-drop row was already wrong before and is fixed here too. ## Test `HyperLiquidClientService.test.ts` gains two cases under the reconnection describe: readiness rejects after a plain `initialize()`, and after a `disconnect()`. Both assert `isInitialized() === false` (the first also asserts `getSubscriptionClient()` is `undefined`). Both fail on the branch without this change (`Received: true`) and pass with it. ## Validation - `jest tests/src/services tests/src/providers` — 34 suites, 1484 passed, 0 failed - `eslint` on both changed files — clean ## References * Stacked on #9032, follows #9865 ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches WebSocket reconnection lifecycle in a critical trading client path; behavior change is narrow (fail-closed on failed ready) but affects when reads proceed after a bad reconnect. > > **Overview** > Fixes a **reconnect** edge case where HyperLiquid SDK clients are built before `WebSocketTransport.ready()` completes. If `ready()` rejects, the catch path used to leave those clients in place, so **`isInitialized()` stayed true** even though the socket never opened—callers gated on initialization could issue WebSocket-backed reads on a dead connection instead of failing closed or retrying. > > On reconnect failure, **`#handleConnectionDrop` now clears all four SDK clients and both transports** (with safe `close()` on the WS transport), matching the cleanup **`initialize()`** already does on failure, before scheduling the existing retry. > > **Tests** cover readiness rejection after a normal `initialize()` and after `disconnect()`; both assert `isInitialized() === false`. Changelog updated under Fixed. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 4df3413. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Explanation
Stacked on #9032 — merge that first, this targets its branch.
#9032 makes trading actions wait for an in-flight
PerpsController.init()instead of failing immediately. Running that branch against HyperLiquid testnet shows the wait works but the action still fails: it now reachesHyperLiquidProvider.placeOrder, which resolves asset info before it ensures trading readiness. That path calls#getCachedMeta→#clientService.getInfoClient()→ensureInitialized(), which throwsCLIENT_NOT_INITIALIZEDwhile the SDK clients are still down.#ensureReadyForTrading()(and with it#ensureClientsInitialized()) only runs later inplaceOrder.So on #9032 alone the caller stops getting a thrown error and starts getting
{ success: false, error: 'CLIENT_NOT_INITIALIZED' }— same failure, quieter.This adds the missing ensure on the cache-miss path of
#getCachedMeta, which is the first client touch on the write path. It is idempotent, and a warm cache hit returns before it, so an initialized provider does no extra work. All 12#getCachedMetacallers benefit.Proof
Real
PerpsControlleron HyperLiquid testnet (headless core adapter: real messenger wiring, fixture signer, real transports). Scenario:disconnect()→ startinit()without awaiting →placeOrderimmediately. Order is a resting limit 50% below market (never fills), cancelled by the rig.mainCLIENT_NOT_INITIALIZED{ success: false, error: 'CLIENT_NOT_INITIALIZED' }57799146376), cancelledExecutable recipe (
perps-cold-start-order.recipe.json, core adapter): assertsverdict: ORDER_PLACEDanderror: nullfrom the rig. Fails on #9032 alone (cold-start-order.stdout does not contain "verdict": "ORDER_PLACED"), passes with this change. Recipe and rig live in the perps recipe library rather than this repo; happy to share both.Unit guard:
HyperLiquidProvider.trading.test.ts— "brings the SDK clients up before reading asset metadata" makes the mockedgetInfoClientthrowCLIENT_NOT_INITIALIZEDuntilinitialize()runs, then assertsplaceOrdersucceeds. Red before the change, green after.Validation
jest tests/src/providers— 14 suites, 667 passed, 0 failedeslinton both changed files — cleanchangelog:validate— cleanReferences
Checklist
Note
Low Risk
Narrow, idempotent initialization guard on an existing metadata fetch path; warm-cache behavior unchanged and covered by a focused unit test.
Overview
Fixes a remaining cold-start / post-disconnect
CLIENT_NOT_INITIALIZEDpath on HyperLiquid trading after controller init waits (#9032):placeOrderstill resolves asset info (via#getCachedMeta→InfoClient) before#ensureReadyForTrading, so the first metadata fetch could throw or return{ success: false, error: 'CLIENT_NOT_INITIALIZED' }while clients were still down.On cache miss (or
skipCache),#getCachedMetanowawaits#ensureClientsInitialized()beforegetInfoClient(). That call is idempotent; warm cache hits return earlier and do not add work.Adds a unit test that mocks
getInfoClientto fail untilinitialize()runs, and documents the fix in CHANGELOG.Reviewed by Cursor Bugbot for commit 32e3a60. Bugbot is set up for automated code reviews on this repo. Configure here.