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
@@ -8,7 +8,20 @@ module Hyperion
8
8
  # All settings have safe defaults that match the per-class DEFAULT_* constants
9
9
  # so that running Hyperion without a config file works identically to the
10
10
  # pre-rc14 behaviour.
11
+ #
12
+ # 1.7.0 (RFC A4): grouped settings live in nested subconfigs —
13
+ # `config.h2.*`, `config.admin.*`, `config.worker_health.*`,
14
+ # `config.logging.*`. 1.7 added the nested DSL alongside the legacy
15
+ # flat keys; 1.8 deprecation-warned the flat keys; 2.0 removed them.
16
+ # The nested DSL is the only configuration surface — flat aliases
17
+ # like `h2_max_concurrent_streams` no longer exist on the DSL or on
18
+ # `Config` itself.
11
19
  class Config
20
+ # Top-level (un-nested) defaults. Flat fields that don't group
21
+ # naturally are deliberately kept here per the RFC's "only 8 fields
22
+ # warrant nesting in A4" guidance — `max_pending`, `idle_keepalive`,
23
+ # `graceful_timeout`, the `tls_*` family, `read_timeout`, and the
24
+ # body/header byte caps stay flat.
12
25
  DEFAULTS = {
13
26
  host: '127.0.0.1',
14
27
  port: 9292,
@@ -21,31 +34,250 @@ module Hyperion
21
34
  graceful_timeout: 30,
22
35
  max_header_bytes: 64 * 1024,
23
36
  max_body_bytes: 16 * 1024 * 1024,
24
- log_level: nil, # nil → Logger picks from env / default
25
- log_format: nil, # nil → Logger picks via auto rule
26
- log_requests: nil, # nil → Hyperion.log_requests? (default true)
27
37
  fiber_local_shim: false,
28
38
  yjit: nil, # nil → auto: enable on production/staging; true/false to force.
29
- worker_max_rss_mb: nil, # Integer, e.g. 1024. When a worker exceeds this RSS in MB, master gracefully cycles it. nil disables.
30
- worker_check_interval: 30, # Seconds between RSS polls. Tradeoff: tighter = faster recycle, more ps calls. 30s matches Puma WorkerKiller.
31
- admin_token: nil, # String. When set, exposes admin endpoints (POST /-/quit triggers graceful drain; GET /-/metrics returns Prometheus-format Hyperion.stats). Same token guards both. nil disables admin entirely (paths fall through to the app).
32
- max_pending: nil, # Integer, e.g. 256. When the per-worker accept inbox has this many queued connections, additional accepts are rejected with HTTP 503 + Retry-After:1 instead of being queued. nil disables (current behaviour: unbounded queue).
33
- max_request_read_seconds: 60, # Numeric. Total wallclock budget (seconds) for reading the request line + headers + body for ONE request. Defends against slowloris-style drips that satisfy the per-recv read_timeout but never finish the request. Resets between requests on a keep-alive connection. nil disables.
34
- async_io: nil, # Three-way: nil (default, auto: inline on TLS h1 / pool on plain HTTP/1.1), true (force inline-on-fiber for plain HTTP/1.1 too required for fiber-cooperative I/O like hyperion-async-pg on plain HTTP), false (force pool hop everywhere — explicit opt-out for operators who specifically want TLS+threadpool with CPU-bound handlers). Costs ~5% throughput on hello-world when inline; in exchange one OS thread can serve N concurrent in-flight DB queries on wait-bound workloads. TLS / HTTP/2 paths always run the Async accept loop regardless of this flag.
35
- h2_max_concurrent_streams: 128, # HTTP/2 SETTINGS_MAX_CONCURRENT_STREAMS cap on simultaneously-open streams per connection. Falcon: 64. nil leaves protocol-http2 default (0xFFFFFFFF).
36
- h2_initial_window_size: 1_048_576, # HTTP/2 SETTINGS_INITIAL_WINDOW_SIZE (octets) flow-control window per stream at open. Bigger = fewer WINDOW_UPDATE round-trips on large bodies. Spec default is 65535. nil → leave protocol default.
37
- h2_max_frame_size: 1_048_576, # HTTP/2 SETTINGS_MAX_FRAME_SIZE (octets) biggest DATA/HEADERS frame we'll accept. Spec floor 16384, ceiling 16777215. We pick 1 MiB to match common CDNs without unbounded buffer growth. nil → leave protocol default (16384).
38
- h2_max_header_list_size: 65_536 # HTTP/2 SETTINGS_MAX_HEADER_LIST_SIZE (octets) — advisory cap on the decompressed header block. Bounds memory of pathological client headers. nil → leave protocol default (unbounded).
39
+ max_pending: nil,
40
+ max_request_read_seconds: 60,
41
+ async_io: nil, # nil/true/false (validated strictly in 1.7.0+ via Server constructor).
42
+ accept_fibers_per_worker: 1, # RFC A6 opt-in multi-fiber accept under :reuseport.
43
+ # 2.3-A: io_uring accept policy (Linux 5.6+ only). Tri-state, mirrors `tls.ktls`:
44
+ # :off never use io_uring; epoll path always (2.3.0 default).
45
+ # :autouse io_uring when supported; quietly fall back otherwise.
46
+ # :on demand it; raise at boot if unsupported.
47
+ # Default flips to :auto in 2.4 only after soak. Operators flip on
48
+ # via `HYPERION_IO_URING={on,auto}` env var to A/B test.
49
+ io_uring: :off,
50
+ # 2.3-B: per-connection in-flight cap. nginx upstream keep-alive
51
+ # pipelines many client requests through one upstream connection;
52
+ # without this cap a single greedy upstream conn can hog the worker
53
+ # thread pool and starve siblings. Tri-state:
54
+ # * Integer >= 1 — explicit cap (e.g., `4` for `-t 16`).
55
+ # * :auto — `Config#finalize!` resolves to `thread_count / 4`
56
+ # (rounded down, minimum 1). Operator opt-in.
57
+ # * nil (default) — no cap; matches 2.2.0 behaviour. Hyperion is
58
+ # opt-in by default — the cap is a hardening tool
59
+ # that operators turn on, not a default flip.
60
+ max_in_flight_per_conn: nil,
61
+ # 2.10-E: explicit `preload_static "/path"` DSL entries plus the
62
+ # CLI's repeatable `--preload-static <dir>` flag accumulate here.
63
+ # Each element is a `{path: String, immutable: Boolean}` Hash.
64
+ # `Server#listen` walks the resolved list (which may also include
65
+ # auto-detected Rails asset paths — see `auto_preload_static_disabled`)
66
+ # and warms `Hyperion::Http::PageCache` before the accept loop spins.
67
+ # Default empty so a vanilla Rack app pays nothing.
68
+ preload_static_dirs: nil,
69
+ # 2.10-E: when truthy, suppress the Rails-aware auto-detect path
70
+ # (`Rails.configuration.assets.paths.first(N)`) at boot. Set by
71
+ # the `--no-preload-static` CLI flag; lets operators turn off
72
+ # auto-warming on a Rails app while still keeping the option to
73
+ # configure explicit dirs via `preload_static`.
74
+ auto_preload_static_disabled: false
39
75
  }.freeze
40
76
 
41
77
  HOOKS = %i[before_fork on_worker_boot on_worker_shutdown].freeze
42
78
 
79
+ # Plain top-level accessors. Subconfigs (h2/admin/worker_health/logging)
80
+ # and their flat-forwarder methods are defined further below.
43
81
  attr_accessor(*DEFAULTS.keys)
44
82
  attr_reader(*HOOKS)
45
83
 
84
+ # Nested subconfig readers. The DSL exposes them as block forms
85
+ # (`h2 do |h| ... end`) and the legacy flat forms (`h2_max_concurrent_streams 256`)
86
+ # both write into the same backing object.
87
+ attr_reader :h2, :admin, :worker_health, :logging, :tls, :websocket, :metrics
88
+
89
+ # H2 settings subconfig. RFC 7540 §6.5.2 settings + the new-in-1.7
90
+ # per-process `max_total_streams` admission cap (RFC A7).
91
+ class H2Settings
92
+ ATTRS = %i[max_concurrent_streams initial_window_size max_frame_size
93
+ max_header_list_size max_total_streams].freeze
94
+ attr_accessor(*ATTRS)
95
+
96
+ # 2.0 default for `max_total_streams`. The literal value `:auto`
97
+ # is a deferred sentinel: `Config#finalize!` resolves it to
98
+ # `max_concurrent_streams × workers × 4` once the worker count
99
+ # is known. Operators wanting the pre-2.0 unbounded behaviour
100
+ # write `:unbounded` (or `nil` after finalize); operators wanting
101
+ # a fixed cap write a positive integer.
102
+ AUTO = :auto
103
+ UNBOUNDED = :unbounded
104
+
105
+ def initialize
106
+ @max_concurrent_streams = 128
107
+ @initial_window_size = 1_048_576
108
+ @max_frame_size = 1_048_576
109
+ @max_header_list_size = 65_536
110
+ @max_total_streams = AUTO # 2.0 default — finalize! resolves it.
111
+ end
112
+ end
113
+
114
+ # Admin endpoint subconfig. `token` was `admin_token` in 1.6.x;
115
+ # `listener_port` / `listener_host` are new-in-1.7 (RFC A8 sibling
116
+ # listener; default nil keeps admin mounted in-app via AdminMiddleware).
117
+ class AdminConfig
118
+ ATTRS = %i[token listener_port listener_host].freeze
119
+ attr_accessor(*ATTRS)
120
+
121
+ def initialize
122
+ @token = nil
123
+ @listener_port = nil
124
+ @listener_host = '127.0.0.1'
125
+ end
126
+ end
127
+
128
+ # Worker health subconfig. `max_rss_mb` recycles a worker that
129
+ # exceeds the configured RSS; `check_interval` is the poll period
130
+ # in seconds. The new `timeout` field is reserved for 1.8+ worker-
131
+ # heartbeat work; ships now so operators can pre-configure.
132
+ class WorkerHealthConfig
133
+ ATTRS = %i[max_rss_mb check_interval timeout].freeze
134
+ attr_accessor(*ATTRS)
135
+
136
+ def initialize
137
+ @max_rss_mb = nil
138
+ @check_interval = 30
139
+ @timeout = nil
140
+ end
141
+ end
142
+
143
+ # Logging subconfig. `level` / `format` mirror the 1.6.x flat
144
+ # setters; `requests` is the new home for `log_requests`. nil =
145
+ # delegate to `Hyperion.log_requests?` (env + default ON).
146
+ class LoggingConfig
147
+ ATTRS = %i[level format requests].freeze
148
+ attr_accessor(*ATTRS)
149
+
150
+ def initialize
151
+ @level = nil
152
+ @format = nil
153
+ @requests = nil
154
+ end
155
+ end
156
+
157
+ # 2.3-C: WebSocket subconfig. The headline knob is
158
+ # `permessage_deflate` — RFC 7692 per-message DEFLATE compression
159
+ # for the WS payload. Tri-state, mirrors `tls.ktls`:
160
+ # :off — never advertise the extension.
161
+ # :auto — accept if the client offers it (default; backwards
162
+ # compatible with clients that don't offer it).
163
+ # :on — require it; reject the handshake (400) if the client
164
+ # doesn't offer a usable variant.
165
+ class WebSocketConfig
166
+ ATTRS = %i[permessage_deflate].freeze
167
+ attr_accessor(*ATTRS)
168
+
169
+ DEFAULT_PERMESSAGE_DEFLATE = :auto
170
+
171
+ def initialize
172
+ @permessage_deflate = DEFAULT_PERMESSAGE_DEFLATE
173
+ end
174
+ end
175
+
176
+ # 2.4-C: Metrics subconfig. The headline knob is `path_templater`,
177
+ # which collapses raw request paths to low-cardinality templates
178
+ # for the per-route latency histogram (operators with Rails-style
179
+ # routes plug in their own templater). `enabled` flips the new
180
+ # 2.4-C histogram/gauge surface as a whole — counters in the legacy
181
+ # surface (requests, bytes_read, …) keep emitting regardless.
182
+ class MetricsConfig
183
+ ATTRS = %i[path_templater enabled].freeze
184
+ attr_accessor(*ATTRS)
185
+
186
+ def initialize
187
+ @path_templater = nil # lazily defaulted to PathTemplater.new on first read
188
+ @enabled = true
189
+ end
190
+
191
+ def path_templater
192
+ @path_templater ||= Hyperion::Metrics::PathTemplater.new
193
+ end
194
+ end
195
+
196
+ # 2.3-B: top-level `:auto` sentinel for `max_in_flight_per_conn`.
197
+ # `Config#finalize!` resolves to `thread_count / 4`, floor 1. Plain
198
+ # symbol (no nested struct) because the only knob is the cap value.
199
+ MAX_IN_FLIGHT_PER_CONN_AUTO = :auto
200
+
201
+ # TLS subconfig. New in 1.8.0 (Phase 4 — TLS session resumption).
202
+ # `session_cache_size` controls the size of the in-process server-
203
+ # side session cache used to short-circuit the full handshake when a
204
+ # client returns with a previously-issued session id. The default of
205
+ # 20_480 is sized for ~16 MiB of cache memory at 800 B/session — well
206
+ # under the workload-default 128 MiB worker RSS cap.
207
+ #
208
+ # `ticket_key_rotation_signal` selects the OS signal that triggers
209
+ # a session-cache flush + ticket-key roll on the master. `:USR2`
210
+ # (default) is conventional for "rotate keys" signals (nginx uses
211
+ # SIGUSR2 for binary-upgrade, but here it's the rotation event).
212
+ # Set to `:NONE` to disable rotation entirely (workloads that don't
213
+ # care about ticket-key rotation security guarantees).
214
+ class TlsConfig
215
+ ATTRS = %i[session_cache_size ticket_key_rotation_signal ktls handshake_rate_limit].freeze
216
+ attr_accessor(*ATTRS)
217
+
218
+ DEFAULT_SESSION_CACHE_SIZE = 20_480
219
+ DEFAULT_ROTATION_SIGNAL = :USR2
220
+ # 2.2.0 (Phase 9): kernel TLS_TX policy.
221
+ # :auto — enable on Linux when supported, off elsewhere
222
+ # :on — force enable; raise at boot if unsupported
223
+ # :off — never enable, always use userspace SSL_write
224
+ DEFAULT_KTLS = :auto
225
+ # 2.3-B: TLS handshake CPU throttle. Token-bucket budget for
226
+ # SSL_accept calls per second per worker. Defends direct-exposure
227
+ # operators against handshake storms (e.g., many short-lived TLS
228
+ # clients reconnecting at once during a deployment). For the
229
+ # nginx-fronted topology this is mostly defensive — nginx keeps
230
+ # long-lived upstream conns so handshake rate is normally near-zero.
231
+ # * Integer >= 1 — handshakes/sec/worker (capacity == rate).
232
+ # * :unlimited (default) — no limit; matches 2.2.0 behaviour.
233
+ DEFAULT_HANDSHAKE_RATE_LIMIT = :unlimited
234
+
235
+ def initialize
236
+ @session_cache_size = DEFAULT_SESSION_CACHE_SIZE
237
+ @ticket_key_rotation_signal = DEFAULT_ROTATION_SIGNAL
238
+ @ktls = DEFAULT_KTLS
239
+ @handshake_rate_limit = DEFAULT_HANDSHAKE_RATE_LIMIT
240
+ end
241
+ end
242
+
243
+ # CLI-only flat→nested setter map. The DSL surface no longer
244
+ # honours these names (2.0 removed the flat DSL forwarders), but
245
+ # `Config#merge_cli!` still receives flat-keyed cli_opts hashes
246
+ # built by the OptionParser branches in `Hyperion::CLI`. Routing
247
+ # them via this table keeps CLI flag spellings stable
248
+ # (`--admin-token`, `--log-level`, …) without re-introducing the
249
+ # deprecated DSL surface.
250
+ CLI_FLAT_TO_NESTED = {
251
+ h2_max_concurrent_streams: %i[h2 max_concurrent_streams],
252
+ h2_initial_window_size: %i[h2 initial_window_size],
253
+ h2_max_frame_size: %i[h2 max_frame_size],
254
+ h2_max_header_list_size: %i[h2 max_header_list_size],
255
+ h2_max_total_streams: %i[h2 max_total_streams],
256
+ admin_token: %i[admin token],
257
+ admin_listener_port: %i[admin listener_port],
258
+ admin_listener_host: %i[admin listener_host],
259
+ worker_max_rss_mb: %i[worker_health max_rss_mb],
260
+ worker_check_interval: %i[worker_health check_interval],
261
+ log_level: %i[logging level],
262
+ log_format: %i[logging format],
263
+ log_requests: %i[logging requests],
264
+ tls_handshake_rate_limit: %i[tls handshake_rate_limit]
265
+ }.freeze
266
+
46
267
  def initialize
