hyperion-rb 1.6.2 → 2.11.0

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 +4768 -0
  3. data/README.md +222 -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 +499 -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 +618 -19
  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,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'socket'
4
+ require 'rack/utils'
5
+
6
+ module Hyperion
7
+ # Sibling HTTP listener for admin endpoints (RFC A8). When the operator
8
+ # sets `admin.listener_port`, Hyperion spawns a small dedicated server
9
+ # on `127.0.0.1:<port>` that handles ONLY `/-/quit` and `/-/metrics`
10
+ # (Prometheus exposition). The application listener is unchanged —
11
+ # admin paths can stay mounted in-app simultaneously, depending on
12
+ # whether `AdminMiddleware` is wrapped.
13
+ #
14
+ # **Why a sibling listener, not just middleware?** Three failure modes
15
+ # AdminMiddleware can't escape on its own:
16
+ #
17
+ # 1. Misordered `Rack::Builder` middleware can disable admin (a
18
+ # `use` of a custom 404 middleware in front of Hyperion's wrap).
19
+ # 2. Request-headers-logging middleware (`Rack::CommonLogger` derivs,
20
+ # OpenTelemetry HTTP instrumentation, app-level header dumpers)
21
+ # logs the `X-Hyperion-Admin-Token` value to access logs. The
22
+ # sibling listener's path never goes through that pipeline.
23
+ # 3. Operators who don't want to manually 404 `/-/*` at the edge
24
+ # proxy can simply not expose this port.
25
+ #
26
+ # **Defence-in-depth, not a replacement for network isolation.** The
27
+ # bearer token still gates every request. Operators MUST keep this
28
+ # port on a private interface (default `127.0.0.1`) or behind an
29
+ # authenticating reverse proxy. Same `secure_match?` logic as
30
+ # AdminMiddleware.
31
+ #
32
+ # **Implementation note.** Single accept thread, no Rack pipeline. We
33
+ # parse the request line + Authorization header by hand because:
34
+ #
35
+ # * The two endpoints are trivial (drain via SIGTERM; render
36
+ # pre-formatted Prometheus text).
37
+ # * Pulling in a full Rack stack inside Hyperion to serve two
38
+ # endpoints would re-introduce the misordering footgun (#1 above).
39
+ # * The bytes per response are tiny — encryption / chunked encoding
40
+ # / keep-alive aren't needed.
41
+ #
42
+ # Returns 202 + `{"status":"draining"}` on quit, 200 + Prometheus text
43
+ # on metrics, 401 on bearer mismatch, 404 on anything else.
44
+ class AdminListener
45
+ PATH_QUIT = '/-/quit'
46
+ PATH_METRICS = '/-/metrics'
47
+
48
+ METRICS_CONTENT_TYPE = 'text/plain; version=0.0.4; charset=utf-8'
49
+ JSON_CONTENT_TYPE = 'application/json'
50
+
51
+ UNAUTHORIZED_BODY = %({"error":"unauthorized"}\n)
52
+ NOT_FOUND_BODY = %({"error":"not_found"}\n)
53
+ DRAINING_BODY = %({"status":"draining"}\n)
54
+ SIGNAL_FAILED = %({"error":"signal_failed"}\n)
55
+
56
+ attr_reader :host, :port
57
+
58
+ def initialize(host:, port:, token:, runtime: nil, signal_target: nil)
59
+ raise ArgumentError, 'admin listener token must be a non-empty String' if token.nil? || token.to_s.empty?
60
+
61
+ @host = host
62
+ @port = port
63
+ @token = token.to_s
64
+ @runtime = runtime || Hyperion::Runtime.default
65
+ @signal_target = signal_target
66
+ @stopped = false
67
+ end
68
+
69
+ # Bind + spawn the accept thread. Returns self so callers can chain
70
+ # `.start.join` or just hold the reference for `#stop`.
71
+ def start
72
+ @server = ::TCPServer.new(@host, @port)
73
+ # Honour port: 0 (let kernel pick) — the test suite uses this so
74
+ # multiple AdminListeners can coexist without port conflicts.
75
+ @port = @server.addr[1]
76
+
77
+ @thread = Thread.new { accept_loop }
78
+ @thread.report_on_exception = false
79
+ @runtime.logger.info do
80
+ { message: 'admin listener started', host: @host, port: @port,
81
+ paths: [PATH_QUIT, PATH_METRICS] }
82
+ end
83
+ self
84
+ end
85
+
86
+ def stop
87
+ @stopped = true
88
+ @server&.close
89
+ @thread&.join(5)
90
+ nil
91
+ rescue StandardError
92
+ nil
93
+ end
94
+
95
+ private
96
+
97
+ def accept_loop
98
+ until @stopped
99
+ begin
100
+ client = @server.accept
101
+ rescue IOError, Errno::EBADF
102
+ break # listener closed
103
+ rescue StandardError => e
104
+ @runtime.logger.warn { { message: 'admin listener accept error', error: e.message } }
105
+ next
106
+ end
107
+
108
+ begin
109
+ handle(client)
110
+ rescue StandardError => e
111
+ @runtime.logger.warn { { message: 'admin listener handler error', error: e.message } }
112
+ ensure
113
+ begin
114
+ client.close unless client.closed?
115
+ rescue StandardError
116
+ nil
117
+ end
118
+ end
119
+ end
120
+ end
121
+
122
+ # Parse one request off the socket and dispatch. We deliberately don't
123
+ # implement keep-alive — `Connection: close` on every response is fine
124
+ # for an admin endpoint that handles ones-of operator probes.
125
+ def handle(socket)
126
+ request_line = socket.gets("\r\n", 1024)
127
+ return write_response(socket, 400, JSON_CONTENT_TYPE, NOT_FOUND_BODY) if request_line.nil?
128
+
129
+ method, path, _http = request_line.strip.split(' ', 3)
130
+ headers = read_headers(socket)
131
+ # Drain Content-Length body if present (POST /-/quit may carry one).
132
+ content_length = headers['content-length'].to_i
133
+ socket.read(content_length) if content_length.positive?
134
+
135
+ provided = (headers['x-hyperion-admin-token'] || '').to_s
136
+ return write_response(socket, 401, JSON_CONTENT_TYPE, UNAUTHORIZED_BODY) unless secure_match?(provided)
137
+
138
+ if path == PATH_QUIT && method == 'POST'
139
+ handle_quit(socket)
140
+ elsif path == PATH_METRICS && method == 'GET'
141
+ handle_metrics(socket)
142
+ else
143
+ write_response(socket, 404, JSON_CONTENT_TYPE, NOT_FOUND_BODY)
144
+ end
145
+ end
146
+
147
+ def read_headers(socket)
148
+ headers = {}
149
+ while (line = socket.gets("\r\n", 8192))
150
+ line = line.strip
151
+ break if line.empty?
152
+
153
+ name, value = line.split(':', 2)
154
+ next if name.nil? || value.nil?
155
+
156
+ headers[name.strip.downcase] = value.strip
157
+ end
158
+ headers
159
+ end
160
+
161
+ def handle_quit(socket)
162
+ target = @signal_target || Hyperion.master_pid
163
+ @runtime.logger.info { { message: 'admin drain requested', target_pid: target, via: 'sibling-listener' } }
164
+ begin
165
+ Process.kill('TERM', target)
166
+ rescue StandardError => e
167
+ @runtime.logger.warn { { message: 'admin drain signal failed', error: e.message } }
168
+ return write_response(socket, 500, JSON_CONTENT_TYPE, SIGNAL_FAILED)
169
+ end
170
+
171
+ write_response(socket, 202, JSON_CONTENT_TYPE, DRAINING_BODY)
172
+ end
173
+
174
+ def handle_metrics(socket)
175
+ body = Hyperion::PrometheusExporter.render(@runtime.metrics.snapshot)
176
+ write_response(socket, 200, METRICS_CONTENT_TYPE, body)
177
+ end
178
+
179
+ def secure_match?(provided)
180
+ return false if provided.empty?
181
+ return false unless provided.bytesize == @token.bytesize
182
+
183
+ Rack::Utils.secure_compare(provided, @token)
184
+ end
185
+
186
+ def write_response(socket, status, content_type, body)
187
+ reason = case status
188
+ when 200 then 'OK'
189
+ when 202 then 'Accepted'
190
+ when 400 then 'Bad Request'
191
+ when 401 then 'Unauthorized'
192
+ when 404 then 'Not Found'
193
+ when 500 then 'Internal Server Error'
194
+ else 'Unknown'
195
+ end
196
+ head = +"HTTP/1.1 #{status} #{reason}\r\n" \
197
+ "content-type: #{content_type}\r\n" \
198
+ "content-length: #{body.bytesize}\r\n" \
199
+ "connection: close\r\n\r\n"
200
+ socket.write(head)
201
+ socket.write(body)
202
+ rescue StandardError
203
+ # Peer hung up — nothing to do.
204
+ nil
205
+ end
206
+ end
207
+ end
@@ -36,8 +36,9 @@ module Hyperion
36
36
 
