Skip to content

feat(transport): inject runtime dependencies for isolated, deterministic tests - #164

Merged
joeblau merged 2 commits into
mainfrom
feat/158-runtime-injection
Oct 6, 2026
Merged

joeblau merged 2 commits into
mainfrom
feat/158-runtime-injection

Conversation

@joeblau

@joeblau joeblau commented Oct 6, 2026 •

Copy link
Copy Markdown

Summary

Transports and their schedulers no longer have to reach for globals. A new Runtime contract (now, monotonicNow, setTimeout/clearTimeout, setInterval/clearInterval, random) plus an injectable fetch and webSocketFactory can be passed per transport. When omitted, everything falls back to the platform.

  • New module src/transport/runtime.ts, exported as @bloxwap/hyperliquid/transport/runtime and from the transport barrel: Runtime, TimerHandle, systemRuntime, resolveRuntime, delay.
  • Resolved once. resolveRuntime() runs in each constructor. It returns systemRuntime itself when nothing is overridden, and an already-resolved runtime unchanged, so WebSocketTransport resolves once and shares one object across its socket, dispatcher and keep-alive. Overriding methods keep their receiver, so a stateful fake clock can be passed directly.
  • Wall vs. elapsed time. Wall time (now) drives only protocol timestamps: nonces and HTTP-date Retry-After. monotonicNow (performance.now) drives the TimeoutWheel, the token-bucket refill, InfoCacheTransport expiry and WebSocketQuota attempt windows. A wall-clock correction neither fires nor stalls them.
  • Deterministic jitter. retryDelayMs and defaultReconnectionDelay use runtime.random.
  • New optional parameters (every one optional; existing constructors are unchanged):
    • HttpTransportOptions.fetch and .runtime
    • WebSocketTransportOptions.runtime and .webSocketFactory
    • ReconnectingWebSocketOptions.runtime and .webSocketFactory
    • WebSocketQuotaOptions.runtime and InfoCacheOptions.runtime
    • a trailing runtime argument on TokenBucketRateLimiter, TimeoutWheel, WebSocketDispatcher and WebSocketKeepAlive
    • createNonceManager(maxEntries, runtime)
  • Retry waits still don't hold the process open. The 429 retry wait now uses delay(), which releases its timer and abort listener on every path. Like the sleep() it replaces, it unrefs the platform timer.
  • Shared coordination is preserved and documented:
    • Without an explicit quota, a WebSocketTransport keeps the per-network shared WebSocketQuota on the platform clock, even when a runtime or socket factory is injected.
    • globalNonceManager is unchanged: one process-wide manager keyed by signer and network, on the platform wall clock. It is intentionally not injectable per transport, so HTTP and WebSocket clients for the same signer can never fork nonce state. Tests that need control can pass the existing nonceManager option on ExchangeClient. createNonceManager(…, runtime) stays internal.

Tests

  • New helpers: tests/_fakeRuntime.ts (FakeRuntime, a virtual wall and monotonic clock with timers, pendingTimers, and an optional random) and tests/_fakeSocket.ts.
  • tests/transport/_runtime.test.ts covers:
    • two HTTP transports with separate fake runtimes and fetches (globals untouched)
    • wall-clock rollback against monotonic deadlines, refill and cache expiry, while nonces stay wall-based
    • exact jitter with random = 0.5
    • HTTP-date Retry-After against the injected wall clock
    • resolveRuntime identity
    • delay unref
    • timer and abort-listener cleanup after success, failure, timeout, retry cancellation and delay abort
  • tests/transport/_runtimeIntegration.test.ts covers:
    • HTTP and WS ExchangeClients on separate fake runtimes still get unique per-signer nonces
    • WS keep-alive and request timeout on the injected clock
    • the default shared quota is kept when a runtime or factory is injected
    • pendingTimers == 0 after close
  • Migrated from @std FakeTime and global patching to FakeRuntime with an injected fetch or socket factory:
    • _rateLimiter, _infoCache, _infoCacheExpiry and _keepAlive
    • the TimeoutWheel block in _abort
    • the retryOnRateLimit and rateLimit blocks in http/mod.test.ts
    • _connectionQuota
    • the backoff, stable-timeout and connection-timeout tests in _reconnectingSocket. These used real sleep/polling before.
    • api/exchange/_nonce
  • The remaining http/mod.test.ts and _reconnectingSocket.test.ts cases still patch the global fetch / WebSocket on purpose. They are the coverage for the default (omitted-dependency) path, which reads the globals at call time.

Docs

New Injectable runtimes section in apps/docs/content/docs/transports.md. It covers the options, the wall vs. monotonic split, the shared quota and nonce caveats, and a small runnable fake-clock test that drives a 429 retry without sleeping. The example was run under bun test and type-checked before landing.

Notes

  • OSV-Scanner fails on main too (the scheduled Security run has been failing since 2026-10-05). The cause is pre-existing bun.lock advisories: braces, katex, sharp, smol-toml and source-map-js. braces is fix(docs): lint Markdown without markdownlint-cli2 to drop vulnerable braces #163's territory, and the rest are docs-app transitive deps. This PR leaves the lockfile alone.
  • apps/docs/app/layout.tsx gets a Biome format-only fix. It came in unformatted with 68cb6d2 (pushed directly to main) and would otherwise fail bun run check in Code Quality.