47
268
  DEFAULTS.each { |k, v| public_send(:"#{k}=", v) }
48
269
  HOOKS.each { |h| instance_variable_set(:"@#{h}", []) }
270
+ @h2 = H2Settings.new
271
+ @admin = AdminConfig.new
272
+ @worker_health = WorkerHealthConfig.new
273
+ @logging = LoggingConfig.new
274
+ @tls = TlsConfig.new
275
+ @websocket = WebSocketConfig.new
276
+ @metrics = MetricsConfig.new
277
+ # 2.10-E: per-instance Array — DEFAULTS is frozen so we can't share
278
+ # a literal `[]` across Config instances or every operator's DSL
279
+ # `preload_static` call would mutate the same backing list.
280
+ @preload_static_dirs = []
49
281
  end
50
282
 
51
283
  HOOKS.each do |hook|
@@ -64,21 +296,124 @@ module Hyperion
64
296
  cfg
65
297
  end
66
298
 
299
+ # Sentinel surfaced through `Config#h2.max_total_streams` when the
300
+ # operator hasn't touched the setting and 2.0's auto-default formula
301
+ # ought to compute on their behalf at finalize time. The `nil` value
302
+ # (RFC §3 1.7 default) used to mean "admission disabled forever";
303
+ # 2.0 redefines `nil` as "auto" and adds an explicit
304
+ # `H2Settings::UNBOUNDED` sentinel for operators who want the
305
+ # pre-2.0 unbounded behaviour.
306
+ #
307
+ # The Auto path is a sentinel-only wire — `H2Settings#initialize` no
308
+ # longer sets a hard `nil`; finalize! resolves it to
309
+ # `max_concurrent_streams × workers × 4` and writes the result back
310
+ # onto `h2.max_total_streams`. Operators reading the value before
311
+ # finalize see the sentinel; after finalize see the resolved
312
+ # integer.
313
+ # Resolve any "auto" sentinels to concrete integers based on
314
+ # finalized peer settings. Called once after `merge_cli!` and after
315
+ # the worker count is known (Master#initialize / CLI run_single).
316
+ # Idempotent — a finalized config can be re-finalized without
317
+ # changing values.
318
+ def finalize!(workers:)
319
+ case @h2.max_total_streams
320
+ when H2Settings::AUTO
321
+ @h2.max_total_streams = compute_h2_max_total_streams(workers: workers)
322
+ when H2Settings::UNBOUNDED
323
+ @h2.max_total_streams = nil
324
+ end
325
+ # 2.3-B: resolve the `:auto` sentinel for the per-conn fairness
326
+ # cap. `thread_count / 4` (floor 1) gives each conn at most 25% of
327
+ # the worker's thread budget — the recommended default. Operators
328
+ # who set an explicit integer at config time keep their value
329
+ # untouched; nil (no cap, 2.2.0 default) is also preserved.
330
+ @max_in_flight_per_conn = compute_max_in_flight_per_conn if @max_in_flight_per_conn == MAX_IN_FLIGHT_PER_CONN_AUTO
331
+ self
332
+ end
333
+
334
+ # 2.0 default formula (RFC §3): per-conn cap × worker count × 4.
335
+ # The 4× headroom factor assumes the average connection holds 25%
336
+ # of the per-conn cap; well above realistic legitimate fan-out yet
337
+ # still bounds the OOM abuse window (5k conns × 128 streams = 640k
338
+ # fibers).
339
+ def compute_h2_max_total_streams(workers:)
340
+ cap_per_conn = @h2.max_concurrent_streams || H2Settings.new.max_concurrent_streams
341
+ worker_count = (workers && workers.positive? ? workers : 1)
342
+ cap_per_conn * worker_count * 4
343
+ end
344
+
345
+ # 2.3-B per-conn fairness default: `thread_count / 4`, floor 1.
346
+ # Each conn caps at 25% of the worker's thread budget so a single
347
+ # greedy upstream connection can't starve siblings. Floor of 1
348
+ # ensures degenerate `-t 1` / `-t 2` / `-t 3` configurations still
349
+ # serve traffic (cap 1 = strictly serial per conn, but no rejects
350
+ # while no conn is currently dispatched).
351
+ def compute_max_in_flight_per_conn
352
+ threads = (@thread_count && @thread_count.positive? ? @thread_count : 1)
353
+ cap = threads / 4
354
+ cap = 1 if cap < 1
355
+ cap
356
+ end
357
+
67
358
  # Apply CLI overrides on top of an existing config. Only non-nil values