37
37
  @app = app
38
38
  @token = token.to_s
39
- # Override hook for tests. Defaults to ppid in worker context, pid
40
- # for single-worker context (caller decides).
39
+ # Override hook for tests. When unset, resolve_signal_target consults
40
+ # Hyperion.master_pid (master writes itself there at boot, exports
41
+ # HYPERION_MASTER_PID into ENV so forked workers inherit it).
41
42
  @signal_target = signal_target
42
43
  end
43
44
 
@@ -85,7 +86,16 @@ module Hyperion
85
86
  end
86
87
 
87
88
  def handle_metrics
88
- body = PrometheusExporter.render(Hyperion.stats)
89
+ # 2.4-C: render the full surface — legacy counters + histograms +
90
+ # gauges + labeled counters. The exporter falls back to the legacy
91
+ # `render(stats)` body when the sink doesn't expose the new
92
+ # snapshot helpers (defensive: third-party Metrics adapters that
93
+ # quack-implement the 1.x surface still emit a valid scrape body).
94
+ body = if Hyperion.metrics.respond_to?(:histogram_snapshot)
95
+ PrometheusExporter.render_full(Hyperion.metrics)
96
+ else
97
+ PrometheusExporter.render(Hyperion.stats)
98
+ end
89
99
  [200, { 'content-type' => METRICS_CONTENT_TYPE }, [body]]