Acceptance criteria

  • Existing constructors and production behavior remain compatible when dependencies are omitted (all new params optional; systemRuntime reads globals at call time; the existing global-patching tests still pass unchanged).
  • Two transports with independent fake runtimes coexist without modifying global fetch, WebSocket, Date.now, or timers. Asserted in _runtime.test.ts, _runtimeIntegration.test.ts and _connectionQuota.test.ts.
  • Representative timeout, backoff, reconnect, cache-expiry and nonce tests advance a fake clock instead of sleeping.
  • Timer and abort-listener cleanup is asserted after success, failure, cancellation and close (pendingTimers === 0, getEventListeners(...) === 0).
  • Clock rollback and wall vs. elapsed semantics have focused coverage (TimeoutWheel, rate limiter, info cache, nonce manager, Retry-After HTTP-date).
  • Shared signer/network nonce coordination and the shared default quota remain correct across HTTP and WebSocket clients.
  • Ran affected offline tests, type and export checks, and transport perf scenarios with defaults (below).
  • Documented custom runtime setup with a small test example.

Measurements

These are perf scenarios with default dependencies: main (68cb6d2) vs. this branch on the same machine (Apple M3 Max, Bun 1.4.0 canary). Runs were interleaved, and each figure is the median of the per-run medians.

Transport, final commit (6 interleaved runs each):

scenario main branch change
transport/http_request 1.05 µs 1.08 µs +2.5%
transport/http_request_with_signal 1.33 µs 1.39 µs +4.4%
transport/ws_request_round_trip 2.05 µs 2.03 µs −0.9%

Per-run spreads overlap: http_request was 1.02–1.14 µs on main and 1.06–1.17 µs here. An earlier 5-run set on a previous revision read +0.5% / −10.6% / −1.3%. The residual ~30 ns per HTTP request comes from monotonicNow. It defaults to performance.now(), which costs about 7 ns more than Date.now() in Bun (measured: 30 ns vs. 23 ns), and the TimeoutWheel reads it once per request through the runtime object. That is the inherent cost of the monotonic deadlines the issue asks for. When no fetch is injected, the request path calls the global fetch directly, with no wrapper.

Transaction (3 runs each, earlier revision):

scenario main branch change
transaction/order_sequential 104 µs 94.5 µs −8.9%
transaction/order_batch_100 1.96 µs 1.97 µs +0.6%
transaction/order_100_concurrent 293 µs 296 µs +1.3%
transaction/order_100_concurrent_instant 89.8 µs 89.8 µs 0.0%
transaction/order_sequential_unchecked 84.7 µs 86.0 µs +1.5%
transaction/prepare_request 4.91 µs 4.91 µs +0.1%

nonce_manager_over_capacity: after the first CI gate run showed +11.4%, createNonceManager() without a runtime (the process-wide manager) goes back to a direct Date.now() call. Only an injected runtime pays the indirection. Local 8-run medians were 84 ns (main) vs. 89 ns (branch), within this scenario's run-to-run spread (80–125 ns on both sides). The CI gate then read −4.1%.

CI perf gate (bun run perf:gate, all 57 scenarios, final commit): 0 regressed, 57 unchanged:

scenario change band
http_request −1.8% [−14.6%, +13.0%]
http_request_with_signal −0.3% [−3.9%, +3.5%]
ws_request_round_trip +0.6% [−0.9%, +2.2%]
nonce_manager_over_capacity +3.7% [−6.2%, +14.6%]

Earlier revisions hit two flaky gate failures, both in signing scenarios this PR doesn't touch: a canonicalize_order_1 band of −2.4% … +219%, and an "inconclusive" multisig_user_signed_3_signers_no_ecdsa.

.dev/perf/info_cache_capacity.ts (3 runs each) was within ±10% with mixed signs across all 15 mode × size cells. There is no consistent direction. On this branch the script injects runtime: { now, monotonicNow }, so it is not a strict defaults comparison.

Test plan

  • bun install && bun run check (format, lint, docs, sdk: tsc, ts7, jsdoc, export, imports)
  • bun run test:offline: 1987 pass, 0 fail
  • bun run build: dist exports ./transport/runtime
  • bun run perf -- --filter transport / --filter transaction vs. main (above)
  • Docs example run as a bun test file and type-checked

Closes #158

🤖 Generated with Claude Code

@joeblau
joeblau force-pushed the feat/158-runtime-injection branch 5 times, most recently from 57e4375 to 2a520d7 Compare October 6, 2026 23:24
joeblau and others added 2 commits October 7, 2026 07:32
…tic tests

Add an optional `Runtime` (wall clock, monotonic clock, timers, random source)
plus an injectable `fetch` and `webSocketFactory`, resolved once per component
at construction and defaulting to the platform. Elapsed-time logic (timeout
wheel, rate limiter, info cache, WebSocket attempt windows) now reads monotonic
time; nonces and HTTP-date Retry-After stay on wall time. Retry and reconnect
jitter read the injected random source.

Timer-driven tests move from global FakeTime / fetch / WebSocket patching to
local FakeRuntime, injected fetch, and FakeSocket helpers, with cleanup
assertions for timers and abort listeners.

Closes #158

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Browsers' window.fetch throws "Illegal invocation" when invoked with a
receiver other than the global, so `this._fetch(url, init)` broke
`new HttpTransport({ fetch: window.fetch })` on every request. Call it
through a local instead and cover it with a receiver-sensitive fetch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@joeblau
joeblau force-pushed the feat/158-runtime-injection branch from 1ca3e0b to 7635a27 Compare October 6, 2026 23:33
@joeblau
joeblau merged commit d3126ae into main Oct 6, 2026
6 checks passed
@joeblau
joeblau deleted the feat/158-runtime-injection branch October 6, 2026 23:36
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.

Inject transport runtime dependencies for isolated, deterministic tests

1 participant