68
359
  # in `overrides` are applied — preserves the precedence ordering
69
360
  # (CLI > env > config file > default).
361
+ #
362
+ # 2.0.0: flat keys that map to a nested subconfig
363
+ # (`admin_token` → `admin.token`, `log_level` → `logging.level`, …)
364
+ # are dispatched through `CLI_FLAT_TO_NESTED`. The DSL no longer
365
+ # accepts these names, but the CLI flag surface keeps its 1.x
366
+ # spellings — operators don't have to learn a new flag set.
367
+ #
368
+ # 2.10-E: `:preload_static` is special-cased — it's an Array of dir
369
+ # strings from the repeatable `--preload-static` flag, and we
370
+ # APPEND each as `{path:, immutable: true}` to the already-populated
371
+ # `preload_static_dirs` list. Operator config-file entries land
372
+ # first; CLI flags win by being applied last.
70
373
  def merge_cli!(overrides)
71
374
  overrides.each do |key, value|
72
375
  next if value.nil?
73
376
 
74
- public_send(:"#{key}=", value) if respond_to?(:"#{key}=")
377
+ if key == :preload_static
378
+ Array(value).each do |dir|
379
+ preload_static_dirs << { path: dir.to_s, immutable: true }
380
+ end
381
+ elsif (route = CLI_FLAT_TO_NESTED[key])
382
+ group, nested = route
383
+ public_send(group).public_send(:"#{nested}=", value)
384
+ elsif respond_to?(:"#{key}=")
385
+ public_send(:"#{key}=", value)
386
+ end
75
387
  end