90
100
  end
91
101
 
@@ -101,10 +111,29 @@ module Hyperion
101
111
  def resolve_signal_target
102
112
  return @signal_target if @signal_target
103
113
 
104
- # In a forked worker, ppid IS the master; in single-worker mode,
105
- # the master + worker are the same process — signal self.
106
- ppid = Process.ppid
107
- ppid > 1 ? ppid : Process.pid
114
+ # Always prefer the explicitly-recorded master PID. In a worker the
115
+ # master wrote `HYPERION_MASTER_PID` into ENV before forking, so
116
+ # `Hyperion.master_pid` returns the master from inside the worker
117
+ # via inherited ENV. In single-mode the master IS the running
118
+ # process and `master_pid!` set the ivar in #run_single.
119
+ #
120
+ # Why not Process.ppid? Two failure modes:
121
+ #
122
+ # 1. Master runs as PID 1 inside containerd / Docker (default
123
+ # shape: `CMD ["hyperion", "config.ru"]`). A worker's
124
+ # `Process.ppid` is 1 — and the previous fallback
125
+ # `ppid > 1 ? ppid : Process.pid` then mistargeted the
126
+ # *worker itself* on a graceful drain, so SIGTERM killed the
127
+ # worker but left the master + the rest of the workers intact.
128
+ # Operators saw the admin endpoint return 202 "draining" and
129
+ # nothing happen at the fleet level.
130
+ #
131
+ # 2. Single-worker mode has no parent Hyperion process; ppid is
132
+ # whatever launched us (shell, systemd, a supervisor). Killing
133
+ # that is at best confusing, at worst destructive.
134
+ #
135
+ # Hyperion.master_pid handles both correctly without any ppid math.
136
+ Hyperion.master_pid
108
137
  end
109
138
  end
110
139
  end
data/lib/hyperion/cli.rb CHANGED
@@ -20,10 +20,45 @@ module Hyperion
20
20
  config = config_path ? Hyperion::Config.load(config_path) : Hyperion::Config.new
21
21
  config.merge_cli!(cli_opts)
22
22
 
23
+ # 2.2.x fix-C: env-var override for the kTLS knob so operators can
24
+ # A/B kernel-TLS vs userspace SSL_write without rewriting their
25
+ # config file. Useful for the large-payload TLS bench harness
26
+ # (`bench/tls_static_1m.ru`, `bench/tls_json_50k.ru`).
27
+ apply_ktls_env_override!(config)
28
+
29
+ # 2.2.x fix-D: env-var override for the `h2.max_total_streams`
30
+ # admission cap. Mirrors `HYPERION_TLS_KTLS` from fix-C — operators
31
+ # running h2load or long-fan-out workloads can lift the 2.0.0
32
+ # default (`max_concurrent_streams × workers × 4`) without
33
+ # rewriting a config file. `HYPERION_H2_MAX_TOTAL_STREAMS=unbounded`
34
+ # restores pre-2.0 behaviour. Applied AFTER `merge_cli!` so it
35
+ # takes precedence over the CLI flag too — the env var is the
36
+ # outermost knob (CI/bench harness), the flag is the inner knob
37
+ # (per-invocation), and the config file is innermost.
38
+ apply_h2_max_total_streams_env_override!(config)
39
+
40
+ # 2.3-A: env-var override for the io_uring accept policy. Same
41
+ # grammar as `HYPERION_TLS_KTLS` (off/on/auto). Operators flip
42
+ # on for an A/B run without rewriting their config file.
43
+ # 2.3.0 default is :off because io_uring under fork+threads has
44
+ # known sharp edges (SQ inheritance, SQPOLL non-survival across
45
+ # fork). The env var is the sanctioned way to opt in.
46
+ apply_io_uring_env_override!(config)
47
+
48
+ # 2.3-B: env-var overrides for the per-conn fairness cap and the
49
+ # TLS handshake CPU throttle. Same precedence rule as the other
50
+ # 2.x env-var bridges — outermost knob (env > CLI > config file).
51
+ apply_max_in_flight_per_conn_env_override!(config)
52
+ apply_tls_handshake_rate_limit_env_override!(config)
53
+
23
54
  # Install logger early so every subsequent log call honours the operator's
24
55
  # chosen format/level (config file or CLI) before anything else logs.
25
- if config.log_level || config.log_format
26
- Hyperion.logger = Hyperion::Logger.new(level: config.log_level, format: config.log_format)
56
+ # 1.8.0: write directly to the default Runtime — `Hyperion.logger=` now
57
+ # emits a deprecation warn aimed at out-of-tree callers, and CLI bootstrap
58
+ # is the canonical in-tree caller, so we sidestep the warn here.
59
+ if config.logging.level || config.logging.format
60
+ Hyperion::Runtime.default.logger =
61
+ Hyperion::Logger.new(level: config.logging.level, format: config.logging.format)
27
62
  end
28
63
 
29
64
  # Advisory: operators frequently flip --async-io expecting "fast mode"
@@ -33,12 +68,16 @@ module Hyperion
33
68
  # once at boot pointing at the operator-guidance docs; the operator's
34
69
  # setting is still honoured.
35
70
  warn_orphan_async_io(config)
71
+ # 1.7.0 (RFC A9): hard validation of `async_io: true` (and a soft
72
+ # warn for `false` with a fiber lib loaded). The nil-default keeps
73
+ # the 1.6.1 advisory shape — see Hyperion.validate_async_io_loaded_libs!.
74
+ Hyperion.validate_async_io_loaded_libs!(config.async_io)
36
75
 
37
76
  # Propagate log_requests so every Connection picks it up via
38
77
  # `Hyperion.log_requests?` without needing to thread it through
39
78
  # Server/ThreadPool/Master plumbing. Default is ON; nil means "don't
40
79
  # touch — fall through to the env/default chain in Hyperion.log_requests?".
41
- Hyperion.log_requests = config.log_requests unless config.log_requests.nil?
80
+ Hyperion.log_requests = config.logging.requests unless config.logging.requests.nil?
42
81
 
43
82
  # Enable YJIT before workers fork / connections start. Auto-on in
44
83
  # production/staging gives operators the perf bump for free; explicit
@@ -49,14 +88,25 @@ module Hyperion
49
88
  abort("[hyperion] no such rackup file: #{rackup}") unless File.exist?(rackup)
50
89
 
51
90
  if config.fiber_local_shim
52
- Hyperion::FiberLocal.install!
53
- Hyperion.logger.info { { message: 'FiberLocal shim installed' } }
91
+ # Gate on async_io: with no fibers in play the shim has no purpose
92
+ # and patching `thread_variable_*` would re-stage the 1.4.x bug
93
+ # (stranded Logger/Metrics counters across thread-pool jobs running
94
+ # in distinct fibers). FiberLocal.install! itself enforces this and
95
+ # warns when ignored — we mirror the gate here for the success log.
96
+ Hyperion::FiberLocal.install!(async_io: config.async_io == true)
97
+ Hyperion.logger.info { { message: 'FiberLocal shim installed' } } if Hyperion::FiberLocal.installed?
54
98
  end
55
99
 
56
100
  app = load_rack_app(rackup)
57
101
  app = wrap_admin_middleware(app, config)
58
102
  workers = config.workers.zero? ? Etc.nprocessors : config.workers
59
103
 
104
+ # 2.0 default flip (RFC A7): resolve the `h2.max_total_streams`
105
+ # auto-sentinel now that worker count is known. After finalize!
106
+ # the field always carries either a positive integer (cap) or nil
107
+ # (operator-requested unbounded).
108
+ config.finalize!(workers: workers)
109
+
60
110
  if workers <= 1
