Skip to content

perf: per-IP WebSocket quota, O(n) termination fan-out, narrow entrypoints (release 0.2.0) - #88

Merged
joeblau merged 1 commit into
mainfrom
perf/phase1-quota-and-entrypoints
Aug 2, 2026
Merged

perf: per-IP WebSocket quota, O(n) termination fan-out, narrow entrypoints (release 0.2.0)#88
joeblau merged 1 commit into
mainfrom
perf/phase1-quota-and-entrypoints

Conversation

@joeblau

@joeblau joeblau commented Aug 2, 2026

Copy link
Copy Markdown

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.0 and not 0.1.7

Per Versioning"Until 1.0.0, breaking changes bump the minor version" — two changes alter the behaviour of working 0.1.6 programs:

  • the WebSocket quota is now shared across transports, so an app running N transports goes from N×1000 subscriptions to 1000 total;
  • MAX_UNIQUE_USERS is 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 tsc warns nobody; the minor bump is the only signal a consumer on ^0.1.6 gets.

Per-IP WebSocket quota — src/transport/websocket/_quota.ts

Hyperliquid 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 quota to opt out.

MAX_UNIQUE_USERS is 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 an error frame reading Cannot 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/unsubscribe only. post frames and keep-alive pings debit the budget but never wait, 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²) — 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 a Set makes it O(1).

before after
2000 in-flight requests 9.2–10.3 ms 4.4–5.0 ms (−50%, 5/5 interleaved pairs, no overlap)
1000-subscription reconnect −18.9% (paired IQR [−22.0%, −10.4%])

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 /utils import graph

The root barrel evaluates all four clients plus both transports, and ./transport was not exported at all — so the raw-function path the SDK's own JSDoc advertises was unusable standalone. Seven additive exports 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. Downstream browser bundles are byte-identical.

src/utils/_symbolConverter.ts imported four functions from api/info/mod.ts — the only value import of that barrel anywhere in src/ — 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 (new check:imports gate) budgets this. It is exact rather than heuristic because verbatimModuleSyntax makes import type the definitive marker for an erased edge. Run against the previous tree it reports OVER src/utils/mod.ts 90 / 20 and exits 1. Runs in 0.24 s from a clean checkout, no build required.

Perf-suite corrections

order_100_concurrent runs at LATENCY_MS = 20 while order_sequential runs 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. Adds order_100_concurrent_instant (identical shape, 0 ms) which measures 106 µs against order_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 reports latencyMs and rttPerOrderUs so 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_trip for as long as it was. Adds a --record warning 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

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! non-null assertion silenced the type error — and failed with TypeError: wallet is not an Object instead of skipping. Now gated on CAN_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: outcomeMeta outcomes gained a deployer field (present across the whole array), and validatorL1Votes carried an action with registerTemplate matching no variant of the documented union. Both are type-only; runtime is unaffected since Info responses pass through as received.

Verification

  • Offline suite: exit 0 — 1701 pass / 236 skip / 0 fail. (An earlier revision exited 1 on a leaked rejection from a new test; caught, fixed, and confirmed by independent skeptics re-running the live tree.)
  • Online suite: every failure also fails on HEAD — the control run had the same six plus a flaky SymbolConverter timeout. Zero quota-guard errors, clearing the shared quota as a suspect for the four user-channel failures.
  • All eight check:* gates pass, including the new check:imports and the TS7 forward-compat gate.
  • All 14 dist entrypoints import cleanly on Node 24.10 and Bun 1.4.

⚠️ Merge decision required: perf.yml will be red

The Performance job fails closed on purpose because this PR edits tests/perf:

the perf suite itself differs between the two revisions (suite fingerprint c58f2d1bfb838bdd vs 357f601f4751c991) — a change to tests/perf changes the workload the gate compares… Land an intentional suite change with the gate red, or as its own PR.

There is no override flag. Splitting tests/perf into 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:gate is red on main too on some machines because the committed baseline.json was recorded on faster hardware; re-recording is worth doing independently of this PR.

🤖 Generated with Claude Code

…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>
@joeblau
joeblau merged commit dd4bbbc into main Aug 2, 2026
5 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant