hyperion-rb 1.0.1 → 1.2.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.
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hyperion
4
+ # Renders Hyperion.stats as Prometheus text exposition format (v0.0.4).
5
+ # Mounted by AdminMiddleware on GET /-/metrics; the returned content-type
6
+ # is `text/plain; version=0.0.4; charset=utf-8`.
7
+ #
8
+ # Mapping rules:
9
+ # - keys listed in KNOWN_METRICS get their canonical name + curated HELP/TYPE
10
+ # - keys matching `responses_<3-digit>` are grouped under a single
11
+ # `hyperion_responses_status_total` family with a `status` label
12
+ # - any other key is auto-exported as `hyperion_<key>` with a generic HELP
13
+ # line, so newly-added counters surface in Prometheus without code changes
14
+ # here (the curated-name path is just nicer presentation, not gating)
15
+ #
16
+ # Output ordering is deterministic for stable scrape diffs:
17
+ # - known metrics in KNOWN_METRICS declaration order
18
+ # - status codes ascending
19
+ # - other keys alphabetically
20
+ module PrometheusExporter
21
+ module_function
22
+
23
+ KNOWN_METRICS = {
24
+ requests: { name: 'hyperion_requests_total',
25
+ help: 'Total HTTP requests handled',
26
+ type: 'counter' },
27
+ bytes_read: { name: 'hyperion_bytes_read_total',
28
+ help: 'Total bytes read from request sockets',
29
+ type: 'counter' },
30
+ bytes_written: { name: 'hyperion_bytes_written_total',
31
+ help: 'Total bytes written to response sockets',
32
+ type: 'counter' },
33
+ rejected_connections: { name: 'hyperion_rejected_connections_total',
34
+ help: 'Connections rejected due to backpressure (max_pending)',
35
+ type: 'counter' },
36
+ sendfile_responses: { name: 'hyperion_sendfile_responses_total',
37
+ help: 'Responses sent via plain-TCP sendfile(2) zero-copy path',
38
+ type: 'counter' },
39
+ tls_zerobuf_responses: { name: 'hyperion_tls_zerobuf_responses_total',
40
+ help: 'Responses sent via TLS IO.copy_stream (avoids userspace String build, but TLS encryption forces copy)',
41
+ type: 'counter' }
42
+ }.freeze
43
+
44
+ STATUS_KEY_PATTERN = /\Aresponses_(\d{3})\z/
45
+
46
+ STATUS_FAMILY_NAME = 'hyperion_responses_status_total'
47
+ STATUS_FAMILY_HELP = 'Responses by HTTP status code'
48
+
49
+ def render(stats)
50
+ buf = +''
51
+ grouped_status = {}
52
+ other = {}
53
+ known = {}
54
+
55
+ stats.each do |key, value|
56
+ if (match = key.to_s.match(STATUS_KEY_PATTERN))
57
+ grouped_status[match[1]] = value
58
+ elsif KNOWN_METRICS.key?(key)
59
+ known[key] = value
60
+ else
61
+ other[key] = value
62
+ end
63
+ end
64
+
65
+ # Known metrics first, in declaration order — gives the scrape a stable,
66
+ # human-friendly preamble regardless of hash insertion order.
67
+ KNOWN_METRICS.each do |key, meta|
68
+ next unless known.key?(key)
69
+
70
+ append_metric(buf, meta[:name], meta[:help], meta[:type], known[key])
71
+ end
72
+
73
+ unless grouped_status.empty?
74
+ buf << "# HELP #{STATUS_FAMILY_NAME} #{STATUS_FAMILY_HELP}\n"
75
+ buf << "# TYPE #{STATUS_FAMILY_NAME} counter\n"
76
+ grouped_status.sort.each do |status, value|
77
+ buf << %(#{STATUS_FAMILY_NAME}{status="#{status}"} #{value}\n)
78
+ end
79
+ end
80
+
81
+ other.sort_by { |k, _| k.to_s }.each do |key, value|
82
+ name = "hyperion_#{key}"
83
+ append_metric(buf, name, 'Hyperion internal counter (auto-exported)', 'counter', value)
84
+ end
85
+
86
+ buf
87
+ end
88
+
89
+ def append_metric(buf, name, help, type, value)
90
+ buf << "# HELP #{name} #{help}\n"
91
+ buf << "# TYPE #{name} #{type}\n"
92
+ buf << "#{name} #{value}\n"
93
+ end
94
+ private_class_method :append_metric
95
+ end
96
+ end
@@ -36,6 +36,21 @@ module Hyperion
36
36
  CRLF_HEADER_VALUE = /[\r\n]/
37
37
 
38
38
  def write(io, status, headers, body, keep_alive: false)
39
+ # Zero-copy fast path: bodies that point at an on-disk file (Rack::Files,
40
+ # asset servers, signed-download responders) get streamed via
41
+ # IO.copy_stream which delegates to sendfile(2) on Linux for plain TCP
42
+ # sockets — bytes go from the file's page cache straight to the socket
43
+ # buffer with no userspace allocation. For TLS sockets we still avoid the
44
+ # multi-MB String build, but encryption forces a userspace round-trip so
45
+ # we count that path separately.
46
+ return write_sendfile(io, status, headers, body, keep_alive: keep_alive) if body.respond_to?(:to_path)
47
+
48
+ write_buffered(io, status, headers, body, keep_alive: keep_alive)
49
+ end
50
+
51
+ private
52
+
53
+ def write_buffered(io, status, headers, body, keep_alive:)
39
54
  # Phase 1 buffers the full body so Content-Length is exact.
40
55
  # Phase 2 introduces chunked transfer-encoding for streaming bodies;
41
56
  # Phase 5 batches via IO::Buffer to avoid this intermediate String.
@@ -43,7 +58,7 @@ module Hyperion
43
58
  body.each { |chunk| buffered << chunk }
44
59
 
45
60
  reason = REASONS[status] || 'Unknown'
46
- date_str = Time.now.httpdate
61
+ date_str = cached_date
47
62
 
48
63
  head = build_head(status, reason, headers, buffered.bytesize, keep_alive, date_str)
49
64
 
@@ -51,19 +66,68 @@ module Hyperion
51
66
  # SINGLE io.write call. Each syscall round-trip is ~1 usec on macOS
52
67
  # kqueue; before this change we issued (1 status) + (N headers) + (1 blank)
53
68
  # + (1 body) = 8+ syscalls per response. Now: 1 syscall.
54
- if buffered.empty?
55
- io.write(head)
56
- else
57
- # Concatenate into the head buffer (which is already a fresh +'' from
58
- # the C builder or the Ruby fallback) so we still emit a single write.
59
- head << buffered
60
- io.write(head)
61
- end
69
+ bytes_out = if buffered.empty?
70
+ io.write(head)
71
+ head.bytesize
72
+ else
73
+ # Concatenate into the head buffer (which is already a fresh +''
74
+ # from the C builder or the Ruby fallback) so we still emit a
75
+ # single write.
76
+ head << buffered
77
+ io.write(head)
78
+ head.bytesize
79
+ end
80
+ Hyperion.metrics.increment(:bytes_written, bytes_out)
62
81
  ensure
63
82
  body.close if body.respond_to?(:close)
64
83
  end
65
84
 
66
- private
85
+ def write_sendfile(io, status, headers, body, keep_alive:)
86
+ path = body.to_path
87
+ file = File.open(path, 'rb')
88
+ file_size = file.size
89
+
90
+ # If the app explicitly set content-length, respect it; otherwise use the
91
+ # real file size. Rack::Files does not pre-set content-length, so the
92
+ # common case is the File.size branch.
93
+ content_length = explicit_content_length(headers) || file_size
94
+
95
+ reason = REASONS[status] || 'Unknown'
96
+ date_str = cached_date
97
+ head = build_head(status, reason, headers, content_length, keep_alive, date_str)
98
+
99
+ io.write(head)
100
+ # IO.copy_stream copies up to file_size bytes from the file to the socket.
101
+ # On Linux + plain TCPSocket this triggers sendfile(2) — kernel-level
102
+ # zero-copy. On TLS sockets and non-Linux platforms it falls back to
103
+ # internal read+write loops, but we still avoid building a String the
104
+ # size of the file in Ruby.
105
+ copied = IO.copy_stream(file, io, file_size)
106
+
107
+ record_zero_copy_metric(io)
108
+ Hyperion.metrics.increment(:bytes_written, head.bytesize + copied)
109
+ ensure
110
+ file&.close
111
+ body.close if body.respond_to?(:close)
112
+ end
113
+
114
+ def explicit_content_length(headers)
115
+ headers.each do |k, v|
116
+ return v.to_i if k.to_s.casecmp('content-length').zero?
117
+ end
118
+ nil
119
+ end
120
+
121
+ # Plain TCPSocket → real sendfile(2). TLS-wrapped sockets cannot use
122
+ # sendfile (kernel can't encrypt) but still avoid the per-response String
123
+ # allocation, so we track them under a separate counter.
124
+ def record_zero_copy_metric(io)
125
+ if defined?(::OpenSSL::SSL::SSLSocket) && io.is_a?(::OpenSSL::SSL::SSLSocket)
126
+ Hyperion.metrics.increment(:tls_zerobuf_responses)
127
+ else
128
+ Hyperion.metrics.increment(:sendfile_responses)
129
+ end
130
+ end
67
131
 
68
132
  # rc17: prefer the C extension when available — eliminates the per-response
69
133
  # status-line interpolation, normalized hash, and per-header String#<<
@@ -76,6 +140,19 @@ module Hyperion
76
140
  end
77
141
  end
78
142
 
143
+ # Cached HTTP `Date:` header at second resolution. `Time.now.httpdate`
144
+ # allocates several strings; at high r/s the cache reuses one String per
145
+ # second per thread instead of allocating per response.
146
+ def cached_date
147
+ now_s = Process.clock_gettime(Process::CLOCK_REALTIME, :second)
148
+ cache = (Thread.current[:__hyperion_date_cache__] ||= [-1, ''])
149
+ return cache[1] if cache[0] == now_s
150
+
151
+ cache[0] = now_s
152
+ cache[1] = Time.now.httpdate
153
+ cache[1]
154
+ end
155
+
79
156
  def build_head_ruby(status, reason, headers, body_size, keep_alive, date_str)
80
157
  normalized = {}
81
158
  headers.each { |k, v| normalized[k.to_s.downcase] = v }
@@ -20,18 +20,40 @@ module Hyperion
20
20
  DEFAULT_READ_TIMEOUT_SECONDS = 30
21
21
  DEFAULT_THREAD_COUNT = 5
22
22
 
23
+ # Pre-built minimal 503 response for the backpressure path. We bypass
24
+ # ResponseWriter / Rack entirely — no env build, no app dispatch, no
25
+ # access-log line. The bytes are frozen and reused across every
26
+ # rejection so the overload path stays allocation-free. Body is JSON
27
+ # so JSON-only API consumers don't have to special-case the format.
28
+ REJECT_503 = lambda {
29
+ body = +%({"error":"server_busy","retry_after_seconds":1}\n)
30
+ body.force_encoding(Encoding::ASCII_8BIT)
31
+ head = +"HTTP/1.1 503 Service Unavailable\r\n" \
32
+ "content-type: application/json\r\n" \
33
+ "content-length: #{body.bytesize}\r\n" \
34
+ "retry-after: 1\r\n" \
35
+ "connection: close\r\n" \
36
+ "\r\n"
37
+ head.force_encoding(Encoding::ASCII_8BIT)
38
+ (head + body).freeze
39
+ }.call
40
+
23
41
  attr_reader :host, :port
24
42
 
25
43
  def initialize(app:, host: '127.0.0.1', port: 9292, read_timeout: DEFAULT_READ_TIMEOUT_SECONDS,
26
- tls: nil, thread_count: DEFAULT_THREAD_COUNT)
27
- @host = host
28
- @port = port
29
- @app = app
30
- @read_timeout = read_timeout
31
- @tls = tls
32
- @thread_count = thread_count
33
- @thread_pool = nil
34
- @stopped = false
44
+ tls: nil, thread_count: DEFAULT_THREAD_COUNT, max_pending: nil,
45
+ max_request_read_seconds: 60, h2_settings: nil)
46
+ @host = host
47
+ @port = port
48
+ @app = app
49
+ @read_timeout = read_timeout
50
+ @tls = tls
51
+ @thread_count = thread_count
52
+ @max_pending = max_pending
53
+ @max_request_read_seconds = max_request_read_seconds
54
+ @h2_settings = h2_settings
55
+ @thread_pool = nil
56
+ @stopped = false
35
57
  end
