hyperion-rb 2.13.0 → 2.14.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.
@@ -151,6 +151,291 @@ module Hyperion
151
151
  nil
152
152
  end
153
153
 
154
+ # 2.14-A — C-accept-loop dispatch helper.
155
+ #
156
+ # The C accept loop (`PageCache.run_static_accept_loop`) calls
157
+ # this helper, under the GVL, when a request matches a
158
+ # `RouteTable::DynamicBlockEntry`. The C side has already done
159
+ # accept + recv + parse without holding the GVL; this helper
160
+ # owns the `app.call(env)` slice and returns the fully-formed
161
+ # HTTP/1.1 response bytes for C to write (also without the GVL).
162
+ #
163
+ # Args (all positional, all Strings except `block` and
164
+ # `keep_alive` and `runtime`):
165
+ # * `method_str` — e.g. "GET"
166
+ # * `path_str` — request path, no query
167
+ # * `query_str` — query (no leading '?'), or "" if none
168
+ # * `host_str` — `Host:` header value, or ""
169
+ # * `headers_blob` — raw header section as bytes
170
+ # (the slice between request-line CRLF and the closing
171
+ # CRLFCRLF, terminated by a CRLF on the last header). The
172
+ # helper parses this in Ruby — header parse is a few µs
173
+ # even for the 30-header case, dwarfed by `app.call`.
174
+ # * `remote_addr` — peer IP as a String, or "" if unknown
175
+ # * `block` — the registered Proc / lambda
176
+ # * `keep_alive` — true to emit `connection: keep-alive`
177
+ # in the response head, false for `connection: close`
178
+ # * `runtime` — the `Hyperion::Runtime` instance the
179
+ # server was constructed with (for lifecycle hooks); the
180
+ # C loop captures this once at boot via the registered
181
+ # callback closure
182
+ #
183
+ # Returns a single binary String of HTTP/1.1 response bytes
184
+ # (status line + response headers + CRLF + body). The C loop
185
+ # writes this verbatim. On exception, returns a 500 envelope
186
+ # so the C loop can still respond to the peer (better UX than
187
+ # closing the fd silently).
188
+ def dispatch_for_c_loop(method_str, path_str, query_str,
189
+ host_str, headers_blob, remote_addr,
190
+ block, keep_alive, runtime)
191
+ env, input = build_c_loop_env(method_str, path_str, query_str,
192
+ host_str, headers_blob, remote_addr)
193
+ request = nil
194
+ response = nil
195
+ error = nil
196
+
197
+ rt = runtime || Hyperion::Runtime.default
198
+ if rt.has_request_hooks?
199
+ request = c_loop_request_for(env)
200
+ rt.fire_request_start(request, env)
201
+ end
202
+
203
+ begin
204
+ response = block.call(env)
205
+ rescue StandardError => e
206
+ error = e
207
+ end
208
+
209
+ if rt.has_request_hooks?
210
+ request ||= c_loop_request_for(env)
211
+ rt.fire_request_end(request, env, response, error)
212
+ end
213
+
214
+ if error
215
+ ::Hyperion.metrics.increment(:app_errors)
216
+ ::Hyperion.logger.error do
217
+ {
218
+ message: 'app raised (c-accept-loop dispatch)',
219
+ error: error.message,
220
+ error_class: error.class.name,
221
+ backtrace: (error.backtrace || []).first(20).join(' | ')
222
+ }
223
+ end
224
+ response = [500, { 'content-type' => 'text/plain' }, ['Internal Server Error']]
225
+ end
226
+
227
+ render_c_loop_response(response, keep_alive)
228
+ ensure
229
+ ENV_POOL.release(env) if env
230
+ INPUT_POOL.release(input) if input
231
+ end
232
+
233
+ # 2.14-A — assemble the Rack env for a C-accept-loop dispatch.
234
+ # Mirrors the constants `build_env` sets on the regular path
235
+ # but skips the `connection`/hijack branches: the C accept
236
+ # loop owns the fd; full-hijack semantics are out of scope
237
+ # for this dispatch shape (h1 keep-alive is handled in C).
238
+ # Returns `[env, input]` so the caller can release both back
239
+ # to their pools after the response is rendered.
240
+ def build_c_loop_env(method_str, path_str, query_str,
241
+ host_str, headers_blob, remote_addr)
242
+ server_name, server_port = split_host(host_str || '')
243
+
244
+ env = ENV_POOL.acquire
245
+ input = INPUT_POOL.acquire
246
+ input.string = EMPTY_INPUT_BUFFER
247
+ input.rewind
248
+
249
+ env['REQUEST_METHOD'] = method_str
250
+ env['PATH_INFO'] = path_str
251
+ env['QUERY_STRING'] = query_str || ''
252
+ env['SERVER_PROTOCOL'] = 'HTTP/1.1'
253
+ env['HTTP_VERSION'] = 'HTTP/1.1'
254
+ env['SERVER_NAME'] = server_name
255
+ env['SERVER_PORT'] = server_port
256
+ env['SERVER_SOFTWARE'] = SERVER_SOFTWARE_VALUE
257
+ env['REMOTE_ADDR'] = remote_addr.nil? || remote_addr.empty? ? '127.0.0.1' : remote_addr
258
+ env['rack.url_scheme'] = 'http'
259
+ env['rack.errors'] = $stderr
260
+ env['rack.version'] = RACK_VERSION
261
+ env['rack.multithread'] = true
262
+ env['rack.multiprocess'] = false
263
+ env['rack.run_once'] = false
264
+ env['rack.hijack?'] = false
265
+ env['SCRIPT_NAME'] = ''
266
+ env['rack.input'] = input
267
+ # 2.14-A — guarded `is_a?(String) && !empty?` (rather than
268
+ # `present?`) so rubocop-rails's Style/Present autocorrect
269
+ # can't rewrite the branch to a Rails-only API. Same pattern
270
+ # `Server#dispatch_handed_off` uses for `partial`.
271
+ env['HTTP_HOST'] = host_str if host_str.is_a?(String) && !host_str.empty?
272
+
273
+ parse_c_loop_headers!(env, headers_blob) if headers_blob.is_a?(String) && !headers_blob.empty?
274
+
275
+ [env, input]
276
+ end
277
+
278
+ # 2.14-A — parse the raw header block the C accept loop hands
279
+ # us into the env hash. Each line is `name: value\r\n`; the
280
+ # final empty line is already trimmed by the caller (the C
281
+ # loop slices between request-line-end and the closing
282
+ # CRLFCRLF and passes the inner bytes verbatim).
283
+ #
284
+ # We honour the same HTTP_KEY_CACHE the regular adapter path
285
+ # uses, so `equal?` pointer-compares from upstream Rack code
286
+ # (Rack::Attack et al.) keep working.
287
+ def parse_c_loop_headers!(env, headers_blob)
288
+ return if headers_blob.empty?
289
+
290
+ c_upcase = c_upcase_available?
291
+ # The Ruby parser walks line-by-line; allocations are 1
292
+ # String per header (the value). Header names go through
293
+ # the cache hit (no alloc) or the C-ext upcase_underscore
294
+ # (single-call alloc).
295
+ start = 0
296
+ blen = headers_blob.bytesize
297
+ while start < blen
298
+ eol = headers_blob.index("\r\n", start) || blen
299
+ line = headers_blob.byteslice(start, eol - start)
300
+ start = eol + 2
301
+ next if line.empty?
302
+
303
+ colon = line.index(':')
304
+ next unless colon
305
+
306
+ name = line.byteslice(0, colon).downcase
307
+ # Skip the colon, then any leading whitespace.
308
+ v_start = colon + 1
309
+ v_start += 1 while v_start < line.bytesize && [32, 9].include?(line.getbyte(v_start))
310
+ v_end = line.bytesize
311
+ v_end -= 1 while v_end > v_start && [32, 9].include?(line.getbyte(v_end - 1))
312
+ value = line.byteslice(v_start, v_end - v_start)
313
+
314
+ key = HTTP_KEY_CACHE[name] ||
315
+ (c_upcase ? ::Hyperion::CParser.upcase_underscore(name) : "HTTP_#{name.upcase.tr('-', '_')}")
316
+ env[key] = value
317
+ end
318
+
319
+ env['CONTENT_TYPE'] = env['HTTP_CONTENT_TYPE'] if env.key?('HTTP_CONTENT_TYPE')
320
+ env['CONTENT_LENGTH'] = env['HTTP_CONTENT_LENGTH'] if env.key?('HTTP_CONTENT_LENGTH')
321
+ nil
322
+ end
323
+
324
+ # 2.14-A — minimal `Hyperion::Request` value for lifecycle
325
+ # hook observers. Only built when hooks are active (the
326
+ # `has_request_hooks?` guard skips the alloc on the no-hook
327
+ # hot path).
328
+ def c_loop_request_for(env)
329
+ ::Hyperion::Request.new(
330
+ method: env['REQUEST_METHOD'],
331
+ path: env['PATH_INFO'],
332
+ query_string: env['QUERY_STRING'],
333
+ http_version: 'HTTP/1.1',
334
+ headers: {},
335
+ body: nil
336
+ )
337
+ end
338
+
339
+ # 2.14-A — render a Rack `[status, headers, body]` triple to
340
+ # the wire bytes for the C loop. Honours:
341
+ # * `keep_alive` — emit `connection: keep-alive` vs
342
+ # `connection: close`. The C loop honours the
343
+ # `connection: close` request header by passing
344
+ # `keep_alive=false`; ditto on Rack apps that opt in via
345
+ # the response header.
346
+ # * `content-length` — auto-computed from the body bytes
347
+ # unless the app set it explicitly. Required for
348
+ # keep-alive correctness.
349
+ # * `body.each` — collected into a single binary blob; the
350
+ # C loop writes head + body in one syscall.
351
+ # * `body.close` — invoked after iteration per Rack spec.
352
+ #
353
+ # Streaming bodies (Rack 3 `body.call(stream)` shape) are NOT
354
+ # supported in the C-loop dispatch. Apps that need streaming
355
+ # must register via the legacy `Connection#serve` path
356
+ # (don't use the block form of `Server.handle`); the
357
+ # `eligible_route_table?` check refuses to engage the C loop
358
+ # for tables containing those handlers.
359
+ def render_c_loop_response(response, keep_alive)
360
+ unless response.is_a?(Array) && response.length == 3
361
+ response = [500, { 'content-type' => 'text/plain' }, ['Invalid Rack response']]
362
+ end
363
+ status, headers, body = response
364
+
365
+ body_bytes = collect_body_bytes(body)
366
+ headers_out = normalize_response_headers(headers, body_bytes.bytesize, keep_alive)
367
+ head = build_status_line(status) + headers_out + "\r\n"
368
+
369
+ buf = String.new(capacity: head.bytesize + body_bytes.bytesize, encoding: Encoding::ASCII_8BIT)
370
+ buf << head.b << body_bytes
371
+ buf
372
+ ensure
373
+ begin
374
+ body.close if body.respond_to?(:close)
375
+ rescue StandardError
376
+ nil
377
+ end
378
+ end
379
+
380
+ # 2.14-A — drain a Rack body into a single binary blob.
381
+ # Honours both Array bodies (the common case — `[body_str]`)
382
+ # and `each`-yielding bodies. Rack 3 streaming bodies (the
383
+ # `call(stream)` variant) raise here; the eligibility check
384
+ # is supposed to refuse them at registration time.
385
+ def collect_body_bytes(body)
386
+ return body[0].b if body.is_a?(Array) && body.length == 1 && body[0].is_a?(String)
387
+
388
+ buf = String.new(encoding: Encoding::ASCII_8BIT)
389
+ body.each { |chunk| buf << chunk.to_s.b } if body.respond_to?(:each)
390
+ buf
391
+ end
392
+
393
+ # 2.14-A — Build the response header lines including
394
+ # `content-length`, `connection`, and `server`. Skips any
395
+ # header named `connection`/`content-length`/`transfer-encoding`
396
+ # the app set (we own those in the C-loop path).
397
+ def normalize_response_headers(headers, body_len, keep_alive)
398
+ out = String.new(encoding: Encoding::ASCII_8BIT)
399
+ if headers.is_a?(Hash)
400
+ headers.each do |name, value|
401
+ ln = name.to_s.downcase
402
+ next if %w[connection content-length transfer-encoding].include?(ln)
403
+
404
+ # Multi-value headers (Rack 3: Array of values, or
405
+ # newline-joined String) — emit one line per value.
406
+ vals = value.is_a?(Array) ? value : value.to_s.split("\n")
407
+ vals.each do |v|
408
+ out << ln << ': ' << v.to_s << "\r\n"
409
+ end
410
+ end
411
+ end
412
+ out << 'content-length: ' << body_len.to_s << "\r\n"
413
+ out << (keep_alive ? "connection: keep-alive\r\n" : "connection: close\r\n")
414
+ out
415
+ end
416
+
417
+ # 2.14-A — minimal status line builder. Covers the canonical
418
+ # 200/201/204/301/302/304/400/401/403/404/500 by name; everything
419
+ # else falls back to a generic reason phrase since the Rack
420
+ # body still wins at the protocol level.
421
+ STATUS_LINES = {
422
+ 200 => "HTTP/1.1 200 OK\r\n",
423
+ 201 => "HTTP/1.1 201 Created\r\n",
424
+ 204 => "HTTP/1.1 204 No Content\r\n",
425
+ 301 => "HTTP/1.1 301 Moved Permanently\r\n",
426
+ 302 => "HTTP/1.1 302 Found\r\n",
427
+ 304 => "HTTP/1.1 304 Not Modified\r\n",
428
+ 400 => "HTTP/1.1 400 Bad Request\r\n",
429
+ 401 => "HTTP/1.1 401 Unauthorized\r\n",
430
+ 403 => "HTTP/1.1 403 Forbidden\r\n",
431
+ 404 => "HTTP/1.1 404 Not Found\r\n",
432
+ 500 => "HTTP/1.1 500 Internal Server Error\r\n"
433
+ }.freeze
434
+
435
+ def build_status_line(status)
436
+ STATUS_LINES[status] || "HTTP/1.1 #{status} OK\r\n"
437
+ end
438
+
154
439
  # 2.1.0 (WS-1): `connection:` is the Hyperion::Connection that owns
155
440
  # the underlying socket for this request. When non-nil, the env hash
156
441
  # advertises Rack 3 full-hijack support — the app can call
@@ -34,6 +34,92 @@ module Hyperion
34
34
  module ConnectionLoop
35
35
  module_function
36
36
 
37
+ # 2.14-B — bound applied to the wake-connect dial inside
38
+ # `Server#stop`. The listener is local — a successful connect
39
+ # is sub-millisecond — so the cap exists purely as a sanity
40
+ # bound for the pathological case where the listener was
41
+ # already torn down (Errno::ECONNREFUSED is fast) or the
42
+ # kernel netstack is somehow stuck (e.g. CI under heavy load).
43
+ WAKE_CONNECT_TIMEOUT_SECONDS = 1.0
44
+
45
+ # 2.14-B — number of wake-connect dials issued per `Server#stop`.
46
+ # In single-server / `:share` cluster mode (Darwin/BSD), one dial
47
+ # is enough — the listener is shared and any wake races to a
48
+ # parked accept call. In `:reuseport` cluster mode (Linux), the
49
+ # kernel hashes incoming SYNs across each worker's per-process
50
+ # listener fd; one dial may hash to a sibling whose stop hasn't
51
+ # progressed, leaving THIS worker's accept thread parked. K=8
52
+ # drops the miss probability to <1% for realistic worker counts
53
+ # (≤32 workers per host) and adds at most ~8ms to a stop call —
54
+ # well below the master-side `graceful_timeout` (30s default).
55
+ WAKE_CONNECT_BURST = 8
56
+
57
+ # 2.14-B — Wake any thread parked in `accept(2)` on the listener
58
+ # bound at `host:port` by dialing one (or `count`) throwaway TCP
59
+ # connections.
60
+ #
61
+ # Background. On Linux ≥ 6.x, calling `close()` on a listening
62
+ # socket from one thread does NOT interrupt another thread that
63
+ # is currently blocked in `accept(2)` on that same fd — the
64
+ # kernel silently dropped the close-wake guarantee that
65
+ # `Server#stop` (and 2.13-C's spec teardown) had relied on.
66
+ # Without this helper, the C accept loop stays parked until a
67
+ # real connection arrives, which during a SIGTERM-driven graceful
68
+ # shutdown means "until SIGKILL".
69
+ #
70
+ # The fix is structural: dial a throwaway TCP connection at the
71
+ # listener's bound address. The accept call returns with the new
72
+ # fd, the C loop services it (a 0-byte read drops it), then
73
+ # re-checks `hyp_cl_stop` between accepts and exits cleanly. The
74
+ # 2.13-C connection_loop_spec helper does the same thing in spec
75
+ # land — this is the production-side mirror.
76
+ #
77
+ # Burst semantics. With SO_REUSEPORT (Linux cluster mode), the
78
+ # kernel hashes each SYN to one of the N still-open per-worker
79
+ # listeners. A single dial from worker A may hash to worker B —
80
+ # leaving A's parked accept un-woken. Dialing K times (default
81
+ # `WAKE_CONNECT_BURST`) drives the miss probability down to
82
+ # negligible for typical worker counts.
83
+ #
84
+ # Failure-tolerant by construction:
85
+ # * `Errno::ECONNREFUSED` — listener already closed (the close
86
+ # raced ahead of us). Nothing to wake; bail out of the burst
87
+ # so we don't spend the timeout budget on doomed dials.
88
+ # * `Errno::EADDRNOTAVAIL` — interface gone. Same.
89
+ # * Connect timeout — kernel netstack is stuck; we tried, the
90
+ # caller's `thread.join(timeout)` will surface the symptom.
91
+ # * Any other socket error — log nothing (we may be running
92
+ # inside a signal handler thread); just swallow.
93
+ def wake_listener(host, port, connect_timeout: WAKE_CONNECT_TIMEOUT_SECONDS,
94
+ count: 1)
95
+ return unless host && port
96
+ return if count <= 0
97
+
98
+ count.times do
99
+ break unless dial_wake_once(host, port, connect_timeout)
100
+ end
101
+ nil
102
+ end
103
+
104
+ # 2.14-B — single dial. Returns true on success (continue
105
+ # bursting), false on a "listener gone" outcome (abort the burst
106
+ # so we don't waste the timeout budget on N×ECONNREFUSED).
107
+ def dial_wake_once(host, port, connect_timeout)
108
+ ::Socket.tcp(host, port, connect_timeout: connect_timeout, &:close)
109
+ true
110
+ rescue Errno::ECONNREFUSED, Errno::EADDRNOTAVAIL, Errno::EHOSTUNREACH,
111
+ Errno::ENETUNREACH
112
+ # Listener gone — no point retrying, the kernel will refuse
113
+ # every dial in this burst the same way.
114
+ false
115
+ rescue Errno::ETIMEDOUT, Errno::ECONNRESET, Errno::EPIPE,
116
+ Errno::EBADF, IOError, SocketError
117
+ # Transient — keep bursting in case a later dial races into a
118
+ # still-open sibling listener (REUSEPORT cluster mode).
119
+ true
120
+ end
121
+ private_class_method :dial_wake_once
122
+
37
123
  # Whether the C accept loop is available and the env didn't
38
124
  # disable it.
39
125
  def available?
@@ -78,20 +164,32 @@ module Hyperion
78
164
  %w[1 on true yes].include?(env.downcase)
79
165
  end
80
166
 
81
- # Whether the route table is C-loop eligible: only `StaticEntry`
82
- # handlers, at least one of them, no dynamic handlers anywhere.
167
+ # Whether the route table is C-loop eligible: every registered
168
+ # entry is either a `StaticEntry` (2.12-C path) or a
169
+ # `DynamicBlockEntry` (2.14-A path), and the table has at least
170
+ # one of either. Legacy `Server.handle(method, path, handler)`
171
+ # registrations (where `handler` takes a `Hyperion::Request`)
172
+ # disable the C path — those still flow through `Connection#serve`.
83
173
  def eligible_route_table?(route_table)
84
174
  return false unless route_table
85
175
 
86
- any_static = false
176
+ any_eligible = false
87
177
  route_table.instance_variable_get(:@routes).each_value do |path_table|
88
178
  path_table.each_value do |handler|
89
- return false unless handler.is_a?(::Hyperion::Server::RouteTable::StaticEntry)
179
+ return false unless eligible_entry?(handler)
90
180
 
91
- any_static = true
181
+ any_eligible = true
92
182
  end
93
183
  end