76
388
  self
77
389
  end
78
390
 
391
+ # 2.10-E — resolve the operator-supplied preload list, falling
392
+ # through to Rails auto-detect when no explicit dirs are configured
393
+ # AND auto-detect is not disabled by the operator. Always returns
394
+ # an Array of `{path:, immutable:}` Hashes (possibly empty).
395
+ #
396
+ # Precedence:
397
+ # 1. Operator-supplied (DSL `preload_static` or CLI flags) — used verbatim.
398
+ # 2. Otherwise, Rails-detected paths if auto-detect is enabled.
399
+ # 3. Otherwise, [] — no preload, 1.x cold-cache behaviour.
400
+ def resolved_preload_static_dirs
401
+ return preload_static_dirs.dup unless preload_static_dirs.empty?
402
+ return [] if auto_preload_static_disabled
403
+
404
+ Hyperion::StaticPreload.detect_rails_paths.map do |path|
405
+ { path: path, immutable: true }
406
+ end
407
+ end
408
+
79
409
  # DSL receiver. Each method call on the DSL maps to a Config setter or
80
410
  # to a hook registration. Unknown methods raise NoMethodError so typos
81
411
  # surface immediately at boot rather than as silent ignores.
412
+ #
413
+ # 1.7.0 (RFC A4) added nested block forms — `h2 do |h| ... end` and
414
+ # the bare-block `worker_health do; max_rss_mb 1024; end` shape. The
415
+ # flat `h2_max_concurrent_streams 256` form keeps working untouched
416
+ # in 1.7; deprecation warn lands in 1.8, removal in 2.0.
82
417
  class DSL