36
58
 
37
59
  def listen
@@ -83,26 +105,19 @@ module Hyperion
83
105
 
84
106
  def start
85
107
  listen unless @server
86
- @thread_pool = ThreadPool.new(size: @thread_count) if @thread_count.positive?
108
+ @thread_pool = ThreadPool.new(size: @thread_count, max_pending: @max_pending) if @thread_count.positive?
87
109
 
88
- Async do |task|
89
- until @stopped
90
- socket = accept_or_nil
91
- next unless socket
92
-
93
- apply_timeout(socket)
94
- # Plain HTTP/1.1 with a pool: submit straight to the worker — no
95
- # fiber wrap needed (submit_connection returns immediately and the
96
- # worker thread owns the connection for its lifetime).
97
- # TLS still goes through a fiber: ALPN negotiation determines h2
98
- # vs http/1.1, and h2 needs the fiber because each stream is its
99
- # own fiber inside Http2Handler.
100
- if @thread_pool && !@tls
101
- @thread_pool.submit_connection(socket, @app)
102
- else
103
- task.async { dispatch(socket) }
104
- end
105
- end
110
+ if @tls
111
+ # TLS path: ALPN may pick `h2`, and h2 spawns one fiber per stream
112
+ # inside Http2Handler. Keep the Async wrapper so the scheduler is
113
+ # available for those fibers and for handshake yields.
114
+ start_async_loop
115
+ else
116
+ # Plain HTTP/1.1: the worker thread owns each connection for its
117
+ # lifetime, so the Async wrapper adds zero value (no fibers ever
118
+ # run on this loop's task). Skip it — pure IO.select + accept_nonblock
119
+ # shaves measurable overhead off the accept hot path.
120
+ start_raw_loop
106
121
  end