61
111
  run_single(config, app)
62
112
  else
@@ -158,6 +208,56 @@ WARNING: argv is visible via `ps`; prefer --admin-token-file PATH for production
158
208
  'Graceful shutdown deadline in seconds before SIGKILL (default 30)') do |n|
159
209
  cli_opts[:graceful_timeout] = n
160
210
  end
211
+ # 2.2.x fix-D: expose the existing `h2.max_total_streams` admission
212
+ # cap (1.7.0+ DSL knob) at the CLI surface. The 2.0.0 default flip
213
+ # to `max_concurrent_streams × workers × 4` (= 512 streams per
214
+ # process at -w 1) is sized for normal browser traffic but cuts
215
+ # off h2load benches and gRPC/long-fan-out workloads mid-test —
216
+ # this flag lets operators raise or disable the cap without
217
+ # writing a config file. `unbounded` (or `:unbounded`) writes
218
+ # `nil` to Config, which restores the pre-2.0 unbounded behaviour.
219
+ o.on('--h2-max-total-streams VALUE',
220
+ 'HTTP/2 per-connection total stream cap. Use `unbounded` to disable. ' \
221
+ 'Default: max_concurrent_streams × workers × 4 (2.0.0 flip).') do |v|
222
+ cli_opts[:h2_max_total_streams] = parse_h2_max_total_streams!(v)
223
+ end
224
+ # 2.3-B: per-connection fairness cap. Defends against a greedy
225
+ # upstream connection (nginx pipelining many client requests
226
+ # through one keep-alive conn) hogging the worker thread pool.
227
+ # Recommended setting: thread_count / 4 (e.g., `4` for `-t 16`).
228
+ # `auto` resolves at finalize! to thread_count/4 (floor 1).
229
+ # Default unset (no cap) — opt-in operator hardening.
230
+ o.on('--max-in-flight-per-conn VALUE',
231
+ 'Per-connection in-flight request cap. Integer >= 1, or `auto` ' \
232
+ '(thread_count/4, floor 1). Default: unset (no cap).') do |v|
233
+ cli_opts[:max_in_flight_per_conn] = parse_max_in_flight_per_conn!(v)
234
+ end
235
+ # 2.3-B: TLS handshake CPU throttle. Token-bucket budget for
236
+ # SSL_accept calls per second per worker. Defends direct-exposure
237
+ # operators against handshake storms; for nginx-fronted topologies
238
+ # this is mostly defensive (nginx keeps long-lived upstream conns).
239
+ # `unlimited` (default) preserves 2.2.0 behaviour.
240
+ o.on('--tls-handshake-rate-limit VALUE',
241
+ 'TLS handshake CPU throttle: handshakes/sec/worker. Integer >= 1 ' \
242
+ 'or `unlimited` (default).') do |v|
243
+ cli_opts[:tls_handshake_rate_limit] = parse_tls_handshake_rate_limit!(v)
244
+ end
245
+ # 2.10-E: repeatable preload-at-boot flag. Each occurrence appends
246
+ # to the cli_opts Array; merge_cli! turns each into a
247
+ # `{path:, immutable: true}` entry on `Config#preload_static_dirs`.
248
+ # `--no-preload-static` is the sibling sentinel that disables the
249
+ # Rails-aware auto-detect path; the operator's explicit dirs (if
250
+ # any) still take effect.
251
+ o.on('--preload-static DIR',
252
+ 'Preload static assets from DIR at boot (repeatable). Marks every ' \
253
+ 'cached entry immutable so subsequent serves never re-stat.') do |dir|
254
+ (cli_opts[:preload_static] ||= []) << dir
255
+ end
256
+ o.on('--no-preload-static',
257
+ 'Disable the Rails-aware static-asset auto-detect at boot. ' \
258
+ 'Explicit `--preload-static` dirs still take effect.') do
259
+ cli_opts[:auto_preload_static_disabled] = true
260
+ end
161
261
  o.on('-h', '--help', 'show help') do
162
262
  puts o
163
263
  exit 0
@@ -169,6 +269,15 @@ WARNING: argv is visible via `ps`; prefer --admin-token-file PATH for production
169
269
  end
170
270
 
171
271
  def self.run_single(config, app)
