hyperion-rb 1.1.0 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/lib/hyperion/adapter/rack.rb +14 -0
- data/lib/hyperion/admin_middleware.rb +47 -17
- data/lib/hyperion/cli.rb +17 -5
- data/lib/hyperion/config.rb +7 -1
- data/lib/hyperion/connection.rb +55 -4
- data/lib/hyperion/http2_handler.rb +90 -2
- data/lib/hyperion/master.rb +24 -1
- data/lib/hyperion/prometheus_exporter.rb +96 -0
- data/lib/hyperion/response_writer.rb +62 -2
- data/lib/hyperion/server.rb +64 -16
- data/lib/hyperion/thread_pool.rb +24 -8
- data/lib/hyperion/version.rb +1 -1
- data/lib/hyperion/worker.rb +19 -11
- data/lib/hyperion.rb +39 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4174d7143559b6bd05bdc78acf4377add8aca32f885e933786c50f31c956e9ba
|
|
4
|
+
data.tar.gz: f163a7f5bd2b363f37205e1f1ba845fb0324c329cc15b4c1144e6d519a1bc60a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ea61b5e3298ae50b9b6530d51e1f9a5299b0ccfea3b99248230a601a96ebaf764b5d7978215e09a7d73ed7e85ee3f8b5f7d13d40a830ca5c4482a9d192b2919a
|
|
7
|
+
data.tar.gz: ed8e125b2ff0c9aab53f3178d0f31d1b0db028f8ebf3a40d09ba11e86c3a62756a3c15c7eb3b288faf8dee6f1062159d372cb0c08997a76fedcb97d485d87283
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.2.0] - 2026-04-27
|
|
4
|
+
|
|
5
|
+
Production hardening + perf round 2. No breaking changes.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **Zero-copy sendfile path** — when a Rack body responds to `#to_path` (e.g. `Rack::Files`, asset uploads), `ResponseWriter` uses `IO.copy_stream(file, socket)` which triggers `sendfile(2)` on Linux for plain TCP. Eliminates the ~MB-sized String allocation per static-asset response. Falls back to userspace copy on TLS / non-Linux but still avoids the userspace String build. New metrics: `:sendfile_responses`, `:tls_zerobuf_responses`.
|
|
9
|
+
- **Hot fork warmup (`Hyperion.warmup!`)** — master pre-allocates the Rack env Hash pool, primes the C extension's lazy state, and touches commonly-resolved constants before `before_fork`. Workers inherit the warm pools via Copy-on-Write. Removes first-N-requests-after-fork allocation tax.
|
|
10
|
+
- **Backpressure (`max_pending`)** — when the thread pool's inbox queue exceeds the configured depth, new accepts get HTTP 503 + `Retry-After: 1` and the socket is closed immediately (no Rack dispatch, no access-log line). Default off (nil); opt in by setting an Integer. New metric: `:rejected_connections`.
|
|
11
|
+
- **Prometheus exporter** — `AdminMiddleware` now serves `GET /-/metrics` in addition to `POST /-/quit` (same token). Renders `Hyperion.stats` as Prometheus text exposition v0.0.4. Counter names follow the `hyperion_<key>_total` convention; `:responses_<code>` keys are grouped under `hyperion_responses_status_total{status="<code>"}`.
|
|
12
|
+
- **Slow-client total-deadline (`max_request_read_seconds`)** — per-request wallclock cap on the request-line + headers read phase (default 60s). Defense-in-depth against slowloris: a malicious client can no longer dribble 1 byte per `read_timeout` window indefinitely. On overrun, Hyperion writes 408 + closes. Resets per request on keep-alive sessions. New metric: `:slow_request_aborts`.
|
|
13
|
+
- **HTTP/2 SETTINGS tuning** — Falcon-class defaults shipped: `MAX_CONCURRENT_STREAMS=128`, `INITIAL_WINDOW_SIZE=1MiB`, `MAX_FRAME_SIZE=1MiB`, `MAX_HEADER_LIST_SIZE=64KiB`. All four overridable via Config DSL (`h2_max_concurrent_streams` etc). Out-of-spec values are clamped + warned, not crashed.
|
|
14
|
+
- **`docs/REVERSE_PROXY.md`** — nginx + AWS ALB samples, X-Forwarded-* semantics, admin-endpoint hardening at the edge. Includes the documented gotcha that ALB-to-target HTTP/2 strips WebSocket upgrade headers (use HTTP/1.1 upstream).
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
- **`ResponseWriter` Date header now uses `cached_date`** — the per-thread, per-second cache landed in 1.1.0 was never wired into the hot path. It is now. Eliminates ~3 String allocations per response (`Time.now.httpdate` → cached String reuse).
|
|
18
|
+
- **`AdminMiddleware`** refactored: shared `authorize` helper between `/-/quit` and `/-/metrics`; `PATH` constant split into `PATH_QUIT` + `PATH_METRICS`.
|
|
19
|
+
- **`Hyperion::Logger` per-thread access buffer key** is now namespaced per Logger instance (already shipped as a 1.1.0 follow-up fix; documented here for completeness).
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- N/A — no regressions discovered between 1.1.0 and 1.2.0.
|
|
23
|
+
|
|
3
24
|
## [1.1.0] - 2026-04-27
|
|
4
25
|
|
|
5
26
|
First minor release after 1.0.0. Production hardening + perf wins, no breaking changes.
|
|
@@ -49,6 +49,20 @@ module Hyperion
|
|
|
49
49
|
)
|
|
50
50
|
|
|
51
51
|
class << self
|
|
52
|
+
# Pre-allocate `n` env-hash and rack-input objects in master before
|
|
53
|
+
# fork. Children inherit the populated free-list via copy-on-write —
|
|
54
|
+
# the hash slots stay shared until a request mutates them. Eliminates
|
|
55
|
+
# the first-N-requests allocation tax that every fresh worker would
|
|
56
|
+
# otherwise pay on cold start. Idempotent: safe to call multiple
|
|
57
|
+
# times; the pool simply caps at its configured `max_size`.
|
|
58
|
+
def warmup_pool(count = 8)
|
|
59
|
+
warmed_envs = Array.new(count) { ENV_POOL.acquire }
|
|
60
|
+
warmed_inputs = Array.new(count) { INPUT_POOL.acquire }
|
|
61
|
+
warmed_envs.each { |e| ENV_POOL.release(e) }
|
|
62
|
+
warmed_inputs.each { |i| INPUT_POOL.release(i) }
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
52
66
|
def call(app, request)
|
|
53
67
|
env, input = build_env(request)
|
|
54
68
|
status, headers, body = app.call(env)
|
|
@@ -7,7 +7,8 @@ module Hyperion
|
|
|
7
7
|
# listener as the application. Disabled by default — only mounted when
|
|
8
8
|
# `admin_token` is configured. Currently provides:
|
|
9
9
|
#
|
|
10
|
-
# POST /-/quit
|
|
10
|
+
# POST /-/quit → triggers graceful master drain (SIGTERM to ppid)
|
|
11
|
+
# GET /-/metrics → returns Hyperion.stats in Prometheus text format
|
|
11
12
|
#
|
|
12
13
|
# Auth: the request must include `X-Hyperion-Admin-Token: <token>`.
|
|
13
14
|
# Mismatch → 401. Path/method mismatch → falls through to the app
|
|
@@ -18,9 +19,17 @@ module Hyperion
|
|
|
18
19
|
# SECURITY: the bearer token is defense-in-depth, not a substitute for
|
|
19
20
|
# network isolation. Operators MUST keep the listener on a private
|
|
20
21
|
# network or behind TLS + an authenticating reverse proxy. Anyone who
|
|
21
|
-
# can reach the listener AND knows the token can drain the server
|
|
22
|
+
# can reach the listener AND knows the token can drain the server or
|
|
23
|
+
# scrape its metrics. See docs/REVERSE_PROXY.md for nginx/ALB recipes
|
|
24
|
+
# that block /-/* at the edge.
|
|
22
25
|
class AdminMiddleware
|
|
23
|
-
|
|
26
|
+
PATH_QUIT = '/-/quit'
|
|
27
|
+
PATH_METRICS = '/-/metrics'
|
|
28
|
+
|
|
29
|
+
METRICS_CONTENT_TYPE = 'text/plain; version=0.0.4; charset=utf-8'
|
|
30
|
+
JSON_CONTENT_TYPE = 'application/json'
|
|
31
|
+
|
|
32
|
+
UNAUTHORIZED_BODY = %({"error":"unauthorized"}\n)
|
|
24
33
|
|
|
25
34
|
def initialize(app, token:, signal_target: nil)
|
|
26
35
|
raise ArgumentError, 'admin_token must be a non-empty String' if token.nil? || token.to_s.empty?
|
|
@@ -33,38 +42,59 @@ module Hyperion
|
|
|
33
42
|
end
|
|
34
43
|
|
|
35
44
|
def call(env)
|
|
36
|
-
|
|
45
|
+
path = env['PATH_INFO']
|
|
46
|
+
method = env['REQUEST_METHOD']
|
|
37
47
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
48
|
+
if path == PATH_QUIT && method == 'POST'
|
|
49
|
+
authorize(env) { handle_quit(env) }
|
|
50
|
+
elsif path == PATH_METRICS && method == 'GET'
|
|
51
|
+
authorize(env) { handle_metrics }
|
|
52
|
+
else
|
|
53
|
+
@app.call(env)
|
|
44
54
|
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# Wrap a handler in the shared bearer-token check. Yields only when the
|
|
60
|
+
# token matches; returns the canonical 401 response otherwise.
|
|
61
|
+
def authorize(env)
|
|
62
|
+
provided = env['HTTP_X_HYPERION_ADMIN_TOKEN'].to_s
|
|
63
|
+
return unauthorized unless secure_match?(provided)
|
|
45
64
|
|
|
65
|
+
yield
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def unauthorized
|
|
69
|
+
[401, { 'content-type' => JSON_CONTENT_TYPE }, [UNAUTHORIZED_BODY]]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def handle_quit(env)
|
|
46
73
|
target = resolve_signal_target
|
|
47
|
-
Hyperion.logger.info
|
|
74
|
+
Hyperion.logger.info do
|
|
75
|
+
{ message: 'admin drain requested', remote_addr: env['REMOTE_ADDR'], target_pid: target }
|
|
76
|
+
end
|
|
48
77
|
begin
|
|
49
78
|
Process.kill('TERM', target)
|
|
50
79
|
rescue StandardError => e
|
|
51
80
|
Hyperion.logger.warn { { message: 'admin drain signal failed', error: e.message } }
|
|
52
|
-
return [500, { 'content-type' =>
|
|
81
|
+
return [500, { 'content-type' => JSON_CONTENT_TYPE }, [%({"error":"signal_failed"}\n)]]
|
|
53
82
|
end
|
|
54
83
|
|
|
55
|
-
[202, { 'content-type' =>
|
|
84
|
+
[202, { 'content-type' => JSON_CONTENT_TYPE }, [%({"status":"draining"}\n)]]
|
|
56
85
|
end
|
|
57
86
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
env['PATH_INFO'] == PATH && env['REQUEST_METHOD'] == 'POST'
|
|
87
|
+
def handle_metrics
|
|
88
|
+
body = PrometheusExporter.render(Hyperion.stats)
|
|
89
|
+
[200, { 'content-type' => METRICS_CONTENT_TYPE }, [body]]
|
|
62
90
|
end
|
|
63
91
|
|
|
64
92
|
def secure_match?(provided)
|
|
65
93
|
return false if provided.empty?
|
|
66
94
|
return false unless provided.bytesize == @token.bytesize
|
|
67
95
|
|
|
96
|
+
# Constant-time comparison. Rack::Utils.secure_compare requires same
|
|
97
|
+
# length, so we prefix-pad first to avoid a length-leak side channel.
|
|
68
98
|
Rack::Utils.secure_compare(provided, @token)
|
|
69
99
|
end
|
|
70
100
|
|
data/lib/hyperion/cli.rb
CHANGED
|
@@ -111,12 +111,21 @@ module Hyperion
|
|
|
111
111
|
tls = build_tls_from_config(config)
|
|
112
112
|
server = Server.new(host: config.host, port: config.port, app: app,
|
|
113
113
|
tls: tls, thread_count: config.thread_count,
|
|
114
|
-
read_timeout: config.read_timeout
|
|
114
|
+
read_timeout: config.read_timeout,
|
|
115
|
+
max_pending: config.max_pending,
|
|
116
|
+
max_request_read_seconds: config.max_request_read_seconds,
|
|
117
|
+
h2_settings: Master.build_h2_settings(config))
|
|
115
118
|
server.listen
|
|
116
119
|
scheme = tls ? 'https' : 'http'
|
|
117
120
|
Hyperion.logger.info { { message: 'listening', url: "#{scheme}://#{server.host}:#{server.port}" } }
|
|
118
121
|
warn_c_parser_unavailable
|
|
119
122
|
|
|
123
|
+
# Pre-allocate Rack env-pool entries and eager-touch lazy constants.
|
|
124
|
+
# In single-mode there's no fork, but the warmup still pays for itself
|
|
125
|
+
# by frontloading the first-N-request allocation cost off the first
|
|
126
|
+
# real client. Idempotent — safe to call once per process.
|
|
127
|
+
Hyperion.warmup!
|
|
128
|
+
|
|
120
129
|
# Single-worker mode reuses the lifecycle hooks: before_fork is a no-op
|
|
121
130
|
# here (no fork happens), and on_worker_boot/on_worker_shutdown fire
|
|
122
131
|
# for the lone in-process "worker" so app code that opens DB pools etc.
|
|
@@ -199,13 +208,16 @@ module Hyperion
|
|
|
199
208
|
private_class_method :maybe_enable_yjit
|
|
200
209
|
|
|
201
210
|
# When admin_token is configured, wrap the app in AdminMiddleware so
|
|
202
|
-
# POST /-/quit
|
|
203
|
-
# the token is unset —
|
|
204
|
-
# still own /-/anything if Hyperion's admin is off.
|
|
211
|
+
# POST /-/quit and GET /-/metrics become token-protected admin endpoints.
|
|
212
|
+
# Skipped when the token is unset — those paths fall through to the app,
|
|
213
|
+
# so apps may still own /-/anything if Hyperion's admin is off.
|
|
205
214
|
def self.wrap_admin_middleware(app, config)
|
|
206
215
|
return app if config.admin_token.nil? || config.admin_token.to_s.empty?
|
|
207
216
|
|
|
208
|
-
Hyperion.logger.info
|
|
217
|
+
Hyperion.logger.info do
|
|
218
|
+
{ message: 'admin endpoint enabled',
|
|
219
|
+
paths: [AdminMiddleware::PATH_QUIT, AdminMiddleware::PATH_METRICS] }
|
|
220
|
+
end
|
|
209
221
|
AdminMiddleware.new(app, token: config.admin_token)
|
|
210
222
|
end
|
|
211
223
|
private_class_method :wrap_admin_middleware
|
data/lib/hyperion/config.rb
CHANGED
|
@@ -28,7 +28,13 @@ module Hyperion
|
|
|
28
28
|
yjit: nil, # nil → auto: enable on production/staging; true/false to force.
|
|
29
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
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, POST /-/quit triggers graceful drain. nil disables
|
|
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
|
+
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).
|
|
35
|
+
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.
|
|
36
|
+
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).
|
|
37
|
+
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).
|
|
32
38
|
}.freeze
|
|
33
39
|
|
|
34
40
|
HOOKS = %i[before_fork on_worker_boot on_worker_shutdown].freeze
|
data/lib/hyperion/connection.rb
CHANGED
|
@@ -17,6 +17,7 @@ module Hyperion
|
|
|
17
17
|
MAX_BODY_BYTES = 16 * 1024 * 1024 # 16 MB cap. Phase 5 introduces streaming bodies.
|
|
18
18
|
HEADER_TERM = "\r\n\r\n"
|
|
19
19
|
TIMEOUT_SENTINEL = :__hyperion_read_timeout__
|
|
20
|
+
DEADLINE_SENTINEL = :__hyperion_request_deadline__
|
|
20
21
|
IDLE_KEEPALIVE_TIMEOUT_SECONDS = 5
|
|
21
22
|
|
|
22
23
|
# Default parser is the C-extension `CParser` when the extension built;
|
|
@@ -44,14 +45,20 @@ module Hyperion
|
|
|
44
45
|
@log_requests = log_requests.nil? ? Hyperion.log_requests? : log_requests
|
|
45
46
|
end
|
|
46
47
|
|
|
47
|
-
def serve(socket, app)
|
|
48
|
+
def serve(socket, app, max_request_read_seconds: 60)
|
|
48
49
|
request_count = 0
|
|
49
50
|
carry = +'' # bytes already pulled off the socket but past the prev request boundary
|
|
50
51
|
peer_addr = peer_address(socket)
|
|
51
52
|
@metrics.increment(:connections_accepted)
|
|
52
53
|
@metrics.increment(:connections_active)
|
|
53
54
|
loop do
|
|
54
|
-
|
|
55
|
+
# Per-request wallclock deadline. Captured fresh for every request so
|
|
56
|
+
# long-lived keep-alive sessions with many small requests don't
|
|
57
|
+
# falsely trip after the cumulative budget elapses.
|
|
58
|
+
request_started_clock = Process.clock_gettime(Process::CLOCK_MONOTONIC) if max_request_read_seconds
|
|
59
|
+
buffer = read_request(socket, carry, deadline_started_at: request_started_clock,
|
|
60
|
+
max_request_read_seconds: max_request_read_seconds,
|
|
61
|
+
peer_addr: peer_addr)
|
|
55
62
|
return unless buffer
|
|
56
63
|
|
|
57
64
|
if buffer == TIMEOUT_SENTINEL
|
|
@@ -65,6 +72,10 @@ module Hyperion
|
|
|
65
72
|
return
|
|
66
73
|
end
|
|
67
74
|
|
|
75
|
+
# Slowloris-style abort: deadline tripped during read. We've already
|
|
76
|
+
# written the 408 (best-effort) inside read_request; close out here.
|
|
77
|
+
return if buffer == DEADLINE_SENTINEL
|
|
78
|
+
|
|
68
79
|
request, body_end = @parser.parse(buffer)
|
|
69
80
|
carry = +(buffer.byteslice(body_end, buffer.bytesize - body_end) || '')
|
|
70
81
|
request = enrich_with_peer(request, peer_addr) if peer_addr && request.peer_address.nil?
|
|
@@ -193,10 +204,16 @@ module Hyperion
|
|
|
193
204
|
# pipelining). Returns the full buffer (with any trailing pipelined
|
|
194
205
|
# bytes intact); the parser's returned end_offset tells the caller
|
|
195
206
|
# where this request ends. On EOF returns nil; on read timeout returns
|
|
196
|
-
# TIMEOUT_SENTINEL
|
|
197
|
-
|
|
207
|
+
# TIMEOUT_SENTINEL; on per-request wallclock deadline trip returns
|
|
208
|
+
# DEADLINE_SENTINEL (and emits a best-effort 408 + close).
|
|
209
|
+
def read_request(socket, carry = +'', deadline_started_at: nil, max_request_read_seconds: nil,
|
|
210
|
+
peer_addr: nil)
|
|
198
211
|
buffer = carry
|
|
199
212
|
until buffer.include?(HEADER_TERM)
|
|
213
|
+
if deadline_exceeded?(deadline_started_at, max_request_read_seconds)
|
|
214
|
+
return abort_for_deadline(socket, deadline_started_at, peer_addr)
|
|
215
|
+
end
|
|
216
|
+
|
|
200
217
|
chunk = read_chunk(socket)
|
|
201
218
|
return chunk if chunk.nil? || chunk == TIMEOUT_SENTINEL
|
|
202
219
|
return nil if chunk.empty?
|
|
@@ -211,6 +228,9 @@ module Hyperion
|
|
|
211
228
|
if chunked?(headers_part)
|
|
212
229
|
until chunked_body_complete?(buffer, header_end)
|
|
213
230
|
raise ParseError, 'chunked body exceeds limit' if buffer.bytesize - header_end > MAX_BODY_BYTES
|
|
231
|
+
if deadline_exceeded?(deadline_started_at, max_request_read_seconds)
|
|
232
|
+
return abort_for_deadline(socket, deadline_started_at, peer_addr)
|
|
233
|
+
end
|
|
214
234
|
|
|
215
235
|
chunk = read_chunk(socket)
|
|
216
236
|
break if chunk.nil? || chunk.empty? || chunk == TIMEOUT_SENTINEL
|
|
@@ -220,6 +240,10 @@ module Hyperion
|
|
|
220
240
|
else
|
|
221
241
|
content_length = headers_part[/^content-length:\s*(\d+)/i, 1].to_i
|
|
222
242
|
while buffer.bytesize < header_end + content_length
|
|
243
|
+
if deadline_exceeded?(deadline_started_at, max_request_read_seconds)
|
|
244
|
+
return abort_for_deadline(socket, deadline_started_at, peer_addr)
|
|
245
|
+
end
|
|
246
|
+
|
|
223
247
|
chunk = read_chunk(socket)
|
|
224
248
|
break if chunk.nil? || chunk.empty? || chunk == TIMEOUT_SENTINEL
|
|
225
249
|
|
|
@@ -230,6 +254,33 @@ module Hyperion
|
|
|
230
254
|
buffer
|
|
231
255
|
end
|
|
232
256
|
|
|
257
|
+
# nil-disabled or budget-untripped → false. Otherwise the wallclock cap
|
|
258
|
+
# has been exceeded and the caller should abort.
|
|
259
|
+
def deadline_exceeded?(started_at, max_seconds)
|
|
260
|
+
return false unless started_at && max_seconds
|
|
261
|
+
|
|
262
|
+
(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) > max_seconds
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Slowloris fallback: log a structured warn, bump :slow_request_aborts,
|
|
266
|
+
# write a best-effort 408, and let the caller close the socket. We don't
|
|
267
|
+
# wait on the 408 write — a dribbling client may never read it, and
|
|
268
|
+
# that's the failure mode we're protecting against anyway.
|
|
269
|
+
def abort_for_deadline(socket, started_at, peer_addr)
|
|
270
|
+
elapsed = started_at ? (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at).round(3) : nil
|
|
271
|
+
@metrics.increment(:slow_request_aborts)
|
|
272
|
+
@logger.warn do
|
|
273
|
+
{ message: 'request read deadline exceeded', remote_addr: peer_addr, elapsed_seconds: elapsed }
|
|
274
|
+
end
|
|
275
|
+
begin
|
|
276
|
+
socket.write("HTTP/1.1 408 Request Timeout\r\nconnection: close\r\ncontent-length: 0\r\n\r\n")
|
|
277
|
+
rescue StandardError
|
|
278
|
+
# Peer may have already gone — nothing to do.
|
|
279
|
+
end
|
|
280
|
+
@metrics.increment_status(408)
|
|
281
|
+
DEADLINE_SENTINEL
|
|
282
|
+
end
|
|
283
|
+
|
|
233
284
|
def chunked?(headers_part)
|
|
234
285
|
headers_part.match?(/^transfer-encoding:[ \t]*[^\r\n]*chunked\b/i)
|
|
235
286
|
end
|
|
@@ -212,9 +212,34 @@ module Hyperion
|
|
|
212
212
|
end
|
|
213
213
|
end
|
|
214
214
|
|
|
215
|
-
|
|
215
|
+
# Maps Hyperion-friendly setting names to the integer SETTINGS_* identifiers
|
|
216
|
+
# protocol-http2 uses on the wire. See RFC 7540 §6.5.2 — these are the
|
|
217
|
+
# only four parameters Hyperion exposes; the rest of the SETTINGS frame
|
|
218
|
+
# (HEADER_TABLE_SIZE, ENABLE_PUSH, etc.) keeps protocol-http2's default.
|
|
219
|
+
SETTINGS_KEY_MAP = {
|
|
220
|
+
max_concurrent_streams: ::Protocol::HTTP2::Settings::MAXIMUM_CONCURRENT_STREAMS,
|
|
221
|
+
initial_window_size: ::Protocol::HTTP2::Settings::INITIAL_WINDOW_SIZE,
|
|
222
|
+
max_frame_size: ::Protocol::HTTP2::Settings::MAXIMUM_FRAME_SIZE,
|
|
223
|
+
max_header_list_size: ::Protocol::HTTP2::Settings::MAXIMUM_HEADER_LIST_SIZE
|
|
224
|
+
}.freeze
|
|
225
|
+
|
|
226
|
+
# RFC 7540 §6.5.2 floor for SETTINGS_MAX_FRAME_SIZE. protocol-http2 raises
|
|
227
|
+
# ProtocolError on values below this; we clamp + warn instead so a
|
|
228
|
+
# misconfigured operator gets a working server, not a boot-time crash.
|
|
229
|
+
H2_MIN_FRAME_SIZE = 0x4000 # 16384
|
|
230
|
+
|
|
231
|
+
# RFC 7540 §6.5.2 ceiling for SETTINGS_MAX_FRAME_SIZE.
|
|
232
|
+
H2_MAX_FRAME_SIZE = 0xFFFFFF # 16777215
|
|
233
|
+
|
|
234
|
+
# RFC 7540 §6.9.2 — INITIAL_WINDOW_SIZE has the same 31-bit max as the
|
|
235
|
+
# WINDOW_UPDATE frame's Window Size Increment (see protocol-http2's
|
|
236
|
+
# MAXIMUM_ALLOWED_WINDOW_SIZE).
|
|
237
|
+
H2_MAX_WINDOW_SIZE = 0x7FFFFFFF
|
|
238
|
+
|
|
239
|
+
def initialize(app:, thread_pool: nil, h2_settings: nil)
|
|
216
240
|
@app = app
|
|
217
241
|
@thread_pool = thread_pool
|
|
242
|
+
@h2_settings = h2_settings
|
|
218
243
|
@metrics = Hyperion.metrics
|
|
219
244
|
@logger = Hyperion.logger
|
|
220
245
|
end
|
|
@@ -224,7 +249,7 @@ module Hyperion
|
|
|
224
249
|
@metrics.increment(:connections_active)
|
|
225
250
|
framer = ::Protocol::HTTP2::Framer.new(socket)
|
|
226
251
|
server = build_server(framer)
|
|
227
|
-
server.read_connection_preface
|
|
252
|
+
server.read_connection_preface(initial_settings_payload)
|
|
228
253
|
|
|
229
254
|
# Extract once — the same TCP peer drives every stream on this conn.
|
|
230
255
|
peer_addr = peer_address(socket)
|
|
@@ -290,6 +315,69 @@ module Hyperion
|
|
|
290
315
|
|
|
291
316
|
private
|
|
292
317
|
|
|
318
|
+
# Build the [setting_id, value] pairs that go in the connection-preface
|
|
319
|
+
# SETTINGS frame. protocol-http2's Server#read_connection_preface accepts
|
|
320
|
+
# this array and does the wire encoding for us. Empty array (no overrides
|
|
321
|
+
# configured) → SETTINGS frame still goes out, just with no entries
|
|
322
|
+
# (effectively an ack), which is what the spec allows.
|
|
323
|
+
#
|
|
324
|
+
# We clamp out-of-range values (max_frame_size below the spec floor or
|
|
325
|
+
# above its ceiling, initial_window_size above 31-bit max) instead of
|
|
326
|
+
# letting protocol-http2 raise ProtocolError at handshake time — a
|
|
327
|
+
# crashing handshake leaks the connection. Operator gets a warn so the
|
|
328
|
+
# misconfiguration surfaces in logs.
|
|
329
|
+
def initial_settings_payload
|
|
330
|
+
return [] unless @h2_settings
|
|
331
|
+
|
|
332
|
+
payload = []
|
|
333
|
+
@h2_settings.each do |key, value|
|
|
334
|
+
next if value.nil?
|
|
335
|
+
|
|
336
|
+
setting_id = SETTINGS_KEY_MAP[key]
|
|
337
|
+
unless setting_id
|
|
338
|
+
@logger.warn { { message: 'unknown h2 setting; skipping', setting: key } }
|
|
339
|
+
next
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
clamped = clamp_h2_setting(key, value)
|
|
343
|
+
payload << [setting_id, clamped]
|
|
344
|
+
end
|
|
345
|
+
payload
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def clamp_h2_setting(key, value)
|
|
349
|
+
case key
|
|
350
|
+
when :max_frame_size
|
|
351
|
+
if value < H2_MIN_FRAME_SIZE
|
|
352
|
+
@logger.warn do
|
|
353
|
+
{ message: 'h2 max_frame_size below spec minimum; clamping',
|
|
354
|
+
configured: value, clamped_to: H2_MIN_FRAME_SIZE }
|
|
355
|
+
end
|
|
356
|
+
H2_MIN_FRAME_SIZE
|
|
357
|
+
elsif value > H2_MAX_FRAME_SIZE
|
|
358
|
+
@logger.warn do
|
|
359
|
+
{ message: 'h2 max_frame_size above spec maximum; clamping',
|
|
360
|
+
configured: value, clamped_to: H2_MAX_FRAME_SIZE }
|
|
361
|
+
end
|
|
362
|
+
H2_MAX_FRAME_SIZE
|
|
363
|
+
else
|
|
364
|
+
value
|
|
365
|
+
end
|
|
366
|
+
when :initial_window_size
|
|
367
|
+
if value > H2_MAX_WINDOW_SIZE
|
|
368
|
+
@logger.warn do
|
|
369
|
+
{ message: 'h2 initial_window_size above spec maximum; clamping',
|
|
370
|
+
configured: value, clamped_to: H2_MAX_WINDOW_SIZE }
|
|
371
|
+
end
|
|
372
|
+
H2_MAX_WINDOW_SIZE
|
|
373
|
+
else
|
|
374
|
+
value
|
|
375
|
+
end
|
|
376
|
+
else
|
|
377
|
+
value
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
293
381
|
def build_server(framer)
|
|
294
382
|
server = ::Protocol::HTTP2::Server.new(framer)
|
|
295
383
|
server.define_singleton_method(:accept_stream) do |stream_id, &block|
|
data/lib/hyperion/master.rb
CHANGED
|
@@ -47,6 +47,20 @@ module Hyperion
|
|
|
47
47
|
end
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
+
# Pulls the four configurable HTTP/2 SETTINGS values out of the Config
|
|
51
|
+
# and returns them as a Hash. Nils are stripped so an operator who
|
|
52
|
+
# explicitly sets one to `nil` (meaning "leave protocol-http2 default in
|
|
53
|
+
# place") doesn't accidentally send a SETTINGS entry with a nil value.
|
|
54
|
+
# Empty hash → no override → Http2Handler skips the SETTINGS push.
|
|
55
|
+
def self.build_h2_settings(config)
|
|
56
|
+
{
|
|
57
|
+
max_concurrent_streams: config.h2_max_concurrent_streams,
|
|
58
|
+
initial_window_size: config.h2_initial_window_size,
|
|
59
|
+
max_frame_size: config.h2_max_frame_size,
|
|
60
|
+
max_header_list_size: config.h2_max_header_list_size
|
|
61
|
+
}.compact
|
|
62
|
+
end
|
|
63
|
+
|
|
50
64
|
def initialize(host:, port:, app:, workers: DEFAULT_WORKER_COUNT,
|
|
51
65
|
read_timeout: Server::DEFAULT_READ_TIMEOUT_SECONDS, tls: nil,
|
|
52
66
|
thread_count: Server::DEFAULT_THREAD_COUNT, config: nil)
|
|
@@ -84,6 +98,12 @@ module Hyperion
|
|
|
84
98
|
}
|
|
85
99
|
end
|
|
86
100
|
|
|
101
|
+
# Pre-allocate Rack env-pool entries and eager-touch lazy constants
|
|
102
|
+
# BEFORE we fork. Children inherit the warm memory via copy-on-write
|
|
103
|
+
# so the first batch of requests on each fresh worker doesn't pay
|
|
104
|
+
# the allocation/autoload tax.
|
|
105
|
+
Hyperion.warmup!
|
|
106
|
+
|
|
87
107
|
# `before_fork` runs ONCE in the master before any worker is forked.
|
|
88
108
|
# Operators use it to close shared resources (DB pools, Redis sockets)
|
|
89
109
|
# so each child gets fresh connections rather than inheriting the
|
|
@@ -143,7 +163,10 @@ module Hyperion
|
|
|
143
163
|
host: @host, port: @port, app: @app,
|
|
144
164
|
read_timeout: @read_timeout, tls: @tls,
|
|
145
165
|
thread_count: @thread_count, config: @config,
|
|
146
|
-
worker_index: worker_index
|
|
166
|
+
worker_index: worker_index,
|
|
167
|
+
max_pending: @config.max_pending,
|
|
168
|
+
max_request_read_seconds: @config.max_request_read_seconds,
|
|
169
|
+
h2_settings: Master.build_h2_settings(@config)
|
|
147
170
|
}
|
|
148
171
|
# Hand the inherited socket to the worker in :share mode. In
|
|
149
172
|
# :reuseport mode the worker binds its own with SO_REUSEPORT.
|
|
@@ -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 =
|
|
61
|
+
date_str = cached_date
|
|
47
62
|
|
|
48
63
|
head = build_head(status, reason, headers, buffered.bytesize, keep_alive, date_str)
|
|
49
64
|
|
|
@@ -67,7 +82,52 @@ module Hyperion
|
|
|
67
82
|
body.close if body.respond_to?(:close)
|
|
68
83
|
end
|
|
69
84
|
|
|
70
|
-
|
|
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
|
|
71
131
|
|
|
72
132
|
# rc17: prefer the C extension when available — eliminates the per-response
|
|
73
133
|
# status-line interpolation, normalized hash, and per-header String#<<
|
data/lib/hyperion/server.rb
CHANGED
|
@@ -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
|
-
|
|
28
|
-
@
|
|
29
|
-
@
|
|
30
|
-
@
|
|
31
|
-
@
|
|
32
|
-
@
|
|
33
|
-
@
|
|
34
|
-
@
|
|
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,7 +105,7 @@ 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
110
|
if @tls
|
|
89
111
|
# TLS path: ALPN may pick `h2`, and h2 spawns one fiber per stream
|
|
@@ -121,9 +143,12 @@ module Hyperion
|
|
|
121
143
|
|
|
122
144
|
apply_timeout(socket)
|
|
123
145
|
if @thread_pool
|
|
124
|
-
@thread_pool.submit_connection(socket, @app
|
|
146
|
+
unless @thread_pool.submit_connection(socket, @app,
|
|
147
|
+
max_request_read_seconds: @max_request_read_seconds)
|
|
148
|
+
reject_connection(socket)
|
|
149
|
+
end
|
|
125
150
|
else
|
|
126
|
-
Connection.new.serve(socket, @app)
|
|
151
|
+
Connection.new.serve(socket, @app, max_request_read_seconds: @max_request_read_seconds)
|
|
127
152
|
end
|
|
128
153
|
end
|
|
129
154
|
end
|
|
@@ -148,15 +173,38 @@ module Hyperion
|
|
|
148
173
|
# HTTP/2: each stream runs on a fiber inside Http2Handler. The
|
|
149
174
|
# handler still uses the pool's `#call` for app.call hops on each
|
|
150
175
|
# stream (one per stream, not one per connection).
|
|
151
|
-
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)
|
|
152
177
|
elsif @thread_pool
|
|
153
178
|
# HTTP/1.1 (e.g. TLS-wrapped after ALPN picked http/1.1): hand the
|
|
154
179
|
# connection to a worker thread. The fiber that called dispatch
|
|
155
|
-
# returns immediately.
|
|
156
|
-
@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
|
|
157
185
|
else
|
|
158
186
|
# No pool (thread_count: 0): inline on the calling fiber.
|
|
159
|
-
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
|
|
160
208
|
end
|
|
161
209
|
end
|
|
162
210
|
|
data/lib/hyperion/thread_pool.rb
CHANGED
|
@@ -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
|
|
33
|
-
@
|
|
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
|
-
|
|
47
|
-
|
|
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
|
{
|
data/lib/hyperion/version.rb
CHANGED
data/lib/hyperion/worker.rb
CHANGED
|
@@ -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
|
-
|
|
23
|
-
|
|
24
|
-
@
|
|
25
|
-
@
|
|
26
|
-
@
|
|
27
|
-
@
|
|
28
|
-
@
|
|
29
|
-
@
|
|
30
|
-
@
|
|
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
|
|
data/lib/hyperion.rb
CHANGED
|
@@ -63,6 +63,44 @@ module Hyperion
|
|
|
63
63
|
else true # default ON
|
|
64
64
|
end
|
|
65
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
|
|
66
104
|
end
|
|
67
105
|
end
|
|
68
106
|
|
|
@@ -89,6 +127,7 @@ require_relative 'hyperion/request'
|
|
|
89
127
|
require_relative 'hyperion/parser'
|
|
90
128
|
require_relative 'hyperion/c_parser'
|
|
91
129
|
require_relative 'hyperion/adapter/rack'
|
|
130
|
+
require_relative 'hyperion/prometheus_exporter'
|
|
92
131
|
require_relative 'hyperion/admin_middleware'
|
|
93
132
|
require_relative 'hyperion/response_writer'
|
|
94
133
|
require_relative 'hyperion/thread_pool'
|
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.
|
|
4
|
+
version: 1.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrey Lobanov
|
|
@@ -160,6 +160,7 @@ files:
|
|
|
160
160
|
- lib/hyperion/metrics.rb
|
|
161
161
|
- lib/hyperion/parser.rb
|
|
162
162
|
- lib/hyperion/pool.rb
|
|
163
|
+
- lib/hyperion/prometheus_exporter.rb
|
|
163
164
|
- lib/hyperion/request.rb
|
|
164
165
|
- lib/hyperion/response_writer.rb
|
|
165
166
|
- lib/hyperion/server.rb
|