107
122
  ensure
108
123
  @thread_pool&.shutdown
@@ -117,20 +132,79 @@ module Hyperion
117
132
 
118
133
  private
119
134
 
135
+ # Plain HTTP/1.1 accept loop — no fiber wrap. Connections go straight to
136
+ # a worker via the thread pool, or are served inline when no pool is
137
+ # configured (thread_count: 0). Matches the dispatch contract used by
138
+ # the TLS path; just skips the irrelevant h2/ALPN branch.
139
+ def start_raw_loop
140
+ until @stopped
141
+ socket = accept_or_nil
142
+ next unless socket
143
+
144
+ apply_timeout(socket)
145
+ if @thread_pool
146
+ unless @thread_pool.submit_connection(socket, @app,
147
+ max_request_read_seconds: @max_request_read_seconds)
148
+ reject_connection(socket)
149
+ end
150
+ else
151
+ Connection.new.serve(socket, @app, max_request_read_seconds: @max_request_read_seconds)
152
+ end
153
+ end
154
+ end
155
+
156
+ # TLS / h2-capable accept loop. The Async wrapper is required because
157
+ # h2 streams (inside Http2Handler) and the ALPN handshake yield
158
+ # cooperatively via the scheduler.
159
+ def start_async_loop
160
+ Async do |task|
161
+ until @stopped
162
+ socket = accept_or_nil
163
+ next unless socket
164
+
165
+ apply_timeout(socket)
166
+ task.async { dispatch(socket) }
167
+ end
168
+ end
169
+ end
170
+
120
171
  def dispatch(socket)
