Class: Hyperion::Server

Inherits:
Object
  • Object
show all
Defined in:
lib/hyperion/server.rb,
lib/hyperion/server/route_table.rb

Overview

Phase 2a server: bind a TCPServer, accept connections, schedule each on its own fiber via Async. Multiple in-flight requests run concurrently on a single OS thread. Keep-alive is still off — connection closes after one request (Phase 2b will add keep-alive).

Phase 7 (scoped): when ‘tls:` is supplied, wrap the listener in an OpenSSL::SSL::SSLServer with ALPN advertising `h2` + `http/1.1`. After the handshake, dispatch on the negotiated protocol — http/1.1 goes through Connection (real path); h2 goes to Http2Handler (505 stub until Phase 8).

Defined Under Namespace

Classes: RouteTable

Constant Summary collapse

DEFAULT_READ_TIMEOUT_SECONDS =
30
DEFAULT_THREAD_COUNT =
5
REJECT_503 =

Pre-built minimal 503 response for the backpressure path. We bypass ResponseWriter / Rack entirely — no env build, no app dispatch, no access-log line. The bytes are frozen and reused across every rejection so the overload path stays allocation-free. Body is JSON so JSON-only API consumers don’t have to special-case the format.

lambda {
  body = +%({"error":"server_busy","retry_after_seconds":1}\n)
  body.force_encoding(Encoding::ASCII_8BIT)
  head = +"HTTP/1.1 503 Service Unavailable\r\n" \
          "content-type: application/json\r\n" \
          "content-length: #{body.bytesize}\r\n" \
          "retry-after: 1\r\n" \
          "connection: close\r\n" \
          "\r\n"
  head.force_encoding(Encoding::ASCII_8BIT)
  (head + body).freeze
}.call

Class Attribute Summary collapse

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(app:, host: '127.0.0.1', port: 9292, read_timeout: DEFAULT_READ_TIMEOUT_SECONDS, tls: nil, thread_count: DEFAULT_THREAD_COUNT, max_pending: nil, max_request_read_seconds: 60, h2_settings: nil, async_io: nil, runtime: nil, accept_fibers_per_worker: 1, h2_max_total_streams: nil, admin_listener_port: nil, admin_listener_host: '127.0.0.1', admin_token: nil, tls_session_cache_size: TLS::DEFAULT_SESSION_CACHE_SIZE, tls_ktls: :auto, io_uring: :off, max_in_flight_per_conn: nil, tls_handshake_rate_limit: :unlimited, route_table: nil, preload_static_dirs: nil) ⇒ Server

1.7.0 added kwargs (all default to current behaviour):

* `runtime:`             — `Hyperion::Runtime` instance (default
                            `Runtime.default`). Threaded through to
                            every per-connection / per-stream code
                            path so per-server metrics/logger
                            isolation works.
* `accept_fibers_per_worker:` — Integer, default 1. When > 1 and the
                            accept loop is async-wrapped, spawn N
                            accept fibers that race on the same
                            listening fd. Linear scaling on
                            `:reuseport` (Linux); Darwin honours the
                            knob silently with no scaling benefit
                            (RFC §5 Q5).
* `h2_max_total_streams:` — Integer or nil (default nil). Process-
                            wide cap on simultaneously-open h2
                            streams across all connections. nil
                            disables (current behaviour); set to
                            opt into RFC A7 admission control.
* `admin_listener_port:`  — Integer or nil (default nil). When set,
                            spawn a sibling HTTP listener on
                            `127.0.0.1:<port>` that serves only
                            `/-/quit` and `/-/metrics`. nil keeps
                            admin mounted in-app (current shape).


168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
# File 'lib/hyperion/server.rb', line 168

def initialize(app:, host: '127.0.0.1', port: 9292, read_timeout: DEFAULT_READ_TIMEOUT_SECONDS,
               tls: nil, thread_count: DEFAULT_THREAD_COUNT, max_pending: nil,
               max_request_read_seconds: 60, h2_settings: nil, async_io: nil,
               runtime: nil, accept_fibers_per_worker: 1,
               h2_max_total_streams: nil, admin_listener_port: nil,
               admin_listener_host: '127.0.0.1', admin_token: nil,
               tls_session_cache_size: TLS::DEFAULT_SESSION_CACHE_SIZE,
               tls_ktls: :auto,
               io_uring: :off,
               max_in_flight_per_conn: nil,
               tls_handshake_rate_limit: :unlimited,
               route_table: nil,
               preload_static_dirs: nil)
  validate_async_io!(async_io)
  @host                     = host
  @port                     = port
  @app                      = app
  @read_timeout             = read_timeout
  @tls                      = tls
  @thread_count             = thread_count
  @max_pending              = max_pending
  @max_request_read_seconds = max_request_read_seconds
  @h2_settings              = h2_settings
  @async_io                 = async_io
  # `@explicit_runtime` toggles between 1.7.0 isolation (an
  # explicitly-passed Runtime) and 1.6.x compat (legacy module-level
  # accessors honoured for stub seams). All record_dispatch /
  # reject_connection / log lines route through `runtime_metrics` /
  # `runtime_logger` helpers below.
  @runtime                  = runtime || Hyperion::Runtime.default
  @explicit_runtime         = !runtime.nil?
  @accept_fibers_per_worker = [accept_fibers_per_worker.to_i, 1].max
  # 2.0: `h2_max_total_streams` is normally a positive integer (the
  # default-flipped cap from `Config#finalize!`) or nil (operator
  # opted out via `h2.max_total_streams :unbounded`). Defensive
  # branch: treat the `:auto` / `:unbounded` sentinels as "no cap"
  # if a caller bypasses Config and constructs Server directly.
  @h2_admission             = if h2_max_total_streams.is_a?(Integer) && h2_max_total_streams.positive?
                                Hyperion::H2Admission.new(max_total_streams: h2_max_total_streams)
                              end
  @admin_listener_port      = admin_listener_port
  @admin_listener_host      = admin_listener_host
  @admin_token              = admin_token
  @admin_listener           = nil
  @thread_pool              = nil
  @stopped                  = false
  @tls_session_cache_size   = tls_session_cache_size
  @tls_ktls                 = tls_ktls
  @ktls_logged              = false
  # 2.3-A: resolve the io_uring accept policy. `:off` (the 2.3.0
  # default) skips the resolve step entirely so hosts without the
  # cdylib don't trigger any Fiddle.dlopen probe at boot.
  # Workers don't share rings across fork — each child opens its
  # own ring lazily on first use inside `run_accept_fiber`.
  @io_uring_policy          = io_uring
  @io_uring_active          = io_uring != :off && Hyperion::IOUring.resolve_policy!(io_uring)
  log_io_uring_state_once
  # 2.3-B: per-conn fairness cap (validated/finalized upstream by
  # `Config#finalize!`; constructor accepts the resolved value, not
  # a sentinel). nil = no cap (default). The cap propagates to
  # every Connection the ThreadPool's `:connection` worker builds.
  @max_in_flight_per_conn   = max_in_flight_per_conn
  # 2.3-B: TLS handshake CPU throttle. One limiter per worker
  # (per-Server). `:unlimited` short-circuits every `acquire_token!`
  # to true so the hot path stays branchless. Built eagerly so
  # bench harnesses can introspect via `server.tls_handshake_limiter`.
  @tls_handshake_limiter    = Hyperion::TLS::HandshakeRateLimiter.new(tls_handshake_rate_limit)
  # 2.10-D: per-instance route table (defaults to the class-level
  # singleton).  Tests can inject a fresh table to isolate
  # registrations from other examples.
  @route_table              = route_table || Hyperion::Server.route_table
  # 2.10-E: list of `{path:, immutable:}` entries the worker warms
  # into `Hyperion::Http::PageCache` at boot. Resolved by
  # `Config#resolved_preload_static_dirs` and threaded through
  # Master → Worker → Server. nil/[] = no preload (1.x cold-cache
  # behaviour).
  @preload_static_dirs      = preload_static_dirs
  @preloaded                = false
