stacks-network / stacks-network/stacks-core

StacksHttp rebuilds the entire RPC route table (~100 regex compilations, ~5 ms) on every inbound HTTP connection

Open
#7,403 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Rust
Stars
3.1k
Forks
762
Avg merge
4d 6h
Merged PRs (30d)
76

Description

Context

Every accepted HTTP socket constructs a fresh StacksHttp protocol state machine, and with it the full RPC route table:

  • HttpPeer::register_http (stackslib/src/net/server.rs) → ConversationHttp::new (stackslib/src/net/rpc.rs) → StacksHttp::new (stackslib/src/net/httpcore.rs), which calls register_rpc_methods() (stackslib/src/net/api/mod.rs).
  • register_rpc_methods() instantiates all ~50 RPC handlers and, for each one, compiles a strict path regex (handler.path_regex() returns a freshly compiled Regex on every call) plus a permissive regex derived by make_permissive_regex() for 400/405 detection — roughly 100 Regex::new compilations per inbound connection.

Measured with the real patterns (release build): a param-heavy strict regex (standard-principal + contract-name + clarity-name character classes, e.g. the /v2/map_entry/... pattern) costs ~175 µs to compile, and a simulated full register_rpc_methods() costs ~5 ms. Cloning an already-compiled Regex costs ~0.1 µs, since regex::Regex is internally reference-counted.

This matters because the compilation happens inline on the p2p thread — the single thread that services all RPC and p2p traffic — so every new connection head-of-line-blocks every other request for ~5 ms. Connection creation is also far more frequent than "once per client":

  • idle_timeout defaults to 15 s (ConnectionOptions, stackslib/src/net/connection.rs), so gateway keep-alive pools churn whenever traffic isn't continuous;
  • max_http_clients defaults to 10 concurrent inbound connections per source IP, so a reverse proxy or API gateway (a single IP) that bursts past the cap gets accept-then-drop behavior and reconnect churn;
  • load balancers open additional upstream connections precisely when the node is under load, adding this cost at the worst time.

It is also a mild DoS amplifier: an attacker can force ~5 ms of event-loop CPU per otherwise-empty TCP connect.

Solution tl;dr

Build the route table once and clone it per connection. All handler structs already derive Clone; add a box_clone() method to the RPCRequestHandler trait (or use the dyn-clone crate), build a template Vec<(String, Regex, Regex, Box<dyn RPCRequestHandler>)> a single time, and make StacksHttp::new clone the template instead of re-registering. Regex clones are refcount bumps, so per-connection setup drops from ~5 ms to ~10 µs (>99% reduction).

Two constraints to respect:

  1. Handler instances must remain per-conversation — parsed request state is stored on the handler between try_parse_request and try_handle_request — so the fix is clone-from-template, not a globally shared table.
  2. The template depends on a few ConnectionOptions-derived fields captured at construction time (maximum_call_argument_size, read_only_call_limit, read_only_max_execution_time, read_only_call_max_mem_bytes, auth_token). These are identical for every inbound connection of a given HttpPeer, so the template can live on HttpPeer (built from its ConnectionOptions at startup) rather than in a global.
Self-contained implementation prompt

In the stacks-core repository, eliminate the per-connection construction of the RPC route table in the HTTP server.

Current behavior. StacksHttp::new in stackslib/src/net/httpcore.rs calls self.register_rpc_methods() (defined in stackslib/src/net/api/mod.rs). That method registers ~50 RPC endpoint handlers via register_rpc_endpoint, which stores (verb, strict_regex, permissive_regex, Box<dyn RPCRequestHandler>) tuples in StacksHttp::request_handlers. The strict regex comes from handler.path_regex() (which compiles a new Regex on each call) and the permissive regex from handler.path_regex_permissive() / make_permissive_regex(). StacksHttp::new is invoked from ConversationHttp::new (stackslib/src/net/rpc.rs), which is invoked from HttpPeer::register_http (stackslib/src/net/server.rs) for every accepted HTTP socket. Net effect: ~100 regex compilations (~5 ms of CPU on the p2p thread) per inbound TCP connection.

Required change. Make route-table construction happen once, and make per-connection setup a cheap clone:

  1. Add a boxed-clone capability to the RPCRequestHandler trait (in stackslib/src/net/httpcore.rs): e.g. a fn box_clone(&self) -> Box<dyn RPCRequestHandler> default-less method implemented via a blanket impl<T: RPCRequestHandler + Clone + 'static> helper trait, or adopt the dyn-clone crate. All existing handlers derive Clone already; verify each of the ~50 handlers in stackslib/src/net/api/ compiles with the new bound.
  2. Introduce a route-table template type holding the (String, Regex, Regex, Box<dyn RPCRequestHandler>) tuples. Build it once per HttpPeer from its ConnectionOptions (the handlers capture maximum_call_argument_size, read_only_call_limit, read_only_max_execution_time, read_only_call_max_mem_bytes, and auth_token at registration time — see register_rpc_methods in stackslib/src/net/api/mod.rs). Store it on HttpPeer (stackslib/src/net/server.rs) and thread it through ConversationHttp::new into StacksHttp::new, which should clone the tuples (Regex::clone is an Arc bump; handlers clone via box_clone) instead of calling register_rpc_methods().
  3. Keep the public behavior of StacksHttp::new intact for callers that construct it without an HttpPeer (tests, clients via StacksHttp::new_client): if no template is supplied, fall back to building the table as today. new_client does not register handlers and needs no change.
  4. Do not share one table across conversations: handlers store parsed request state on themselves between try_parse_request and try_handle_request, so each ConversationHttp must own its own cloned handler instances.

Verification.

  • cargo check -p stackslib and the HTTP test suites: cargo test -p stackslib net::api and cargo test -p stackslib net::http (also net::tests::httpcore if present in the tree).
  • Add a unit test asserting that two StacksHttp instances cloned from the same template route identically (same handler order and regex patterns) and that per-connection construction no longer calls path_regex() (e.g., assert template build count via the template API shape, or benchmark-style assert that constructing 100 StacksHttp from a template completes well under the cost of 100 fresh registrations).
  • Confirm no behavioral change to routing: registration order must be preserved exactly, since try_parse_request matches handlers linearly in insertion order.

Success criteria. Accepting an inbound HTTP connection performs zero Regex::new calls in the steady state; all existing tests pass; handler per-conversation state semantics are unchanged.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with StacksHttp::new and register_rpc_methods in stackslib/src/net/httpcore.rs and stackslib/src/net/api/mod.rs, then trace ConversationHttp::new and HttpPeer::register_http. Follow how ConnectionOptions values and handler state enter the route table, preserving registration order and per-conversation handler ownership. Run cargo check -p stackslib and the net::api and net::http test suites; done means steady-state inbound setup performs no Regex::new calls and routing behavior remains unchanged.

Written by the indexing model from the issue text.

Assessment

Tech stack
rust
Domain
api, backend, networking, performance
Issue type
Refactor
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.