121
172
  if socket.is_a?(::OpenSSL::SSL::SSLSocket) && socket.alpn_protocol == 'h2'
122
173
  # HTTP/2: each stream runs on a fiber inside Http2Handler. The
123
174
  # handler still uses the pool's `#call` for app.call hops on each
124
175
  # stream (one per stream, not one per connection).
125
- Http2Handler.new(app: @app, thread_pool: @thread_pool).serve(socket)
176
+ Http2Handler.new(app: @app, thread_pool: @thread_pool, h2_settings: @h2_settings).serve(socket)
126
177
  elsif @thread_pool
127
178
  # HTTP/1.1 (e.g. TLS-wrapped after ALPN picked http/1.1): hand the
128
179
  # connection to a worker thread. The fiber that called dispatch
129
- # returns immediately.
130
- @thread_pool.submit_connection(socket, @app)
180
+ # returns immediately. On overflow, reject with 503 + close.
181
+ unless @thread_pool.submit_connection(socket, @app,
182
+ max_request_read_seconds: @max_request_read_seconds)
183
+ reject_connection(socket)
184
+ end
131
185
  else
132
186
  # No pool (thread_count: 0): inline on the calling fiber.
133
- Connection.new.serve(socket, @app)
187
+ Connection.new.serve(socket, @app, max_request_read_seconds: @max_request_read_seconds)
188
+ end
189
+ end
190
+
191
+ # Backpressure rejection. Emits a pre-built 503 + closes the socket.
192
+ # No Rack env, no app dispatch, no access-log line — the overload
193
+ # path must stay cheap so we don't pile rejection cost on top of the
194
+ # already-saturated workers. Bumps :rejected_connections so operators
195
+ # can alert on sustained overload.
196
+ def reject_connection(socket)
197
+ socket.write(REJECT_503)
198
+ Hyperion.metrics.increment(:rejected_connections)
199
+ rescue StandardError
200
+ # Client may have hung up between accept and our 503 write — that's
201
+ # the failure mode we're protecting them from anyway, so swallow.
202
+ nil
203
+ ensure
204
+ begin
205
+ socket.close
206
+ rescue StandardError
207
+ nil
134
208
  end
