perf: per-IP WebSocket quota, O(n) termination fan-out, narrow entrypoints (release 0.2.0) - #88
Merged
Merged
Conversation
…oints (release 0.2.0)
Four models diagnosed this SDK independently and every candidate finding was
adversarially verified by a skeptic who reproduced it from scratch. This lands
the findings that survived.
Minor, not patch, per docs/README.md#versioning: two changes alter the behaviour
of working 0.1.6 programs. Both convert a silent server-side failure into an
immediate local one — the server already refused this traffic, as a 10 s timeout
with no echoed request to match it to.
WebSocket per-IP quota (src/transport/websocket/_quota.ts)
Hyperliquid scopes every documented WebSocket limit to the client IP, not the
connection, and says so for two of them ("across all websocket connections").
Subscription and unique-user budgets were tracked per manager, so N transports
admitted N x 1000 subscriptions against a limit of 1000. They now share one
quota per network by default; pass `quota` to opt out.
MAX_UNIQUE_USERS is 14, not 15. A live mainnet probe on 2026-08-02 subscribed
distinct users one at a time with the guard disabled, twice on two independent
connections: both accepted exactly 14 and had the 15th refused by an `error`
frame reading "Cannot track more than 15 total users." The server enforces one
fewer than its own message states, so the previous value let the 15th
subscription through to be dropped without an echo — the exact failure the
guard exists to prevent. The same probe established the scope: a second
connection was refused a 15th distinct user while still being allowed one the
first connection already held.
Also adds an opt-in outbound message limiter for the documented 2000/min
budget. It paces subscribe/unsubscribe only: `post` frames and keep-alive
pings debit the budget without ever waiting, because _shell.ts fixes an
exchange action's wire order on transport.request reaching send synchronously,
and delaying the watchdog is how a half-open socket goes unnoticed.
O(n) termination fan-out (src/transport/websocket/_dispatcher.ts)
Every request relayed the socket's single shared terminationSignal, putting
one listener per in-flight request on one AbortSignal. EventTarget scans that
list linearly on both add and remove, so a burst was O(n^2): measured at 195 ns
per add/remove pair with the list empty and 13.1 us with 5000 resident on Bun.
One listener plus a Set makes it O(1). Measured 2000 in-flight requests
9.2-10.3 ms -> 4.4-5.0 ms (-50%, 5/5 interleaved pairs, no overlap), and a real
1000-subscription reconnect -18.9%.
Reason precedence is preserved deliberately: the caller's signal is relayed
first and the termination branch is gated on the controller still being
unaborted, so a caller's own abort still outranks the socket's when both are
already aborted. A mutant with those two reversed fails exactly the new test
for it and nothing else.
Narrow entrypoints (package.json) and the /utils import graph
The root barrel evaluates all four clients plus both transports, and
./transport was not exported at all. Seven additive keys let an info-only
consumer enter through ./api/info/client + ./transport: 69.5 -> 41.0 ms on
Node, 22.0 -> 8.3 ms on Bun.
src/utils/_symbolConverter.ts imported four functions from api/info/mod.ts —
the only value import of that barrel in src/ — dragging 80+ method modules into
@bloxwap/hyperliquid/utils. Importing the four _methods/* modules directly
takes the built /utils entry from 91 to 10 modules: 23.0-31.5 -> 6.2-6.3 ms on
Node, 6.2-8.2 -> 3.5-3.7 ms on Bun. .dev/import_graph_check.ts gates it; run
against the previous tree it reports OVER 90/20 and exits 1.
Perf suite corrections
order_100_concurrent runs at 20 ms latency while order_sequential runs at 0,
and the harness divides burst wall time by 100 — so 200 us/order of that
figure is amortized RTT, and four separate audits each "found" a phantom
regression in it. Adds order_100_concurrent_instant (same shape, 0 ms) which
measures 106 us against order_sequential's 111: concurrency is cheaper, not
3x worse. Added as a sibling, not a rename, so the gate's join key survives.
The 20 ms scenario now reports latencyMs and rttPerOrderUs.
Documents the harness's strictly-sequential execution, whose peak in-flight of
1 is why the dispatcher's O(n^2) was invisible to ws_request_round_trip. Adds
a --record warning for baseline entries above 15% rme; the committed baseline
has nine, including the 41% rme entry that generated a phantom 7.7 us
"validation cost" three audits then spent effort refuting.
Test harness
runTestWithExchange gated only on OFFLINE while funding a throwaway account,
so without PRIVATE_KEY four subscription tests reached ExchangeClient with an
undefined wallet (a `MAIN_WALLET!` assertion hid it) and failed with
"TypeError: wallet is not an Object" instead of skipping. Now gated on
CAN_FUND_TEMP_ACCOUNT, matching the exchange harness.
Note: bun run perf:gate fails closed on this commit because the suite
fingerprint changed. That is the documented behaviour for an intentional
tests/perf edit and needs an explicit merge decision; there is no override flag.
Interleaved runs show no regression (HEAD 0/0, this tree 1/0 across two rounds).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Four frontier models (Claude Opus 5, GPT-5.6/codex, grok-4.5, kimi-k3) diagnosed this SDK independently against a shared measured baseline. Every candidate finding was then handed to an adversarial verifier who reproduced it from scratch — fresh process per data point, A/B order alternated per pair, untouched control scenarios carried through the same runs. This lands the findings that survived. Where a verifier disagreed with the original claim, the verifier's number is the one used.
Why
0.2.0and not0.1.7Per Versioning — "Until
1.0.0, breaking changes bump the minor version" — two changes alter the behaviour of working0.1.6programs:MAX_UNIQUE_USERSis 14, not 15.Both convert a silent server-side failure into an immediate local one. The server already refused this traffic — as a 10 s timeout carrying no echoed request to match it to. The type surface is pure addition, so
tscwarns nobody; the minor bump is the only signal a consumer on^0.1.6gets.Per-IP WebSocket quota —
src/transport/websocket/_quota.tsHyperliquid scopes every documented WebSocket limit to the client IP rather than the connection, and says so in the text of two of them ("across all websocket connections"). Subscription and unique-user budgets were tracked per manager, so N transports admitted N×1000 subscriptions against a limit of 1000. They now share one quota per network by default; pass
quotato opt out.MAX_UNIQUE_USERSis 14. A live mainnet probe on 2026-08-02 subscribed distinct users one at a time with the guard disabled, twice, on two independent connections. Both accepted exactly 14 and had the 15th refused by anerrorframe readingCannot track more than 15 total users.— the server enforces one fewer than its own message states. The previous value therefore let the 15th subscription through to be dropped without an echo, producing exactly the unmatched 10 s timeout the guard exists to prevent. The same probe settled the scope: with one connection holding 14 users, a second connection from the same host was refused a 15th distinct user while still being allowed to subscribe one the first already held. Per IP, not per connection — so sharding user channels across sockets buys nothing.Also adds an opt-in outbound message limiter for the documented 2000/min budget. It paces
subscribe/unsubscribeonly.postframes and keep-alive pings debit the budget but never wait, because_shell.tsfixes an exchange action's wire order ontransport.requestreachingsendsynchronously, and delaying the watchdog is how a half-open socket goes unnoticed.O(n) termination fan-out —
src/transport/websocket/_dispatcher.tsEvery request relayed the socket's single shared
terminationSignal, putting one listener per in-flight request on oneAbortSignal.EventTargetscans that list linearly on both add and remove, so a burst was O(n²) — measured at 195 ns per add/remove pair with the list empty and 13.1 µs with 5000 resident on Bun (40.4 µs on Node). One listener plus aSetmakes it O(1).Reason precedence is preserved deliberately. The caller's signal is relayed first and the termination branch is gated on the controller still being unaborted, so a caller's own abort still outranks the socket's when both are already aborted. A mutant with those two lines reversed fails exactly the new test written for it, and nothing else.
Narrow entrypoints and the
/utilsimport graphThe root barrel evaluates all four clients plus both transports, and
./transportwas not exported at all — so the raw-function path the SDK's own JSDoc advertises was unusable standalone. Seven additiveexportskeys let an info-only consumer enter through./api/info/client+./transport: 69.5 → 41.0 ms on Node, 22.0 → 8.3 ms on Bun. Downstream browser bundles are byte-identical.src/utils/_symbolConverter.tsimported four functions fromapi/info/mod.ts— the only value import of that barrel anywhere insrc/— dragging 80+ method modules into@bloxwap/hyperliquid/utils. Importing the four_methods/*modules directly takes the built entry from 91 to 10 modules: 23.0–31.5 → 6.2–6.3 ms on Node, 6.2–8.2 → 3.5–3.7 ms on Bun..dev/import_graph_check.ts(newcheck:importsgate) budgets this. It is exact rather than heuristic becauseverbatimModuleSyntaxmakesimport typethe definitive marker for an erased edge. Run against the previous tree it reportsOVER src/utils/mod.ts 90 / 20and exits 1. Runs in 0.24 s from a clean checkout, no build required.Perf-suite corrections
order_100_concurrentruns atLATENCY_MS = 20whileorder_sequentialruns at 0, and the harness divides burst wall time by 100 — so 200 µs/order of that figure is amortized RTT. Four separate audits each "discovered" a phantom regression in it. Addsorder_100_concurrent_instant(identical shape, 0 ms) which measures 106 µs againstorder_sequential's 111 — concurrency is cheaper per order, not 3× worse. Added as a sibling rather than a rename so the gate's join key survives; the 20 ms scenario now reportslatencyMsandrttPerOrderUsso the arithmetic is visible without opening the file.Documents the harness's strictly-sequential execution, whose peak in-flight of 1 is precisely why the dispatcher's O(n²) was invisible to
ws_request_round_tripfor as long as it was. Adds a--recordwarning for baseline entries above 15% rme — the committed baseline has nine, including the 3031.9 ns @ 41.0% rme entry that generated a phantom "7.7 µs validation cost" three audits then spent effort refuting.Test harness
runTestWithExchangegated only onOFFLINEwhile funding a throwaway account, so withoutPRIVATE_KEYfour subscription tests reachedExchangeClientwith anundefinedwallet — aMAIN_WALLET!non-null assertion silenced the type error — and failed withTypeError: wallet is not an Objectinstead of skipping. Now gated onCAN_FUND_TEMP_ACCOUNT, matching the exchange harness, and the assertion is replaced by a check that names the missing key. Takes an online run without a key from 6 failures to 2.Known drift recorded
Entries 9 and 10 in
known-drift.md:outcomeMetaoutcomes gained adeployerfield (present across the whole array), andvalidatorL1Votescarried an action withregisterTemplatematching no variant of the documented union. Both are type-only; runtime is unaffected since Info responses pass through as received.Verification
HEAD— the control run had the same six plus a flakySymbolConvertertimeout. Zero quota-guard errors, clearing the shared quota as a suspect for the four user-channel failures.check:*gates pass, including the newcheck:importsand the TS7 forward-compat gate.perf.ymlwill be redThe Performance job fails closed on purpose because this PR edits
tests/perf:There is no override flag. Splitting
tests/perfinto a separate PR would be red too. This needs an explicit human merge decision.There is no actual regression. Interleaved gate runs on a quiet machine: HEAD 0 regressed / 0 regressed, this branch 1 / 0. An earlier run showing 14 regressions across untouched paths (
data/parse_*,signing/canonicalize,nonce_manager) was a load artifact — I had run this branch at load 12.75 and the control afterwards as load decayed, which is the same sequential-block A/B fallacy the suite's own methodology notes now warn about.Separately,
bun run perf:gateis red onmaintoo on some machines because the committedbaseline.jsonwas recorded on faster hardware; re-recording is worth doing independently of this PR.🤖 Generated with Claude Code