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