135
209
  end
136
210
 
@@ -26,11 +26,12 @@ module Hyperion
26
26
  class ThreadPool
27
27
  SHUTDOWN = :__hyperion_thread_pool_shutdown__
28
28
 
29
- attr_reader :size
29
+ attr_reader :size, :max_pending
30
30
 
31
- def initialize(size:)
32
- @size = size
33
- @inbox = Queue.new # multiplexes both kinds of jobs
31
+ def initialize(size:, max_pending: nil)
32
+ @size = size
33
+ @max_pending = max_pending
34
+ @inbox = Queue.new # multiplexes both kinds of jobs
34
35
  # Pre-allocate one reply queue per in-flight slot for the legacy `#call`
35
36
  # path. Bounded by `size`: if all workers are busy, all reply queues are
36
37
  # checked out, and the next caller blocks on `@reply_pool.pop` until a
@@ -43,8 +44,23 @@ module Hyperion
43
44
  # HTTP/1.1 path: hand the whole socket to a worker thread. The worker
44
45
  # runs `Connection#serve(socket, app)` directly. No per-request hop.
45
46
  # Returns immediately — caller does not wait.
46
- def submit_connection(socket, app)
47
- @inbox << [:connection, socket, app]
47
+ #
48
+ # Returns true on enqueue, false on rejection. When `max_pending` is set
49
+ # and the inbox already has at least that many entries, the connection
50
+ # is rejected up to the caller (Server emits a 503 and closes the
51
+ # socket). Without `max_pending` (default nil) the queue is unbounded
52
+ # and we always return true — preserves pre-1.2 behaviour.
53
+ #
54
+ # The check is inherently racy with worker drain — workers may pop
55
+ # between our `size` read and the `<<`. Backpressure is statistical,
56
+ # not strict. Off-by-one over the configured cap during a thundering
57
+ # accept burst is acceptable; the cost of stricter sync would be a
58
+ # mutex on every enqueue, which we won't pay on the hot path.
59
+ def submit_connection(socket, app, max_request_read_seconds: 60)
60
+ return false if @max_pending && @inbox.size >= @max_pending
61
+
62
+ @inbox << [:connection, socket, app, max_request_read_seconds]
63
+ true
48
64
  end
49
65
 
50
66
  # HTTP/2 + sub-call path: hop one `app.call` from the calling fiber to a
@@ -78,12 +94,12 @@ module Hyperion
78
94
 
79
95
  case job[0]
80
96
  when :connection
81
- _, socket, app = job
97
+ _, socket, app, max_request_read_seconds = job
82
98
  # Worker thread owns the connection for its full lifetime. Pass
83
99
  # thread_pool: nil so Connection#call_app inlines Adapter::Rack.call
84
100
  # — the worker IS the pool, no further hop required.
85
101
  begin
86
- Hyperion::Connection.new.serve(socket, app)
102
+ Hyperion::Connection.new.serve(socket, app, max_request_read_seconds: max_request_read_seconds)
87
103
  rescue StandardError => e
88
104
  Hyperion.logger.error do