end

Class Attribute Details

.route_tableObject

2.10-D — process-wide direct-dispatch route table. Operators register routes via ‘Hyperion::Server.handle(:GET, ’/hello’, handler)‘ BEFORE forking workers; each forked worker inherits the populated table via copy-on-write. Per-Server instances can override by passing `route_table:` to the constructor (a test seam — production code uses the class singleton).

Lazily initialized so ‘require ’hyperion’‘ itself doesn’t pay the allocation when the operator never registers a direct route (the common 1.x deployment).



55
56
57
# File 'lib/hyperion/server.rb', line 55

def self.route_table
  @route_table ||= RouteTable.new
end

Instance Attribute Details

#hostObject (readonly)

Returns the value of attribute host.



43
44
45
# File 'lib/hyperion/server.rb', line 43

def host
  @host
end

#portObject (readonly)

Returns the value of attribute port.



43
44
45
# File 'lib/hyperion/server.rb', line 43

def port
  @port
end

#route_tableObject (readonly)

2.10-D — read-only handle to the per-instance route table. Connection#serve consults this after parse to decide whether to engage the direct-dispatch fast path. Defaults to the process-wide ‘Hyperion::Server.route_table` singleton.



255
256
257
# File 'lib/hyperion/server.rb', line 255

def route_table
  @route_table