272
+ # Single-mode: there's no fork, but AdminMiddleware still resolves the
273
+ # signal target via Hyperion.master_pid. Set it to ourselves so
274
+ # POST /-/quit signals the lone process — same contract as cluster
275
+ # mode (SIGTERM the master). See Hyperion.master_pid for why we don't
276
+ # rely on Process.pid alone (the AdminMiddleware reader's fallback
277
+ # would do that anyway, but making it explicit + writing
278
+ # HYPERION_MASTER_PID into ENV keeps single/cluster behaviour
279
+ # symmetric for any external tooling that introspects the var).
280
+ Hyperion.master_pid!(Process.pid)
172
281
  tls = build_tls_from_config(config)
173
282
  server = Server.new(host: config.host, port: config.port, app: app,
174
283
  tls: tls, thread_count: config.thread_count,
@@ -176,10 +285,18 @@ WARNING: argv is visible via `ps`; prefer --admin-token-file PATH for production
176
285
  max_pending: config.max_pending,
177
286
  max_request_read_seconds: config.max_request_read_seconds,
178
287
  h2_settings: Master.build_h2_settings(config),
179
- async_io: config.async_io)
180
- server.listen
181
- scheme = tls ? 'https' : 'http'
182
- Hyperion.logger.info { { message: 'listening', url: "#{scheme}://#{server.host}:#{server.port}" } }
288
+ async_io: config.async_io,
289
+ accept_fibers_per_worker: config.accept_fibers_per_worker,
290
+ h2_max_total_streams: config.h2.max_total_streams,
291
+ admin_listener_port: config.admin.listener_port,
292
+ admin_listener_host: config.admin.listener_host,
293
+ admin_token: config.admin.token,
294
+ tls_session_cache_size: config.tls.session_cache_size,
295
+ tls_ktls: config.tls.ktls,
296
+ io_uring: config.io_uring,
297
+ max_in_flight_per_conn: config.max_in_flight_per_conn,
298
+ tls_handshake_rate_limit: config.tls.handshake_rate_limit,
299
+ preload_static_dirs: config.resolved_preload_static_dirs)
183
300
  warn_c_parser_unavailable
184
301
 
185
302
  # Pre-allocate Rack env-pool entries and eager-touch lazy constants.
@@ -192,8 +309,17 @@ WARNING: argv is visible via `ps`; prefer --admin-token-file PATH for production
192
309
  # here (no fork happens), and on_worker_boot/on_worker_shutdown fire
193
310
  # for the lone in-process "worker" so app code that opens DB pools etc.
194
311
  # gets the same lifecycle whether you run 1 or N workers.
312
+ #
313
+ # `on_worker_boot` fires BEFORE the listener is bound — same contract
314
+ # as the cluster path (Worker#run): the operator's boot hook runs
315
+ # against a process with no inbound socket yet, so DB/Redis warmup
316
+ # finishes before the kernel can queue any connections.
195
317
  config.on_worker_boot.each { |h| h.call(0) }
196
318
 
319
+ server.listen
320
+ scheme = tls ? 'https' : 'http'
321
+ Hyperion.logger.info { { message: 'listening', url: "#{scheme}://#{server.host}:#{server.port}" } }
322
+
197
323
  shutdown_r, shutdown_w = IO.pipe
198
324
  %w[INT TERM].each do |sig|
199
325
  Signal.trap(sig) do
@@ -269,6 +395,179 @@ WARNING: argv is visible via `ps`; prefer --admin-token-file PATH for production
269
395
  end
270
396
  private_class_method :maybe_enable_yjit
271
397
 