89
105
  {
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hyperion
4
- VERSION = '1.0.1'
4
+ VERSION = '1.2.0'
5
5
  end
@@ -18,16 +18,21 @@ module Hyperion
18
18
  class Worker
19
19
  def initialize(host:, port:, app:, read_timeout:, tls: nil,
20
20
  thread_count: Server::DEFAULT_THREAD_COUNT,
21
- config: nil, worker_index: 0, listener: nil)
22
- @host = host
23
- @port = port
24
- @app = app
25
- @read_timeout = read_timeout
26
- @tls = tls
27
- @thread_count = thread_count
28
- @config = config || Hyperion::Config.new
29
- @worker_index = worker_index
30
- @listener = listener
21
+ config: nil, worker_index: 0, listener: nil,
22
+ max_pending: nil, max_request_read_seconds: 60,
23
+ h2_settings: nil)
24
+ @host = host
25
+ @port = port
26
+ @app = app
27
+ @read_timeout = read_timeout
28
+ @tls = tls
29
+ @thread_count = thread_count
30
+ @config = config || Hyperion::Config.new
31
+ @worker_index = worker_index
32
+ @listener = listener
33
+ @max_pending = max_pending
34
+ @max_request_read_seconds = max_request_read_seconds
35
+ @h2_settings = h2_settings
31
36
  end
32
37
 
33
38
  def run
@@ -43,7 +48,10 @@ module Hyperion
43
48
 
44
49
  server = Server.new(host: @host, port: @port, app: @app,
45
50
  read_timeout: @read_timeout, tls: @tls,
46
- thread_count: @thread_count)
51
+ thread_count: @thread_count,
52
+ max_pending: @max_pending,
53
+ max_request_read_seconds: @max_request_read_seconds,
54
+ h2_settings: @h2_settings)
47
55
  tcp_server = @listener || build_reuseport_listener
48
56
  server.adopt_listener(tcp_server)
49
57
 
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hyperion
4
+ # Measures a worker process's resident set size (RSS) in MiB.
5
+ # Cross-platform: uses /proc/<pid>/statm on Linux (zero subprocess) and
6
+ # `ps -o rss= -p <pid>` everywhere else (macOS, BSD).
7
+ module WorkerHealth
8
+ module_function
9
+
10
+ # Returns the worker's RSS in MiB, or nil if it can't be read (process
11
+ # gone, ps not available, /proc not mounted). Callers must handle nil
12
+ # gracefully — health checks must never crash the supervisor.
13
+ def rss_mb(pid)
14
+ if File.readable?("/proc/#{pid}/statm")
15
+ # statm fields are in pages; column index 1 is "resident".
16
+ # PAGE_SIZE = 4096 on x86_64 / aarch64 Linux.
17
+ contents = File.read("/proc/#{pid}/statm")
18
+ pages = contents.split.fetch(1).to_i
19
+ bytes = pages * 4096
20
+ bytes / 1024 / 1024
21
+ else
22
+ # Fallback: ps emits RSS in KiB.
23
+ out = `ps -o rss= -p #{pid} 2>/dev/null`
24
+ kib = out.strip.to_i
25
+ return nil if kib.zero?
26
+
27
+ kib / 1024
28
+ end
29
+ rescue StandardError
30
+ nil
31
+ end
32
+ end
33
+ end
data/lib/hyperion.rb CHANGED
@@ -25,6 +25,23 @@ module Hyperion
25
25
  metrics.snapshot
26
26
  end
27
27
 
28
+ # Whether YJIT is currently enabled in this Ruby process. False on Rubies
29
+ # that don't ship YJIT (JRuby, TruffleRuby) and on CRuby builds compiled
30
+ # without YJIT support. Cheap (no allocations) — safe to call from hot
31
+ # paths if needed for diagnostics.
32
+ def yjit_enabled?
33
+ defined?(::RubyVM::YJIT) && ::RubyVM::YJIT.enabled?
34
+ end
35
+
36
+ # Whether the llhttp C extension loaded. False on JRuby/TruffleRuby and
37
+ # any environment where extconf.rb / make failed at install time. The
38
+ # pure-Ruby parser handles those cases correctly but is ~2× slower on
39
+ # parse-heavy workloads. Operators running production should confirm this
40
+ # returns true; CLI emits a startup banner if it doesn't.
41
+ def c_parser_available?
42
+ defined?(::Hyperion::CParser) && ::Hyperion::CParser.respond_to?(:build_response_head)
43
+ end
44
+
28
45
  # Per-request access logging is ON by default — matches Puma/Rails operator
29
46
  # expectations (Rails::Rack::Logger emits one line per request out of the
30
47
  # box). Operators can disable it via `--no-log-requests`,