end

#runtimeObject (readonly)

Returns the value of attribute runtime.



43
44
45
# File 'lib/hyperion/server.rb', line 43

def runtime
  @runtime
end

#ssl_ctxObject (readonly)

Read-only handle to the per-worker SSL context (nil when the listener is plain TCP). Exposed so the worker can call ‘Hyperion::TLS.rotate!(server.ssl_context)` from its SIGUSR2 handler without reaching into Server internals.



261
262
263
# File 'lib/hyperion/server.rb', line 261

def ssl_ctx
  @ssl_ctx
end

#tls_handshake_limiterObject (readonly)

Read-only handle for tests + bench harness introspection.



249
250
251
# File 'lib/hyperion/server.rb', line 249

def tls_handshake_limiter
  @tls_handshake_limiter
end

Class Method Details

.handle(method_sym, path, handler) ⇒ Object

2.10-D — register a direct-dispatch handler. Bypasses the Rack adapter on hit: when a request whose method + path matches this entry arrives, ‘Connection#serve` skips the env-hash build, the middleware chain, and the body-iteration loop —the handler is called directly with a `Hyperion::Request` value object.

‘method_sym` is one of `:GET`, `:POST`, `:PUT`, `:DELETE`, `:HEAD`, `:PATCH`, `:OPTIONS` (case-insensitive — `:get` works too). `path` is an exact-match String (regex / glob routing is intentionally out of scope; future work). `handler` is any object responding to `#call(request)` that returns a `[status, headers, body]` Rack tuple.