83
418
  def initialize(config)
84
419
  @config = config
@@ -89,18 +424,46 @@ module Hyperion
89
424
  @config.host = value
90
425
  end
91
426
 
427
+ # Top-level flat setters. We define them dynamically off the
428
+ # DEFAULTS hash so adding a new top-level field auto-wires the DSL.
92
429
  Config::DEFAULTS.each_key do |key|
93
430
  define_method(key) do |value|
94
431
  @config.public_send(:"#{key}=", value)
95
432
  end
96
433
  end
97
434
 
435
+ # 2.0.0: the flat DSL keys (`h2_max_concurrent_streams`, `admin_token`,
436
+ # `log_format`, …) are removed. Operators must use the nested DSL
437
+ # blocks defined below. Unknown DSL methods bubble up as
438
+ # `NoMethodError` from the DSL evaluator — typos surface at boot.
98
439
  Config::HOOKS.each do |hook|
99
440
  define_method(hook) do |&block|
100
441
  @config.public_send(:"add_#{hook}", &block)
101
442
  end
102
443
  end
103
444
 
445
+ # Nested-block DSL — `h2 do |h| h.max_concurrent_streams 256 end`
446
+ # OR `h2 do; max_concurrent_streams 256; end`. The block is
447
+ # eval'd against a BlockProxy that proxies bareword method calls
448
+ # into the subconfig's accessors; explicit-arg form (`|h|`) gives
449
+ # callers the proxy directly so they can pass it around.
450
+ %i[h2 admin worker_health logging tls websocket metrics].each do |group|
451
+ define_method(group) do |&block|
452
+ subconfig = @config.public_send(group)
453
+ if block.nil?
454
+ subconfig
455
+ else
456
+ proxy = BlockProxy.new(subconfig)
457
+ if block.arity.zero? || block.arity.negative?
458
+ proxy.instance_eval(&block)
459
+ else
460
+ block.call(proxy)
461
+ end
462
+ subconfig
463
+ end
464
+ end
465
+ end
466
+
104
467
  # `tls_cert_path` / `tls_key_path` are convenience aliases that read