@@ -46,6 +63,44 @@ module Hyperion
46
63
  else true # default ON
47
64
  end
48
65
  end
66
+
67
+ # Pre-fork warmup. Run by Master and CLI single-mode BEFORE children are
68
+ # forked (or before the lone worker starts accepting). Pre-allocates the
69
+ # Rack adapter's object pools and eager-touches lazily-resolved constants
70
+ # so each forked child inherits warm memory via copy-on-write — the first
71
+ # N requests on a fresh worker no longer pay the allocation / autoload
72
+ # tax that would otherwise serialize behind the GVL on cold start.
73
+ #
74
+ # Idempotent — second and later calls are no-ops. Failures are swallowed
75
+ # with a warn log: warmup is an optimization, not a correctness gate.
76
+ # If, for instance, OpenSSL can't be required in some odd environment,
77
+ # we'd rather start cold than refuse to boot.
78
+ def warmup!
79
+ return if @warmed
80
+
81
+ @warmed = true
82
+
83
+ if defined?(::Hyperion::Adapter::Rack) && ::Hyperion::Adapter::Rack.respond_to?(:warmup_pool)
84
+ ::Hyperion::Adapter::Rack.warmup_pool(8)
85
+ end
86
+
87
+ # Touch the C extension's response-head builder so its lazily-initialized
88
+ # internal state runs in the master, not in every child after fork.
89
+ ::Hyperion::CParser.respond_to?(:build_response_head) if defined?(::Hyperion::CParser)
90
+
91
+ # Eager-load TLS / SSLSocket. The sendfile path's `is_a?` check would
92
+ # otherwise trigger autoload in the worker on the first TLS response.
93
+ require 'openssl'
94
+ defined?(::OpenSSL::SSL::SSLSocket) && ::OpenSSL::SSL::SSLSocket.name
95
+
96
+ # Force Ruby's tzinfo / strftime-cache load by emitting one httpdate.
97
+ # Subsequent calls hit the per-thread `cached_date` slot in response_writer.
98
+ Time.now.httpdate
99
+ nil
100
+ rescue StandardError => e
101
+ Hyperion.logger.warn { { message: 'warmup failed (non-fatal)', error: e.message } }
102
+ nil
103
+ end
49
104
  end
50
105
  end
51
106
 
@@ -72,6 +127,8 @@ require_relative 'hyperion/request'
72
127
  require_relative 'hyperion/parser'
73
128
  require_relative 'hyperion/c_parser'
74
129
  require_relative 'hyperion/adapter/rack'
130
+ require_relative 'hyperion/prometheus_exporter'
131
+ require_relative 'hyperion/admin_middleware'
75
132
  require_relative 'hyperion/response_writer'
76
133
  require_relative 'hyperion/thread_pool'
77
134
  require_relative 'hyperion/connection'
@@ -79,4 +136,5 @@ require_relative 'hyperion/tls'
79
136
  require_relative 'hyperion/http2_handler'
80
137
  require_relative 'hyperion/server'
81
138
  require_relative 'hyperion/worker'
139
+ require_relative 'hyperion/worker_health'
82
140
  require_relative 'hyperion/master'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hyperion-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrey Lobanov
@@ -148,6 +148,7 @@ files:
148
148
  - lib/hyperion-rb.rb
149
149
  - lib/hyperion.rb
150
150
  - lib/hyperion/adapter/rack.rb
151
+ - lib/hyperion/admin_middleware.rb
151
152
  - lib/hyperion/c_parser.rb
152
153
  - lib/hyperion/cli.rb
153
154
  - lib/hyperion/config.rb
@@ -159,6 +160,7 @@ files:
159
160
  - lib/hyperion/metrics.rb
160
161
  - lib/hyperion/parser.rb
161
162
  - lib/hyperion/pool.rb
163
+ - lib/hyperion/prometheus_exporter.rb
162
164
  - lib/hyperion/request.rb
163
165
  - lib/hyperion/response_writer.rb
164
166
  - lib/hyperion/server.rb
@@ -166,6 +168,7 @@ files:
166
168
  - lib/hyperion/tls.rb
167
169
  - lib/hyperion/version.rb
168
170
  - lib/hyperion/worker.rb
171
+ - lib/hyperion/worker_health.rb
169
172
  homepage: https://github.com/andrew-woblavobla/hyperion
170
173
  licenses:
171
174
  - MIT