Lifecycle hooks (‘Runtime#on_request_start` / `on_request_end`) still fire on direct routes so NewRelic / AppSignal / OpenTelemetry instrumentation works regardless of dispatch shape.

On a non-match (any path / method not registered here) the request falls through to the regular Rack adapter dispatch — existing behaviour for un-handled routes is unchanged.



90
91
92
# File 'lib/hyperion/server.rb', line 90

def self.handle(method_sym, path, handler)
  route_table.register(method_sym, path, handler)
end

.handle_static(method_sym, path, body_bytes, content_type: 'text/plain') ⇒ Object

2.10-D — register a direct-dispatch route whose response is FULLY known at registration time. The full HTTP/1.1 response buffer (status line + Content-Type + Content-Length + body) is built ONCE here and stashed in a ‘RouteTable::StaticEntry`; on hit, `Connection#serve` issues a single `socket.write` of the pre-built bytes — no header build, no body iteration, zero per-request allocation past the Connection ivars.

Mirrors agoo’s optimal hello-world path. ‘body_bytes` is the response body (frozen automatically); `content_type` defaults to `text/plain`. Returns the registered `StaticEntry` for inspection.

Raises:

  • (ArgumentError)


106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
# File 'lib/hyperion/server.rb', line 106

def self.handle_static(method_sym, path, body_bytes, content_type: 'text/plain')
  raise ArgumentError, 'body_bytes must be a String' unless body_bytes.is_a?(String)
  raise ArgumentError, 'content_type must be a String' unless content_type.is_a?(String)

  body = body_bytes.dup.b.freeze
  head = +"HTTP/1.1 200 OK\r\n" \
          "content-type: #{content_type}\r\n" \
          "content-length: #{body.bytesize}\r\n" \
          "\r\n"
  head.force_encoding(Encoding::ASCII_8BIT)
  buffer = (head + body).freeze

  method_key = method_sym.to_s.upcase.to_sym
  # 2.10-F — record the headers prefix length on the StaticEntry
  # struct so HEAD-method writes can serve a headers-only prefix.
  entry = RouteTable::StaticEntry.new(method_key, path.dup.freeze, buffer, head.bytesize).freeze
  # 2.10-F — register the entry DIRECTLY (StaticEntry responds to
  # `#call`) instead of wrapping it in a closure, so the dispatch
  # path can branch on `is_a?(StaticEntry)` BEFORE invoking the
  # handler — that's what unlocks the C-ext fast path.
  route_table.register(method_sym, path, entry)
  # 2.10-F — also register HEAD for any GET registration.  HTTP
  # mandates HEAD-on-a-GET-resource, and the C fast path strips
  # the body bytes for HEAD requests inside `serve_request`.
  # Idiomatic for static-asset routes (every CDN-shaped GET URL
  # MUST also answer HEAD with the same headers).  No-op on a
  # POST/PUT/etc. registration — those don't get a HEAD twin.
  route_table.register(:HEAD, path, entry) if method_key == :GET
  # 2.10-F — fold the prebuilt response into the C-side PageCache so
  # `PageCache.serve_request` can write it without ever crossing
  # back into Ruby.  Best-effort: if the C ext isn't available
  # (JRuby / TruffleRuby), the dispatcher silently falls back to
  # the Ruby `socket.write` path that's been there since 2.10-D.
  if defined?(::Hyperion::Http::PageCache) && ::Hyperion::Http::PageCache.respond_to?(:register_prebuilt)
    ::Hyperion::Http::PageCache.register_prebuilt(path, buffer, body.bytesize)
  end
  entry
end

Instance Method Details

#adopt_listener(sock) ⇒ Object

Phase 3: workers pass in a pre-bound, SO_REUSEPORT-set socket built by Hyperion::Worker. Bypasses #listen but keeps the rest of the accept loop intact since Socket and TCPServer both quack #accept_nonblock.

Phase 8: when ‘tls:` was supplied to the constructor, also build the SSL context here so the accept loop can wrap incoming connections. Each worker builds its own context — they don’t share state.



301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
# File 'lib/hyperion/server.rb', line 301

def adopt_listener(sock)
  @server = sock
  @tcp_server = sock
  @port = case sock
          when ::TCPServer
            sock.addr[1]
          else
            sock.local_address.ip_port
          end
  if @tls
    @ssl_ctx = TLS.context(cert: @tls[:cert], key: @tls[:key], chain: @tls[:chain],
                           session_cache_size: @tls_session_cache_size,
                           ktls: @tls_ktls)
  end
  self
end

#listenObject



275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
# File 'lib/hyperion/server.rb', line 275

def listen
  tcp = ::TCPServer.new(@host, @port)
  @port = tcp.addr[1]

  if @tls
    @ssl_ctx = TLS.context(cert: @tls[:cert], key: @tls[:key], chain: @tls[:chain],
                           session_cache_size: @tls_session_cache_size,
                           ktls: @tls_ktls)
    ssl_server = ::OpenSSL::SSL::SSLServer.new(tcp, @ssl_ctx)
    ssl_server.start_immediately = false
    @server = ssl_server
    @tcp_server = tcp
  else
    @server = tcp
    @tcp_server = tcp
  end
  self
end

#preload_static!(logger: runtime_logger) ⇒ Object

2.10-E — Walk every configured preload directory, populate ‘Hyperion::Http::PageCache`, and mark every entry immutable when asked. Called from `start` once per worker. Idempotent — second call is a no-op so test harnesses + Worker respawn paths don’t re-walk the tree.

‘logger` is exposed as a kwarg purely for the spec suite; production callers omit it and the runtime logger is used.



387
388
389
390
391
392
393
394
395
# File 'lib/hyperion/server.rb', line 387

def preload_static!(logger: runtime_logger)
  return 0 if @preloaded

  @preloaded = true
  entries = @preload_static_dirs
  return 0 if entries.nil? || entries.empty?

  Hyperion::StaticPreload.run(entries, logger: logger)
end

#run_oneObject



318
319
320
321
322
323
324
325
326
# File 'lib/hyperion/server.rb', line 318

def run_one
  Async do
    socket = blocking_accept
    next unless socket

    apply_timeout(socket)
    dispatch(socket)
  end.wait
end

#startObject



328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
# File 'lib/hyperion/server.rb', line 328

def start
  listen unless @server
  # 2.10-E: warm the page cache before any request can land. Idempotent
  # via `@preloaded`, so repeated `start` calls (test harnesses,
  # Worker#run respawn) don't re-walk the tree. Runs after `listen`
  # (so `@server` exists for the operator's introspection hooks if any
  # future runtime fires off boot-side instrumentation) but before the
  # accept loop fires up — first request hits warm cache.
  preload_static!
  if @thread_count.positive?
    @thread_pool = ThreadPool.new(size: @thread_count, max_pending: @max_pending,
                                  max_in_flight_per_conn: @max_in_flight_per_conn,
                                  route_table: @route_table)
  end
  maybe_start_admin_listener

  if @tls || @async_io
    # TLS path: ALPN may pick `h2`, and h2 spawns one fiber per stream
    # inside Http2Handler. Keep the Async wrapper so the scheduler is
    # available for those fibers and for handshake yields. Plain
    # HTTP/1.1-over-TLS dispatch is also handled inline on the calling
    # fiber by default in 1.4.0+ (see #dispatch) — fiber-cooperative
    # libraries (async-pg, async-redis) work without --async-io.
    #
    # async_io: true: operator opt-in for plain HTTP/1.1. The Async wrap
    # is required when callers want fiber cooperative I/O — e.g.
    # `hyperion-async-pg` yielding while a Postgres query is in flight.
    # Pays ~5% throughput vs the raw-loop fast path; in exchange one
    # OS thread can serve N concurrent in-flight DB queries instead of 1.
    start_async_loop
  else
    # Plain HTTP/1.1, async_io: nil (default with no TLS) or
    # async_io: false (explicit opt-out): the worker thread owns each
    # connection for its lifetime, so the Async wrapper adds zero value
    # (no fibers ever run on this loop's task). Skip it — pure
    # IO.select + accept_nonblock shaves measurable overhead off the
    # accept hot path.
    start_raw_loop
  end
ensure
  @thread_pool&.shutdown
  @admin_listener&.stop
end

#stopObject



372
373
374
375
376
377
# File 'lib/hyperion/server.rb', line 372

def stop
  @stopped = true
  @server&.close
  @server = nil
  @tcp_server = nil
end