398
+ # 2.2.x fix-C: env-var bridge for `tls.ktls`. Operators running the
399
+ # large-payload TLS bench harness (`bench/tls_static_1m.ru` /
400
+ # `bench/tls_json_50k.ru`) need to A/B kernel-TLS vs userspace
401
+ # SSL_write without editing their config file — the bench script
402
+ # flips `HYPERION_TLS_KTLS=off` for the userspace baseline and
403
+ # leaves it unset (`:auto`) for the kTLS run. Unknown values are
404
+ # ignored (with a warn) rather than aborting boot — the env var is
405
+ # a convenience knob, not a security boundary, and a typo
406
+ # shouldn't crash the process.
407
+ def self.apply_ktls_env_override!(config)
408
+ raw = ENV['HYPERION_TLS_KTLS']
409
+ return if raw.nil? || raw.empty?
410
+
411
+ case raw
412
+ when 'off' then config.tls.ktls = :off
413
+ when 'on' then config.tls.ktls = :on
414
+ when 'auto' then config.tls.ktls = :auto
415
+ else
416
+ Hyperion.logger.warn do
417
+ { message: 'HYPERION_TLS_KTLS ignored (must be off|on|auto)', value: raw }
418
+ end
419
+ end
420
+ end
421
+ private_class_method :apply_ktls_env_override!
422
+
423
+ # 2.2.x fix-D: shared parser for `--h2-max-total-streams VALUE` and
424
+ # `HYPERION_H2_MAX_TOTAL_STREAMS=VALUE`. Returns either a positive
425
+ # Integer (explicit cap) or the `H2Settings::UNBOUNDED` sentinel,
426
+ # which `Config#finalize!` later resolves to `nil` (no cap).
427
+ # Anything else raises `OptionParser::InvalidArgument` — same shape
428
+ # as the built-in `OptionParser` integer-parse failures, so the CLI
429
+ # branch's caller treats it identically.
430
+ def self.parse_h2_max_total_streams!(raw)
431
+ case raw
432
+ when 'unbounded', ':unbounded'
433
+ Hyperion::Config::H2Settings::UNBOUNDED
434
+ when /\A\d+\z/
435
+ n = raw.to_i
436
+ unless n.positive?
437
+ raise OptionParser::InvalidArgument,
438
+ "--h2-max-total-streams: expected a positive integer or 'unbounded', got #{raw.inspect}"
439
+ end
440
+ n
441
+ else
442
+ raise OptionParser::InvalidArgument,
443
+ "--h2-max-total-streams: expected a positive integer or 'unbounded', got #{raw.inspect}"
444
+ end
445
+ end
446
+ private_class_method :parse_h2_max_total_streams!
447
+
448
+ # 2.2.x fix-D: env-var bridge for the h2 admission cap. Same value
449
+ # grammar as the CLI flag (`unbounded` or a positive integer).
450
+ # Unknown values warn and leave the config untouched — the env var
451
+ # is a convenience knob for benches and operator overrides, not a
452
+ # security boundary, and a typo shouldn't crash boot.
453
+ def self.apply_h2_max_total_streams_env_override!(config)
454
+ raw = ENV['HYPERION_H2_MAX_TOTAL_STREAMS']
455
+ return if raw.nil? || raw.empty?
456
+
457
+ begin
458
+ config.h2.max_total_streams = parse_h2_max_total_streams!(raw)
459
+ rescue OptionParser::InvalidArgument
460
+ Hyperion.logger.warn do
461
+ { message: 'HYPERION_H2_MAX_TOTAL_STREAMS ignored (must be a positive integer or `unbounded`)',
462
+ value: raw }
463
+ end
464
+ end
465
+ end
466
+ private_class_method :apply_h2_max_total_streams_env_override!
467
+
468
+ # 2.3-A: env-var bridge for the io_uring accept policy. Mirrors
469
+ # `apply_ktls_env_override!`. Unknown values warn and leave the
470
+ # config untouched — env vars are convenience knobs for benches /
471
+ # operator overrides, not security boundaries, so a typo
472
+ # shouldn't crash boot.
473
+ def self.apply_io_uring_env_override!(config)
474
+ raw = ENV['HYPERION_IO_URING']
475
+ return if raw.nil? || raw.empty?
476
+
477
+ case raw
478
+ when 'off' then config.io_uring = :off
479
+ when 'on' then config.io_uring = :on
480
+ when 'auto' then config.io_uring = :auto
481
+ else
482
+ Hyperion.logger.warn do
483
+ { message: 'HYPERION_IO_URING ignored (must be off|on|auto)', value: raw }
484
+ end
485
+ end
486
+ end
487
+ private_class_method :apply_io_uring_env_override!
488
+
489
+ # 2.3-B: shared parser for `--max-in-flight-per-conn VALUE` and
490
+ # `HYPERION_MAX_IN_FLIGHT_PER_CONN=VALUE`. Returns either a positive
491
+ # Integer (explicit cap) or the `:auto` sentinel which `Config#finalize!`
492
+ # later resolves to `thread_count / 4`. Anything else raises
493
+ # `OptionParser::InvalidArgument` so CLI typos surface at boot.
494
+ def self.parse_max_in_flight_per_conn!(raw)
495
+ case raw
496
+ when 'auto', ':auto'
497
+ Hyperion::Config::MAX_IN_FLIGHT_PER_CONN_AUTO
498
+ when /\A\d+\z/
499
+ n = raw.to_i
500
+ unless n.positive?
501
+ raise OptionParser::InvalidArgument,
502
+ "--max-in-flight-per-conn: expected a positive integer or 'auto', got #{raw.inspect}"
503
+ end
504
+ n
505
+ else
506
+ raise OptionParser::InvalidArgument,
507
+ "--max-in-flight-per-conn: expected a positive integer or 'auto', got #{raw.inspect}"
508
+ end
509
+ end
510
+ private_class_method :parse_max_in_flight_per_conn!
511
+
512
+ # 2.3-B: env-var bridge for the per-conn fairness cap. Same value
513
+ # grammar as the CLI flag (`auto` or a positive integer). Unknown
514
+ # values warn and leave the config untouched — the env var is a
515
+ # convenience knob, not a security boundary.
516
+ def self.apply_max_in_flight_per_conn_env_override!(config)
517
+ raw = ENV['HYPERION_MAX_IN_FLIGHT_PER_CONN']
518
+ return if raw.nil? || raw.empty?
519
+
520
+ begin
521
+ config.max_in_flight_per_conn = parse_max_in_flight_per_conn!(raw)
522
+ rescue OptionParser::InvalidArgument
523
+ Hyperion.logger.warn do
524
+ { message: 'HYPERION_MAX_IN_FLIGHT_PER_CONN ignored (must be a positive integer or `auto`)',
525
+ value: raw }
526
+ end
527
+ end
528
+ end
529
+ private_class_method :apply_max_in_flight_per_conn_env_override!
530
+
531
+ # 2.3-B: shared parser for `--tls-handshake-rate-limit VALUE` and
532
+ # `HYPERION_TLS_HANDSHAKE_RATE_LIMIT=VALUE`. Returns either a
533
+ # positive Integer (handshakes/sec/worker) or the `:unlimited`
534
+ # sentinel which keeps the 2.2.0 (no-throttle) behaviour. Anything
535
+ # else raises `OptionParser::InvalidArgument`.
536
+ def self.parse_tls_handshake_rate_limit!(raw)
537
+ case raw
538
+ when 'unlimited', ':unlimited'
539
+ :unlimited
540
+ when /\A\d+\z/
541
+ n = raw.to_i
542
+ unless n.positive?
543
+ raise OptionParser::InvalidArgument,
544
+ "--tls-handshake-rate-limit: expected a positive integer or 'unlimited', got #{raw.inspect}"
545
+ end
546
+ n
547
+ else
548
+ raise OptionParser::InvalidArgument,
549
+ "--tls-handshake-rate-limit: expected a positive integer or 'unlimited', got #{raw.inspect}"
550
+ end
551
+ end
552
+ private_class_method :parse_tls_handshake_rate_limit!
553
+
554
+ # 2.3-B: env-var bridge for the TLS handshake throttle. Same value
555
+ # grammar as the CLI flag.
556
+ def self.apply_tls_handshake_rate_limit_env_override!(config)
557
+ raw = ENV['HYPERION_TLS_HANDSHAKE_RATE_LIMIT']
558
+ return if raw.nil? || raw.empty?
559
+
560
+ begin
561
+ config.tls.handshake_rate_limit = parse_tls_handshake_rate_limit!(raw)
562
+ rescue OptionParser::InvalidArgument
563
+ Hyperion.logger.warn do
564
+ { message: 'HYPERION_TLS_HANDSHAKE_RATE_LIMIT ignored (must be a positive integer or `unlimited`)',
565
+ value: raw }
566
+ end
567
+ end
568
+ end
569
+ private_class_method :apply_tls_handshake_rate_limit_env_override!
570
+
272
571
  # Probe table for fiber-cooperative I/O libraries. If `async_io: true` is
273
572
  # set but none of these are loaded, the operator has likely flipped the
274
573
  # flag without reading the bench numbers — `--async-io` adds Async-loop
@@ -303,13 +602,13 @@ WARNING: argv is visible via `ps`; prefer --admin-token-file PATH for production
303
602
  # Skipped when the token is unset — those paths fall through to the app,
304
603
  # so apps may still own /-/anything if Hyperion's admin is off.
305
604
  def self.wrap_admin_middleware(app, config)
306
- return app if config.admin_token.nil? || config.admin_token.to_s.empty?
605
+ return app if config.admin.token.nil? || config.admin.token.to_s.empty?
307
606
 
308
607
  Hyperion.logger.info do
309
608
  { message: 'admin endpoint enabled',
310
609
  paths: [AdminMiddleware::PATH_QUIT, AdminMiddleware::PATH_METRICS] }
311
610
  end
312
- AdminMiddleware.new(app, token: config.admin_token)
611
+ AdminMiddleware.new(app, token: config.admin.token)
313
612
  end
314
613
  private_class_method :wrap_admin_middleware
315
614