hyperion-rb 1.6.2 → 2.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4563 -0
  3. data/README.md +189 -13
  4. data/ext/hyperion_h2_codec/Cargo.lock +7 -0
  5. data/ext/hyperion_h2_codec/Cargo.toml +33 -0
  6. data/ext/hyperion_h2_codec/extconf.rb +73 -0
  7. data/ext/hyperion_h2_codec/src/frames.rs +140 -0
  8. data/ext/hyperion_h2_codec/src/hpack/huffman.rs +161 -0
  9. data/ext/hyperion_h2_codec/src/hpack.rs +457 -0
  10. data/ext/hyperion_h2_codec/src/lib.rs +296 -0
  11. data/ext/hyperion_http/extconf.rb +28 -0
  12. data/ext/hyperion_http/h2_codec_glue.c +408 -0
  13. data/ext/hyperion_http/page_cache.c +1125 -0
  14. data/ext/hyperion_http/parser.c +473 -38
  15. data/ext/hyperion_http/sendfile.c +982 -0
  16. data/ext/hyperion_http/websocket.c +493 -0
  17. data/ext/hyperion_io_uring/Cargo.lock +33 -0
  18. data/ext/hyperion_io_uring/Cargo.toml +34 -0
  19. data/ext/hyperion_io_uring/extconf.rb +74 -0
  20. data/ext/hyperion_io_uring/src/lib.rs +316 -0
  21. data/lib/hyperion/adapter/rack.rb +370 -42
  22. data/lib/hyperion/admin_listener.rb +207 -0
  23. data/lib/hyperion/admin_middleware.rb +36 -7
  24. data/lib/hyperion/cli.rb +310 -11
  25. data/lib/hyperion/config.rb +440 -14
  26. data/lib/hyperion/connection.rb +679 -22
  27. data/lib/hyperion/deprecations.rb +81 -0
  28. data/lib/hyperion/dispatch_mode.rb +165 -0
  29. data/lib/hyperion/fiber_local.rb +75 -13
  30. data/lib/hyperion/h2_admission.rb +77 -0
  31. data/lib/hyperion/h2_codec.rb +452 -0
  32. data/lib/hyperion/http/page_cache.rb +122 -0
  33. data/lib/hyperion/http/sendfile.rb +696 -0
  34. data/lib/hyperion/http2/native_hpack_adapter.rb +70 -0
  35. data/lib/hyperion/http2_handler.rb +368 -9
  36. data/lib/hyperion/io_uring.rb +317 -0
  37. data/lib/hyperion/lint_wrapper_pool.rb +126 -0
  38. data/lib/hyperion/master.rb +96 -9
  39. data/lib/hyperion/metrics/path_templater.rb +68 -0
  40. data/lib/hyperion/metrics.rb +256 -0
  41. data/lib/hyperion/prometheus_exporter.rb +150 -0
  42. data/lib/hyperion/request.rb +13 -0
  43. data/lib/hyperion/response_writer.rb +477 -16
  44. data/lib/hyperion/runtime.rb +195 -0
  45. data/lib/hyperion/server/route_table.rb +179 -0
  46. data/lib/hyperion/server.rb +519 -55
  47. data/lib/hyperion/static_preload.rb +133 -0
  48. data/lib/hyperion/thread_pool.rb +61 -7
  49. data/lib/hyperion/tls.rb +343 -1
  50. data/lib/hyperion/version.rb +1 -1
  51. data/lib/hyperion/websocket/close_codes.rb +71 -0
  52. data/lib/hyperion/websocket/connection.rb +876 -0
  53. data/lib/hyperion/websocket/frame.rb +356 -0
  54. data/lib/hyperion/websocket/handshake.rb +525 -0
  55. data/lib/hyperion/worker.rb +111 -9
  56. data/lib/hyperion.rb +137 -3
  57. metadata +50 -1
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hyperion
4
+ # Runtime services container — holds per-process or per-server services
5
+ # (metrics sink, logger, clock) that used to live as module-level
6
+ # singletons on `Hyperion`.
7
+ #
8
+ # Pre-1.7 every Connection / Http2Handler / ResponseWriter reached for
9
+ # `Hyperion.metrics` / `Hyperion.logger` directly. That made:
10
+ #
11
+ # 1. Long-lived keep-alive connections impossible to swap services on
12
+ # mid-flight — `Connection#initialize` cached the singletons in ivars
13
+ # and never re-read them.
14
+ # 2. Multi-tenant apps unable to give each `Hyperion::Server` its own
15
+ # metrics sink — the module-level singleton is process-global.
16
+ # 3. Tests messy: stubbing `Hyperion.metrics` is global state mutation
17
+ # that bleeds across examples unless every spec resets explicitly.
18
+ #
19
+ # 1.7.0 introduces this Runtime and adds a `runtime:` kwarg to `Server`
20
+ # and `Connection`. The default is `Runtime.default`, a process-wide
21
+ # singleton — back-compat with the 1.6.x behaviour. Tests and library
22
+ # users construct their own `Runtime.new(metrics: …, logger: …)` and
23
+ # pass it explicitly; that runtime is then used exclusively by the
24
+ # connection/server it was given to.
25
+ #
26
+ # `Runtime.default` is intentionally NOT frozen after first read.
27
+ # RFC §5 Q4: tests need to swap metrics/logger on the default runtime,
28
+ # and freezing for no real safety benefit just adds ceremony.
29
+ #
30
+ # Module-level `Hyperion.metrics` / `Hyperion.logger` (and their
31
+ # writers) keep working — they delegate to `Runtime.default`. They're
32
+ # marked for deprecation in 1.8.0 and removal in 2.0.
33
+ class Runtime
34
+ attr_reader :clock
35
+ attr_writer :metrics, :logger
36
+
37
+ # The default Runtime's metrics / logger readers honour module-level
38
+ # ivar overrides on `Hyperion` itself. This preserves a back-compat
39
+ # seam for 1.6.x specs that swap by reaching into private internals
40
+ # via `Hyperion.instance_variable_set(:@metrics, …)` — the new
41
+ # Runtime-routed code paths (Server / Connection / Http2Handler) all
42
+ # read `runtime.metrics`, so without this the override would only
43
+ # affect the legacy `Hyperion.metrics` reader and the new code path
44
+ # would still write to the original Runtime-owned object.
45
+ #
46
+ # Custom Runtimes (`Hyperion::Runtime.new(...)`) ignore the override
47
+ # entirely — they're per-Server isolated by design.
48
+ def metrics
49
+ override = Hyperion.instance_variable_get(:@metrics) if default?
50
+ override || @metrics
51
+ end
52
+
53
+ def logger
54
+ override = Hyperion.instance_variable_get(:@logger) if default?
55
+ override || @logger
56
+ end
57
+
58
+ # True when this runtime is `Runtime.default`. The default runtime
59
+ # is the one consulted by legacy module-level accessors — see the
60
+ # `metrics` / `logger` readers above.
61
+ def default?
62
+ Runtime.instance_variable_get(:@default).equal?(self)
63
+ end
64
+
65
+ # Process-wide default Runtime. Lazily initialized on first read.
66
+ # Module-level `Hyperion.metrics` / `Hyperion.logger` accessors and
67
+ # writers all delegate to this instance, so legacy callers in 1.6.x
68
+ # shape (`Hyperion.metrics = MyAdapter.new`) keep working without
69
+ # any source change.
70
+ #
71
+ # Tests can mutate `Runtime.default.metrics = …` directly or replace
72
+ # the whole default with `Runtime.default = Runtime.new(...)` (writer
73
+ # below). Resetting between examples is on the test author — there's
74
+ # no auto-reset because the singleton is part of the public surface.
75
+ def self.default
76
+ @default ||= new
77
+ end
78
+
79
+ # Test seam: replace the process-wide default. Used in specs that
80
+ # need to inject a known-state Runtime without reaching into
81
+ # `@default` directly.
82
+ def self.default=(runtime)
83
+ raise ArgumentError, 'expected a Hyperion::Runtime' unless runtime.is_a?(Runtime)
84
+
85
+ @default = runtime
86
+ end
87
+
88
+ # Test seam: clear the memoized default so the next `default` call
89
+ # builds a fresh one. Equivalent to `default = Runtime.new` but
90
+ # without forcing the caller to allocate.
91
+ def self.reset_default!
92
+ @default = nil
93
+ end
94
+
95
+ def initialize(metrics: nil, logger: nil, clock: Process)
96
+ @metrics = metrics || Hyperion::Metrics.new
97
+ @logger = logger || Hyperion::Logger.new
98
+ @clock = clock
99
+ # 2.5-C: per-request lifecycle hooks. Pre-allocated empty Arrays so
100
+ # `has_request_hooks?` can be a single `any?` check on each side
101
+ # — no nil-guard, no lazy-init branch on the hot path. Hooks are
102
+ # appended in registration order; FIFO dispatch.
103
+ @before_request_hooks = []
104
+ @after_request_hooks = []
105
+ end
106
+
107
+ # 2.5-C — register a Proc to fire AFTER env is built but BEFORE
108
+ # `app.call`. Receives `(request, env)` where `request` is the
109
+ # parsed `Hyperion::Request` and `env` is the mutable Rack env Hash
110
+ # — callbacks may stash trace context (NewRelic transactions,
111
+ # OpenTelemetry spans, AppSignal/DataDog handles) into the env so
112
+ # the corresponding after-hook can finish them.
113
+ #
114
+ # Hook errors are caught and logged; they DO NOT abort dispatch.
115
+ # Multiple hooks fire in registration order (FIFO).
116
+ def on_request_start(&block)
117
+ raise ArgumentError, 'block required' unless block
118
+
119
+ @before_request_hooks << block
120
+ block
121
+ end
122
+
123
+ # 2.5-C — register a Proc to fire AFTER `app.call` returns or
124
+ # raises. Receives `(request, env, response, error)`:
125
+ #
126
+ # * `response` is the `[status, headers, body]` tuple when the
127
+ # app returned normally, or `nil` when the app raised.
128
+ # * `error` is the `StandardError` the app raised, or `nil` on
129
+ # success.
130
+ #
131
+ # Use this to finish trace spans, attach response codes to the
132
+ # active transaction, increment per-route counters, etc. Hook
133
+ # errors are caught and logged — they never break dispatch.
134
+ def on_request_end(&block)
135
+ raise ArgumentError, 'block required' unless block
136
+
137
+ @after_request_hooks << block
138
+ block
139
+ end
140
+
141
+ # 2.5-C — zero-cost guard used by Adapter::Rack#call. When both
142
+ # arrays are empty (the default — no hooks registered), the
143
+ # adapter skips the dispatch entirely: no Array iteration, no
144
+ # Proc invocation, no allocation. The audit harness
145
+ # (`yjit_alloc_audit_spec`) verifies the per-request alloc count
146
+ # is unchanged from 2.5-B.
147
+ def has_request_hooks?
148
+ !@before_request_hooks.empty? || !@after_request_hooks.empty?
149
+ end
150
+
151
+ # 2.5-C — invoked by Adapter::Rack#call after env is built. Wraps
152
+ # each hook in a rescue so a misbehaving observer can't break the
153
+ # dispatch chain — failures are logged with the block's source
154
+ # location so operators can identify which hook went wrong.
155
+ def fire_request_start(request, env)
156
+ @before_request_hooks.each do |hook|
157
+ hook.call(request, env)
158
+ rescue StandardError => e
159
+ log_hook_failure(:before_request, hook, e)
160
+ end
161
+ nil
162
+ end
163
+
164
+ # 2.5-C — invoked by Adapter::Rack#call after `app.call` returns
165
+ # (or raises). `response` is the [status, headers, body] tuple on
166
+ # success, `nil` on error; `error` is the raised exception or nil.
167
+ # Same rescue contract as `fire_request_start`: each hook runs
168
+ # independently; one failure does not prevent later hooks from
169
+ # firing or the response from being written.
170
+ def fire_request_end(request, env, response, error)
171
+ @after_request_hooks.each do |hook|
172
+ hook.call(request, env, response, error)
173
+ rescue StandardError => e
174
+ log_hook_failure(:after_request, hook, e)
175
+ end
176
+ nil
177
+ end
178
+
179
+ private
180
+
181
+ def log_hook_failure(phase, hook, error)
182
+ file, line = hook.source_location
183
+ logger.error do
184
+ {
185
+ message: 'request lifecycle hook raised',
186
+ phase: phase,
187
+ hook_source: file ? "#{file}:#{line}" : 'unknown',
188
+ error: error.message,
189
+ error_class: error.class.name,
190
+ backtrace: (error.backtrace || []).first(5).join(' | ')
191
+ }
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hyperion
4
+ class Server
5
+ # 2.10-D — direct-dispatch route registry. Mirrors agoo's
6
+ # `Agoo::Server.handle(:GET, "/hello", handler)` design: a lookup
7
+ # table indexed by HTTP method then exact-match path. When a
8
+ # match hits inside `Connection#serve` (after parse, before the
9
+ # Rack adapter), the dispatcher skips the env-hash build, the
10
+ # middleware chain, and the body-iteration overhead — the
11
+ # handler is called directly with a `Hyperion::Request` value
12
+ # object and returns either a `[status, headers, body]` Rack
13
+ # tuple or a sentinel that points at a pre-built static
14
+ # response buffer (the `handle_static` path, where the response
15
+ # bytes are baked at registration time so the hot path is one
16
+ # `socket.write` syscall and zero Ruby allocation past the
17
+ # Connection ivars).
18
+ #
19
+ # The table is per-process: forked workers each inherit a copy
20
+ # of the parent's table at fork time (no IPC, no shared memory)
21
+ # so registrations made before `Server.start` propagate to every
22
+ # worker via copy-on-write. Registrations made AFTER fork (e.g.
23
+ # from `on_worker_boot`) only affect the calling worker — by
24
+ # design, this is the operator's escape hatch for per-worker
25
+ # routing. The hot-path lookup is a two-Hash-key access (O(1));
26
+ # write paths are guarded by a Mutex so a registration racing
27
+ # with an in-flight request lookup is safe.
28
+ #
29
+ # Mutability invariant: registrations replace any prior entry
30
+ # for the same `(method, path)` tuple — last writer wins. No
31
+ # delete API for now (operator restarts to clear), keeps the
32
+ # public surface minimal.
33
+ class RouteTable
34
+ # The seven HTTP methods agoo's `Server.handle` accepts. We
35
+ # match agoo's surface verbatim so apps porting their
36
+ # registrations across servers don't have to relearn the
37
+ # matrix. `OPTIONS` is included for CORS preflight handlers
38
+ # (commonly registered as direct routes since they have no
39
+ # business model).
40
+ KNOWN_METHODS = %i[GET POST PUT DELETE HEAD PATCH OPTIONS].freeze
41
+
42
+ # 2.10-D — sentinel result returned by `Server.handle_static`'s
43
+ # internal handler. When `Connection#serve` sees it, the writer
44
+ # short-circuits to a single `socket.write(buf)` of the
45
+ # pre-built response buffer — no header build, no body
46
+ # iteration. Wrapping the buffer in a small struct (rather
47
+ # than returning the raw String from `handle`) keeps the
48
+ # `[status, headers, body]` shape contract visible while
49
+ # giving the dispatcher a single `is_a?` branch to engage the
50
+ # one-syscall fast path.
51
+ # 2.10-F adds `headers_len` so the C fast path
52
+ # (`PageCache.serve_request`) can write the headers-only prefix
53
+ # for HEAD requests without reparsing the buffer. Defaults to
54
+ # `buffer.bytesize` for back-compat with callers that
55
+ # constructed StaticEntry the 2.10-D way (3 args, no body
56
+ # split) — those entries fall back to writing the whole buffer
57
+ # on HEAD too, which is RFC-correct (HEAD MAY include the body
58
+ # so long as Content-Length matches; the spec only forbids the
59
+ # SERVER from sending body bytes the client didn't ask for).
60
+ StaticEntry = Struct.new(:method, :path, :buffer, :headers_len) do
61
+ # Returns the pre-built response bytes ready for one
62
+ # `socket.write` call. Always frozen.
63
+ def response_bytes
64
+ buffer
65
+ end
66
+
67
+ # 2.10-F — StaticEntry responds to `#call` so it can be
68
+ # registered directly in the route table (instead of via a
69
+ # closure wrapping it). Returning `self` keeps the
70
+ # `[status, headers, body]` contract: `dispatch_direct!`'s
71
+ # is_a?(StaticEntry) branch handles the wire write. Pre-
72
+ # 2.10-F callers that registered via
73
+ # `Server.handle_static` still work — that registration
74
+ # path now stores the entry directly and the route table's
75
+ # `respond_to?(:call)` invariant is preserved.
76
+ def call(_request)
77
+ self
78
+ end
79
+
80
+ # 2.10-F — bytes-count of the headers-only prefix. Used by
81
+ # callers that reach the StaticEntry directly (specs, custom
82
+ # writers); the C fast path reads the C-side `headers_len`
83
+ # mirror that `PageCache.register_prebuilt` records.
84
+ def headers_bytesize
85
+ headers_len || buffer.bytesize
86
+ end
87
+ end
88
+
89
+ def initialize
90
+ # Per-method Hash so the lookup is `@routes[:GET][path]`
91
+ # — two integer-keyed-Hash hits. Pre-allocate the seven
92
+ # slots so the request hot path never lazily creates an
93
+ # entry under a misspelled method (we just miss).
94
+ @routes = KNOWN_METHODS.each_with_object({}) { |m, h| h[m] = {} }
95
+ @mutex = Mutex.new
96
+ end
97
+
98
+ # Register a direct-dispatch handler for the given method +
99
+ # path. `handler` must respond to `#call(request)` and return
100
+ # either:
101
+ #
102
+ # * a `[status, headers, body]` Rack tuple — the dispatcher
103
+ # writes it via the standard ResponseWriter (no env hash,
104
+ # no middleware), or
105
+ # * a `StaticEntry` (built only via `Server.handle_static`)
106
+ # — the dispatcher emits the pre-built bytes in one
107
+ # write syscall.
108
+ #
109
+ # `method_sym` is upper-cased before lookup so callers may pass
110
+ # `:get` or `'get'` interchangeably with `:GET`.
111
+ def register(method_sym, path, handler)
112
+ method_key = normalize_method(method_sym)
113
+ raise ArgumentError, "unknown method #{method_sym.inspect}" unless KNOWN_METHODS.include?(method_key)
114
+ raise ArgumentError, 'path must be a String' unless path.is_a?(String)
115
+ raise ArgumentError, 'handler must respond to #call' unless handler.respond_to?(:call)
116
+
117
+ @mutex.synchronize { @routes[method_key][path.dup.freeze] = handler }
118
+ handler
119
+ end
120
+
121
+ # Hot-path lookup. `method_str` is the request method as the
122
+ # parser produced it (a String like `'GET'`); `path` is the
123
+ # request path String. Returns the registered handler or nil.
124
+ #
125
+ # No mutex on the read side — Ruby Hash reads under MRI are
126
+ # safe against a concurrent write that's mutex-guarded
127
+ # (the GVL pins the writer during the bucket update), and
128
+ # the cost of a Mutex acquire on every request would defeat
129
+ # the whole point of the fast path.
130
+ def lookup(method_str, path)
131
+ method_key = METHOD_LOOKUP[method_str] || normalize_method(method_str)
132
+ table = @routes[method_key]
133
+ return nil unless table
134
+
135
+ table[path]
136
+ end
137
+
138
+ # Inspection helper — returns the count of registered routes
139
+ # across all methods. Used by specs and the bench harness
140
+ # to assert registrations took effect.
141
+ def size
142
+ @routes.values.sum(&:size)
143
+ end
144
+
145
+ # Clear all registrations. Test / spec seam — production
146
+ # code restarts the process to drop routes.
147
+ def clear
148
+ @mutex.synchronize { @routes.each_value(&:clear) }
149
+ nil
150
+ end
151
+
152
+ private
153
+
154
+ # Pre-built lookup for the seven canonical method strings the
155
+ # parser emits. Skips the Symbol allocation + upcase that
156
+ # `normalize_method` does for unrecognised inputs. Frozen so
157
+ # the table itself is shared across all RouteTable instances
158
+ # (one allocation, process-wide).
159
+ METHOD_LOOKUP = {
160
+ 'GET' => :GET,
161
+ 'POST' => :POST,
162
+ 'PUT' => :PUT,
163
+ 'DELETE' => :DELETE,
164
+ 'HEAD' => :HEAD,
165
+ 'PATCH' => :PATCH,
166
+ 'OPTIONS' => :OPTIONS
167
+ }.freeze
168
+
169
+ def normalize_method(value)
170
+ case value
171
+ when Symbol
172
+ KNOWN_METHODS.include?(value) ? value : value.to_s.upcase.to_sym
173
+ else
174
+ value.to_s.upcase.to_sym
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end