94
- any_static
184
+ any_eligible
185
+ end
186
+
187
+ # 2.14-A — predicate split out so specs and the engagement check
188
+ # can introspect single entries. Lives here (rather than on the
189
+ # entry classes) so the eligibility surface stays in one place.
190
+ def eligible_entry?(handler)
191
+ handler.is_a?(::Hyperion::Server::RouteTable::StaticEntry) ||
192
+ handler.is_a?(::Hyperion::Server::RouteTable::DynamicBlockEntry)
95
193
  end
96
194
 
97
195
  # Build a lifecycle callback that, when invoked from the C loop
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'stringio'
4
+
3
5
  module Hyperion
4
6
  class Server
5
7
  # 2.10-D — direct-dispatch route registry. Mirrors agoo's
@@ -86,6 +88,68 @@ module Hyperion
86
88
  end
87
89
  end
88
90
 
91
+ # 2.14-A — wrapper for a Rack-style block registered via
92
+ # `Server.handle(:GET, '/path') { |env| [...] }`. Differs from
93
+ # `StaticEntry` in that the response is computed per-request
94
+ # rather than baked at registration time — but the route table
95
+ # entry shape is uniform, so the C accept loop can branch on
96
+ # `is_a?(DynamicBlockEntry)` AFTER the StaticEntry check and
97
+ # invoke the block via the registered C-loop dispatch helper.
98
+ #
99
+ # The struct holds:
100
+ # * `method` — request-method symbol (`:GET`, `:POST`, ...)
101
+ # * `path` — exact-match path String (frozen)
102
+ # * `block` — the registered Proc / lambda; receives a Rack
103
+ # env hash and must return a `[status, headers, body]`
104
+ # triple per the Rack spec. The C accept loop hands it a
105
+ # populated env via the `Adapter::Rack.dispatch_for_c_loop`
106
+ # helper; the block sees the same env shape Rack apps
107
+ # normally see (HTTP_*, REQUEST_METHOD, PATH_INFO, etc.).
108
+ #
109
+ # Calling the entry directly (the legacy fall-through path used
110
+ # when the C accept loop is NOT engaged — TLS listeners, mixed
111
+ # tables, operator escape hatch via `HYPERION_C_ACCEPT_LOOP=0`)
112
+ # delegates straight to the block with a freshly-built env via
113
+ # the existing `Adapter::Rack#call` machinery. The Connection
114
+ # path's direct-route dispatcher already handles
115
+ # `respond_to?(:call)` entries by invoking them with a
116
+ # `Hyperion::Request` value object — we route through that
117
+ # surface so the legacy fallback stays bit-identical to a
118
+ # 2.13-shape `Server.handle` registration.
119
+ DynamicBlockEntry = Struct.new(:method, :path, :block) do
120
+ # Legacy direct-route surface: `RouteTable#lookup` → handler →
121
+ # `handler.call(request)` returning a `[status, headers, body]`
122
+ # triple. Used by the Connection path when the C accept loop is
123
+ # disengaged (TLS, mixed tables). We hand the block a minimal
124
+ # env hash so it sees the same Rack-style API regardless of
125
+ # which dispatch shape served the request.
126
+ def call(request)
127
+ env = build_legacy_env(request)
128
+ block.call(env)
129
+ end
130
+
131
+ private
132
+
133
+ def build_legacy_env(request)
134
+ headers = request.respond_to?(:headers) ? (request.headers || {}) : {}
135
+ env = {
136
+ 'REQUEST_METHOD' => request.method,
137
+ 'PATH_INFO' => request.path,
138
+ 'QUERY_STRING' => request.query_string.to_s,
139
+ 'SERVER_NAME' => 'localhost',
140
+ 'SERVER_PORT' => '80',
141
+ 'rack.input' => StringIO.new(request.body.to_s),
142
+ 'rack.errors' => $stderr,
143
+ 'rack.url_scheme' => 'http'
144
+ }
145
+ headers.each do |name, value|
146
+ key = "HTTP_#{name.to_s.upcase.tr('-', '_')}"
147
+ env[key] = value
148
+ end
149
+ env
150
+ end
151
+ end
152
+
89
153
  def initialize
90
154
  # Per-method Hash so the lookup is `@routes[:GET][path]`
91
155
  # — two integer-keyed-Hash hits. Pre-allocate the seven