Module: Hyperion::Server::ConnectionLoop
- Defined in:
- lib/hyperion/server/connection_loop.rb
Overview
2.12-C — Connection lifecycle in C.
Engaged by ‘Server#start_raw_loop` when ALL of the following hold:
* The listener is plain TCP (no TLS, no h2 ALPN dance).
* The route table has at least one `RouteTable::StaticEntry`
registration (i.e. `Server.handle_static` was called).
* The route table has NO non-StaticEntry registrations
(any `Server.handle(:GET, '/api', dynamic_handler)` disables
the C path; the C loop only knows how to write prebuilt
responses).
* The `HYPERION_C_ACCEPT_LOOP` env knob is not set to `"0"` /
`"off"` (operator escape hatch for debug).
On engage, the Ruby accept loop is not run for this listener; ‘Hyperion::Http::PageCache.run_static_accept_loop` drives the accept-and-serve loop entirely in C and only re-enters Ruby for:
1. Per-request lifecycle hooks
(`Runtime#fire_request_start` / `fire_request_end`), gated
by a single C-side integer flag so the no-hook hot path
stays one syscall.
2. Connection handoff: requests that don't match a `StaticEntry`
(or are malformed, h2/upgrade, or carry a body) are passed
back as `(fd, partial_buffer)` — Ruby resumes ownership of
the fd and dispatches via the regular `Connection` path.
The wiring lives in this module so the conditional logic stays out of the Server hot-path entry methods.
Constant Summary collapse
- WAKE_CONNECT_TIMEOUT_SECONDS =
2.14-B — bound applied to the wake-connect dial inside ‘Server#stop`. The listener is local — a successful connect is sub-millisecond — so the cap exists purely as a sanity bound for the pathological case where the listener was already torn down (Errno::ECONNREFUSED is fast) or the kernel netstack is somehow stuck (e.g. CI under heavy load).
1.0- WAKE_CONNECT_BURST =
2.14-B — number of wake-connect dials issued per ‘Server#stop`. In single-server / `:share` cluster mode (Darwin/BSD), one dial is enough — the listener is shared and any wake races to a parked accept call. In `:reuseport` cluster mode (Linux), the kernel hashes incoming SYNs across each worker’s per-process listener fd; one dial may hash to a sibling whose stop hasn’t progressed, leaving THIS worker’s accept thread parked. K=8 drops the miss probability to <1% for realistic worker counts (≤32 workers per host) and adds at most ~8ms to a stop call —well below the master-side ‘graceful_timeout` (30s default).
8
Class Method Summary collapse
-
.available? ⇒ Boolean
Whether the C accept loop is available and the env didn’t disable it.
-
.build_handoff_callback(server) ⇒ Object
Build the handoff callback the C loop invokes when a connection’s first request can’t be served from the static cache.
-
.build_lifecycle_callback(runtime) ⇒ Object
Build a lifecycle callback that, when invoked from the C loop with ‘(method_str, path_str)`, fires the runtime’s ‘fire_request_start` / `fire_request_end` hooks against a minimal `Hyperion::Request` value object.
-
.eligible_entry?(handler) ⇒ Boolean
2.14-A — predicate split out so specs and the engagement check can introspect single entries.
-
.eligible_route_table?(route_table) ⇒ Boolean
Whether the route table is C-loop eligible: every registered entry is either a ‘StaticEntry` (2.12-C path) or a `DynamicBlockEntry` (2.14-A path), and the table has at least one of either.
-
.io_uring_eligible? ⇒ Boolean
2.12-D — whether to engage the io_uring accept loop variant over the 2.12-C ‘accept4` loop.
-
.wake_listener(host, port, connect_timeout: WAKE_CONNECT_TIMEOUT_SECONDS, count: 1) ⇒ Object
2.14-B — Wake any thread parked in ‘accept(2)` on the listener bound at `host:port` by dialing one (or `count`) throwaway TCP connections.
Class Method Details
.available? ⇒ Boolean
Whether the C accept loop is available and the env didn’t disable it.
125 126 127 128 129 130 131 |
# File 'lib/hyperion/server/connection_loop.rb', line 125 def available? return false unless defined?(::Hyperion::Http::PageCache) return false unless ::Hyperion::Http::PageCache.respond_to?(:run_static_accept_loop) env = ENV['HYPERION_C_ACCEPT_LOOP'] env.nil? || !%w[0 off false no].include?(env.downcase) end |
.build_handoff_callback(server) ⇒ Object
Build the handoff callback the C loop invokes when a connection’s first request can’t be served from the static cache. Receives ‘(fd, partial_buffer_or_nil)` — Ruby owns the fd from that point on. We wrap the fd in a `Socket` (so `apply_timeout` and the rest of the Connection path see the same surface they always see) and dispatch through the server’s existing ‘dispatch_handed_off` helper.
238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 |
# File 'lib/hyperion/server/connection_loop.rb', line 238 def build_handoff_callback(server) lambda do |fd, partial| server.send(:dispatch_handed_off, fd, partial) rescue StandardError => e server.send(:runtime_logger).warn do { message: 'C loop handoff dispatch failed', error: e., error_class: e.class.name } end # Always close the fd if dispatch raised — Ruby owns it. begin require 'socket' ::Socket.for_fd(fd).close rescue StandardError nil end end end |
.build_lifecycle_callback(runtime) ⇒ Object
Build a lifecycle callback that, when invoked from the C loop with ‘(method_str, path_str)`, fires the runtime’s ‘fire_request_start` / `fire_request_end` hooks against a minimal `Hyperion::Request` value object. `env=nil` and the response slot carries the `:c_static` symbol (just a marker —the wire write already happened in C and we have no `[status, headers, body]` tuple to hand back).
The proc captures ‘runtime` so multi-tenant deployments with per-Server runtimes route hooks to the right observer registry. Allocation cost: one Request per request when hooks are active. The C loop only invokes this callback when `lifecycle_active?` is true; the no-hook path pays nothing.
208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 |
# File 'lib/hyperion/server/connection_loop.rb', line 208 def build_lifecycle_callback(runtime) lambda do |method_str, path_str| request = ::Hyperion::Request.new( method: method_str, path: path_str, query_string: nil, http_version: 'HTTP/1.1', headers: {}, body: nil ) if runtime.has_request_hooks? runtime.fire_request_start(request, nil) runtime.fire_request_end(request, nil, :c_static, nil) end nil rescue StandardError # Hook errors are already swallowed inside `Runtime#fire_*`; # this rescue catches Request allocation oddities so a # misbehaving observer can't take down the C loop. nil end end |
.eligible_entry?(handler) ⇒ Boolean
2.14-A — predicate split out so specs and the engagement check can introspect single entries. Lives here (rather than on the entry classes) so the eligibility surface stays in one place.
190 191 192 193 |
# File 'lib/hyperion/server/connection_loop.rb', line 190 def eligible_entry?(handler) handler.is_a?(::Hyperion::Server::RouteTable::StaticEntry) || handler.is_a?(::Hyperion::Server::RouteTable::DynamicBlockEntry) end |
.eligible_route_table?(route_table) ⇒ Boolean
Whether the route table is C-loop eligible: every registered entry is either a ‘StaticEntry` (2.12-C path) or a `DynamicBlockEntry` (2.14-A path), and the table has at least one of either. Legacy `Server.handle(method, path, handler)` registrations (where `handler` takes a `Hyperion::Request`) disable the C path — those still flow through `Connection#serve`.
173 174 175 176 177 178 179 180 181 182 183 184 185 |
# File 'lib/hyperion/server/connection_loop.rb', line 173 def eligible_route_table?(route_table) return false unless route_table any_eligible = false route_table.instance_variable_get(:@routes).each_value do |path_table| path_table.each_value do |handler| return false unless eligible_entry?(handler) any_eligible = true end end any_eligible end |
.io_uring_eligible? ⇒ Boolean
2.12-D — whether to engage the io_uring accept loop variant over the 2.12-C ‘accept4` loop. All four conditions must hold:
1. Operator opted in via `HYPERION_IO_URING_ACCEPT=1`. This
is OFF by default for 2.12.0 — flipping the default to ON
is a 2.13 decision after production-soak.
2. The C ext was compiled with `HAVE_LIBURING` (probed at
gem-install time via `extconf.rb` — needs `liburing-dev`
headers). Builds without it ship the stub method that
returns `:unavailable` regardless of the env var.
3. `Hyperion::Http::PageCache.run_static_io_uring_loop` is
defined (paranoia: the symbol always exists on builds
that loaded the C ext, but the check keeps us from
NameError'ing on partial installs).
4. A liburing runtime probe — opening a tiny ring with
`io_uring_queue_init`. The probe lives inside the C
method itself (`run_static_io_uring_loop` returns
`:unavailable` if `io_uring_queue_init` fails); we
don't pre-probe here because that would require holding
a ring open across the eligibility check, and the
penalty for "engaged but probe-fail at run time" is
one cheap fall-through to the `accept4` path.
155 156 157 158 159 160 161 162 163 164 165 |
# File 'lib/hyperion/server/connection_loop.rb', line 155 def io_uring_eligible? return false unless available? return false unless ::Hyperion::Http::PageCache.respond_to?(:run_static_io_uring_loop) return false unless ::Hyperion::Http::PageCache.respond_to?(:io_uring_loop_compiled?) && ::Hyperion::Http::PageCache.io_uring_loop_compiled? env = ENV['HYPERION_IO_URING_ACCEPT'] return false unless env %w[1 on true yes].include?(env.downcase) end |
.wake_listener(host, port, connect_timeout: WAKE_CONNECT_TIMEOUT_SECONDS, count: 1) ⇒ Object
2.14-B — Wake any thread parked in ‘accept(2)` on the listener bound at `host:port` by dialing one (or `count`) throwaway TCP connections.
Background. On Linux ≥ 6.x, calling ‘close()` on a listening socket from one thread does NOT interrupt another thread that is currently blocked in `accept(2)` on that same fd — the kernel silently dropped the close-wake guarantee that `Server#stop` (and 2.13-C’s spec teardown) had relied on. Without this helper, the C accept loop stays parked until a real connection arrives, which during a SIGTERM-driven graceful shutdown means “until SIGKILL”.
The fix is structural: dial a throwaway TCP connection at the listener’s bound address. The accept call returns with the new fd, the C loop services it (a 0-byte read drops it), then re-checks ‘hyp_cl_stop` between accepts and exits cleanly. The 2.13-C connection_loop_spec helper does the same thing in spec land — this is the production-side mirror.
Burst semantics. With SO_REUSEPORT (Linux cluster mode), the kernel hashes each SYN to one of the N still-open per-worker listeners. A single dial from worker A may hash to worker B —leaving A’s parked accept un-woken. Dialing K times (default ‘WAKE_CONNECT_BURST`) drives the miss probability down to negligible for typical worker counts.
Failure-tolerant by construction:
-
‘Errno::ECONNREFUSED` — listener already closed (the close raced ahead of us). Nothing to wake; bail out of the burst so we don’t spend the timeout budget on doomed dials.
-
‘Errno::EADDRNOTAVAIL` — interface gone. Same.
-
Connect timeout — kernel netstack is stuck; we tried, the caller’s ‘thread.join(timeout)` will surface the symptom.
-
Any other socket error — log nothing (we may be running inside a signal handler thread); just swallow.
93 94 95 96 97 98 99 100 101 102 |
# File 'lib/hyperion/server/connection_loop.rb', line 93 def wake_listener(host, port, connect_timeout: WAKE_CONNECT_TIMEOUT_SECONDS, count: 1) return unless host && port return if count <= 0 count.times do break unless dial_wake_once(host, port, connect_timeout) end nil end |