Skip to content

fix(perps): ensure SDK clients before provider metadata read - #9865

Merged
abretonc7s merged 2 commits into
fix/perps-client-not-initialized-reconnectionfrom
fix/perps-ensure-clients-before-meta-read
Aug 13, 2026
Merged

fix(perps): ensure SDK clients before provider metadata read#9865
abretonc7s merged 2 commits into
fix/perps-client-not-initialized-reconnectionfrom
fix/perps-ensure-clients-before-meta-read

Conversation

@abretonc7s

@abretonc7s abretonc7s commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

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 reaches HyperLiquidProvider.placeOrder, which resolves asset info before it ensures trading readiness. That path calls #getCachedMeta#clientService.getInfoClient()ensureInitialized(), which throws CLIENT_NOT_INITIALIZED while the SDK clients are still down. #ensureReadyForTrading() (and with it #ensureClientsInitialized()) only runs later in placeOrder.

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 #getCachedMeta callers benefit.

Proof

Real PerpsController on HyperLiquid testnet (headless core adapter: real messenger wiring, fixture signer, real transports). Scenario: disconnect() → start init() without awaiting → placeOrder immediately. Order is a resting limit 50% below market (never fills), cancelled by the rig.

Build Cold-start result
main rejects, thrown CLIENT_NOT_INITIALIZED
#9032 alone resolves { success: false, error: 'CLIENT_NOT_INITIALIZED' }
#9032 + this change order placed (57799146376), cancelled

Executable recipe (perps-cold-start-order.recipe.json, core adapter): asserts verdict: ORDER_PLACED and error: null from 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 mocked getInfoClient throw CLIENT_NOT_INITIALIZED until initialize() runs, then asserts placeOrder succeeds. Red before the change, green after.

Validation

  • jest tests/src/providers — 14 suites, 667 passed, 0 failed
  • eslint on both changed files — clean
  • changelog:validate — clean

References

Checklist

  • I've updated the test suite for new or updated code as appropriate
  • I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate
  • I've communicated my changes to consumers by updating changelogs for packages I've changed
  • I've introduced breaking changes in this PR and have prepared draft pull requests for clients and consumer packages to resolve them

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_INITIALIZED path on HyperLiquid trading after controller init waits (#9032): placeOrder still resolves asset info (via #getCachedMetaInfoClient) 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), #getCachedMeta now awaits #ensureClientsInitialized() before getInfoClient(). That call is idempotent; warm cache hits return earlier and do not add work.

Adds a unit test that mocks getInfoClient to fail until initialize() 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.

@abretonc7s
abretonc7s requested review from a team as code owners August 13, 2026 11:17
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
abretonc7s force-pushed the fix/perps-ensure-clients-before-meta-read branch from 91e7a54 to 32e3a60 Compare August 13, 2026 12:16
@abretonc7s
abretonc7s merged commit e7be777 into fix/perps-client-not-initialized-reconnection Aug 13, 2026
39 checks passed
@abretonc7s
abretonc7s deleted the fix/perps-ensure-clients-before-meta-read branch August 13, 2026 12:18
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 -->
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