105
468
  # the file off disk so the DSL stays terse. The parsed cert/key are
106
469
  # stored on the config and Server consumes them directly.
@@ -113,6 +476,69 @@ module Hyperion
113
476
  require 'openssl'
114
477
  @config.tls_key = OpenSSL::PKey.read(File.read(path))
115
478
  end
479
+
480
+ # 2.10-E — `preload_static "/path", immutable: true` DSL key.
481
+ # Appends `{path:, immutable:}` onto `preload_static_dirs`. The
482
+ # `immutable:` kwarg defaults to true — the whole point of preload
483
+ # is "I promise these don't change without a restart" so the
484
+ # immutable flag is the operator-friendly default. Multiple calls
485
+ # accumulate.
486
+ #
487
+ # Overrides the auto-generated DEFAULTS-based setter for the
488
+ # backing field (which would write the entire array via `=`); this
489
+ # explicit method is the one the DSL actually exposes.
490
+ def preload_static(path, immutable: true)
491
+ @config.preload_static_dirs << { path: path.to_s, immutable: immutable }
492
+ end
493
+ end
494
+
495
+ # Block-form DSL proxy for nested subconfigs. Each bareword call
496
+ # inside `h2 do; max_concurrent_streams 256; end` lands on this
497
+ # proxy, which forwards into the wrapped subconfig's setter. Also
498
+ # supports explicit-arg form `h2 do |h| h.max_concurrent_streams 256 end`
499
+ # via the same accessor path. Unknown method names raise
500
+ # NoMethodError — typos surface at boot, matching the top-level
501
+ # DSL's strictness.
502
+ #
503
+ # Inherits from BasicObject so Ruby's `Kernel#format` / `Kernel#level`
504
+ # / etc. don't shadow our subconfig setters when callers write
505
+ # `logging do; format :json; end` — Kernel methods are absent on
506
+ # BasicObject, so the bareword falls through to method_missing.
507
+ class BlockProxy < BasicObject
508
+ def initialize(target)
509
+ @target = target
510
+ end
511
+
512
+ def method_missing(name, *args, &block)
513
+ setter = :"#{name}="
514
+ if @target.respond_to?(setter)
515
+ # Single-arg sets (`max_concurrent_streams 256`) write the
516
+ # value through. Zero-arg calls (`max_concurrent_streams`)
517
+ # behave as readers — needed by the explicit-arg form so
518
+ # `h.max_concurrent_streams` returns the current value.
519
+ if args.length == 1
520
+ @target.public_send(setter, args.first)
521
+ elsif args.empty? && @target.respond_to?(name)
522
+ @target.public_send(name)
523
+ else
524
+ ::Kernel.raise ::NoMethodError, "no DSL setter for #{name.inspect} on #{@target.class}"
525
+ end
526
+ elsif @target.respond_to?(name)
527
+ @target.public_send(name, *args, &block)
528
+ else
529
+ ::Kernel.raise ::NoMethodError, "no DSL setter for #{name.inspect} on #{@target.class}"
530
+ end
531
+ end
532
+
533
+ def respond_to_missing?(name, include_private = false)
534
+ setter = :"#{name}="
535
+ @target.respond_to?(setter) || @target.respond_to?(name, include_private)
536
+ end
537
+
538
+ # BasicObject lacks #class — provide it for tests + introspection.
539
+ def class
540
+ ::Hyperion::Config::BlockProxy
541
+ end
116
542
  end
117
543
  end
118
544
  end