hyperion-rb 1.0.0.rc17
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 +7 -0
- data/CHANGELOG.md +133 -0
- data/LICENSE +21 -0
- data/README.md +260 -0
- data/bin/hyperion +6 -0
- data/ext/hyperion_http/extconf.rb +19 -0
- data/ext/hyperion_http/llhttp/api.c +509 -0
- data/ext/hyperion_http/llhttp/http.c +170 -0
- data/ext/hyperion_http/llhttp/llhttp.c +10103 -0
- data/ext/hyperion_http/llhttp/llhttp.h +907 -0
- data/ext/hyperion_http/parser.c +428 -0
- data/lib/hyperion/adapter/rack.rb +143 -0
- data/lib/hyperion/c_parser.rb +19 -0
- data/lib/hyperion/cli.rb +151 -0
- data/lib/hyperion/config.rb +107 -0
- data/lib/hyperion/connection.rb +338 -0
- data/lib/hyperion/fiber_local.rb +104 -0
- data/lib/hyperion/http2_handler.rb +312 -0
- data/lib/hyperion/logger.rb +269 -0
- data/lib/hyperion/master.rb +221 -0
- data/lib/hyperion/metrics.rb +68 -0
- data/lib/hyperion/parser.rb +128 -0
- data/lib/hyperion/pool.rb +34 -0
- data/lib/hyperion/request.rb +25 -0
- data/lib/hyperion/response_writer.rb +98 -0
- data/lib/hyperion/server.rb +198 -0
- data/lib/hyperion/thread_pool.rb +116 -0
- data/lib/hyperion/tls.rb +29 -0
- data/lib/hyperion/version.rb +5 -0
- data/lib/hyperion/worker.rb +91 -0
- data/lib/hyperion.rb +82 -0
- metadata +193 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'socket'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
require 'async'
|
|
6
|
+
require 'async/scheduler'
|
|
7
|
+
|
|
8
|
+
module Hyperion
|
|
9
|
+
# Phase 2a server: bind a TCPServer, accept connections, schedule each on its
|
|
10
|
+
# own fiber via Async. Multiple in-flight requests run concurrently on a
|
|
11
|
+
# single OS thread. Keep-alive is still off — connection closes after one
|
|
12
|
+
# request (Phase 2b will add keep-alive).
|
|
13
|
+
#
|
|
14
|
+
# Phase 7 (scoped): when `tls:` is supplied, wrap the listener in an
|
|
15
|
+
# OpenSSL::SSL::SSLServer with ALPN advertising `h2` + `http/1.1`. After
|
|
16
|
+
# the handshake, dispatch on the negotiated protocol — http/1.1 goes
|
|
17
|
+
# through Connection (real path); h2 goes to Http2Handler (505 stub
|
|
18
|
+
# until Phase 8).
|
|
19
|
+
class Server
|
|
20
|
+
DEFAULT_READ_TIMEOUT_SECONDS = 30
|
|
21
|
+
DEFAULT_THREAD_COUNT = 5
|
|
22
|
+
|
|
23
|
+
attr_reader :host, :port
|
|
24
|
+
|
|
25
|
+
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
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def listen
|
|
38
|
+
tcp = ::TCPServer.new(@host, @port)
|
|
39
|
+
@port = tcp.addr[1]
|
|
40
|
+
|
|
41
|
+
if @tls
|
|
42
|
+
@ssl_ctx = TLS.context(cert: @tls[:cert], key: @tls[:key])
|
|
43
|
+
ssl_server = ::OpenSSL::SSL::SSLServer.new(tcp, @ssl_ctx)
|
|
44
|
+
ssl_server.start_immediately = false
|
|
45
|
+
@server = ssl_server
|
|
46
|
+
@tcp_server = tcp
|
|
47
|
+
else
|
|
48
|
+
@server = tcp
|
|
49
|
+
@tcp_server = tcp
|
|
50
|
+
end
|
|
51
|
+
self
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Phase 3: workers pass in a pre-bound, SO_REUSEPORT-set socket built
|
|
55
|
+
# by Hyperion::Worker. Bypasses #listen but keeps the rest of the
|
|
56
|
+
# accept loop intact since Socket and TCPServer both quack #accept_nonblock.
|
|
57
|
+
#
|
|
58
|
+
# Phase 8: when `tls:` was supplied to the constructor, also build the
|
|
59
|
+
# SSL context here so the accept loop can wrap incoming connections.
|
|
60
|
+
# Each worker builds its own context — they don't share state.
|
|
61
|
+
def adopt_listener(sock)
|
|
62
|
+
@server = sock
|
|
63
|
+
@tcp_server = sock
|
|
64
|
+
@port = case sock
|
|
65
|
+
when ::TCPServer
|
|
66
|
+
sock.addr[1]
|
|
67
|
+
else
|
|
68
|
+
sock.local_address.ip_port
|
|
69
|
+
end
|
|
70
|
+
@ssl_ctx = TLS.context(cert: @tls[:cert], key: @tls[:key]) if @tls
|
|
71
|
+
self
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def run_one
|
|
75
|
+
Async do
|
|
76
|
+
socket = blocking_accept
|
|
77
|
+
next unless socket
|
|
78
|
+
|
|
79
|
+
apply_timeout(socket)
|
|
80
|
+
dispatch(socket)
|
|
81
|
+
end.wait
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def start
|
|
85
|
+
listen unless @server
|
|
86
|
+
@thread_pool = ThreadPool.new(size: @thread_count) if @thread_count.positive?
|
|
87
|
+
|
|
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
|
|
106
|
+
end
|
|
107
|
+
ensure
|
|
108
|
+
@thread_pool&.shutdown
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def stop
|
|
112
|
+
@stopped = true
|
|
113
|
+
@server&.close
|
|
114
|
+
@server = nil
|
|
115
|
+
@tcp_server = nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def dispatch(socket)
|
|
121
|
+
if socket.is_a?(::OpenSSL::SSL::SSLSocket) && socket.alpn_protocol == 'h2'
|
|
122
|
+
# HTTP/2: each stream runs on a fiber inside Http2Handler. The
|
|
123
|
+
# handler still uses the pool's `#call` for app.call hops on each
|
|
124
|
+
# stream (one per stream, not one per connection).
|
|
125
|
+
Http2Handler.new(app: @app, thread_pool: @thread_pool).serve(socket)
|
|
126
|
+
elsif @thread_pool
|
|
127
|
+
# HTTP/1.1 (e.g. TLS-wrapped after ALPN picked http/1.1): hand the
|
|
128
|
+
# connection to a worker thread. The fiber that called dispatch
|
|
129
|
+
# returns immediately.
|
|
130
|
+
@thread_pool.submit_connection(socket, @app)
|
|
131
|
+
else
|
|
132
|
+
# No pool (thread_count: 0): inline on the calling fiber.
|
|
133
|
+
Connection.new.serve(socket, @app)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def listening_io
|
|
138
|
+
@tcp_server
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def accept_or_nil
|
|
142
|
+
ready, = IO.select([listening_io], nil, nil, 0.1)
|
|
143
|
+
return nil unless ready
|
|
144
|
+
|
|
145
|
+
if @tls
|
|
146
|
+
raw, = listening_io.accept_nonblock
|
|
147
|
+
ssl = ::OpenSSL::SSL::SSLSocket.new(raw, @ssl_ctx)
|
|
148
|
+
ssl.sync_close = true
|
|
149
|
+
ssl.accept # blocks; under Async this yields cooperatively via the scheduler
|
|
150
|
+
ssl
|
|
151
|
+
else
|
|
152
|
+
socket, = listening_io.accept_nonblock
|
|
153
|
+
socket
|
|
154
|
+
end
|
|
155
|
+
rescue IO::WaitReadable, Errno::EINTR, Errno::ECONNABORTED
|
|
156
|
+
nil
|
|
157
|
+
rescue IOError, Errno::EBADF
|
|
158
|
+
@stopped = true
|
|
159
|
+
nil
|
|
160
|
+
rescue OpenSSL::SSL::SSLError => e
|
|
161
|
+
Hyperion.logger.warn { { message: 'tls handshake failed', error: e.message } }
|
|
162
|
+
nil
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def blocking_accept
|
|
166
|
+
if @tls
|
|
167
|
+
raw, = listening_io.accept
|
|
168
|
+
ssl = ::OpenSSL::SSL::SSLSocket.new(raw, @ssl_ctx)
|
|
169
|
+
ssl.sync_close = true
|
|
170
|
+
ssl.accept
|
|
171
|
+
ssl
|
|
172
|
+
else
|
|
173
|
+
socket, = @server.accept
|
|
174
|
+
socket
|
|
175
|
+
end
|
|
176
|
+
rescue OpenSSL::SSL::SSLError => e
|
|
177
|
+
Hyperion.logger.warn { { message: 'tls handshake failed', error: e.message } }
|
|
178
|
+
nil
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Defensively set a per-connection read deadline so a stalled client
|
|
182
|
+
# cannot wedge the worker. Phase 2 (fiber scheduler) will replace this
|
|
183
|
+
# with cooperative timeouts driven by the scheduler.
|
|
184
|
+
def apply_timeout(socket)
|
|
185
|
+
target = socket.respond_to?(:io) ? socket.io : socket
|
|
186
|
+
if target.respond_to?(:timeout=)
|
|
187
|
+
target.timeout = @read_timeout
|
|
188
|
+
else
|
|
189
|
+
timeval = [@read_timeout, 0].pack('l_l_')
|
|
190
|
+
target.setsockopt(::Socket::SOL_SOCKET, ::Socket::SO_RCVTIMEO, timeval)
|
|
191
|
+
end
|
|
192
|
+
rescue StandardError => e
|
|
193
|
+
Hyperion.logger.warn do
|
|
194
|
+
{ message: 'failed to set read timeout', error: e.message, error_class: e.class.name }
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hyperion
|
|
4
|
+
# Thread pool for Rack dispatch. Has two modes:
|
|
5
|
+
#
|
|
6
|
+
# 1. `#submit_connection(socket, app)` — HTTP/1.1 path. The whole socket is
|
|
7
|
+
# handed to a worker thread, which runs `Connection#serve(socket, app)`
|
|
8
|
+
# directly with `thread_pool: nil` (the worker IS the pool). Zero
|
|
9
|
+
# per-request hop, one OS thread per in-flight connection — Puma's model.
|
|
10
|
+
#
|
|
11
|
+
# 2. `#call(app, request)` — old hop-based API. Used by Http2Handler, where
|
|
12
|
+
# each h2 stream runs on a fiber inside the connection fiber and DOES
|
|
13
|
+
# need the cross-thread hop for `app.call(env)`.
|
|
14
|
+
#
|
|
15
|
+
# Why we need this: synchronous Rack handlers (Rails dev-mode reloader,
|
|
16
|
+
# ActiveRecord, many gems) hold global mutexes that serialize work across
|
|
17
|
+
# fibers on a single thread. Fibers give us cheap connection counts but
|
|
18
|
+
# cannot deliver true parallelism for blocking handlers. The thread pool
|
|
19
|
+
# gives us Puma-style OS-thread concurrency for `app.call(env)` while the
|
|
20
|
+
# accept loop stays on fibers.
|
|
21
|
+
#
|
|
22
|
+
# Cross-thread fiber wakeup (for the legacy `#call` path): on Ruby 3.2+ with
|
|
23
|
+
# the Async fiber scheduler, `Queue#pop` is fiber-aware — the fiber yields
|
|
24
|
+
# cooperatively while waiting on the queue. Verified experimentally on Ruby
|
|
25
|
+
# 3.3.3.
|
|
26
|
+
class ThreadPool
|
|
27
|
+
SHUTDOWN = :__hyperion_thread_pool_shutdown__
|
|
28
|
+
|
|
29
|
+
attr_reader :size
|
|
30
|
+
|
|
31
|
+
def initialize(size:)
|
|
32
|
+
@size = size
|
|
33
|
+
@inbox = Queue.new # multiplexes both kinds of jobs
|
|
34
|
+
# Pre-allocate one reply queue per in-flight slot for the legacy `#call`
|
|
35
|
+
# path. Bounded by `size`: if all workers are busy, all reply queues are
|
|
36
|
+
# checked out, and the next caller blocks on `@reply_pool.pop` until a
|
|
37
|
+
# worker frees one. That's the correct backpressure shape.
|
|
38
|
+
@reply_pool = Queue.new
|
|
39
|
+
size.times { @reply_pool << Queue.new }
|
|
40
|
+
@workers = Array.new(size) { spawn_worker }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# HTTP/1.1 path: hand the whole socket to a worker thread. The worker
|
|
44
|
+
# runs `Connection#serve(socket, app)` directly. No per-request hop.
|
|
45
|
+
# Returns immediately — caller does not wait.
|
|
46
|
+
def submit_connection(socket, app)
|
|
47
|
+
@inbox << [:connection, socket, app]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# HTTP/2 + sub-call path: hop one `app.call` from the calling fiber to a
|
|
51
|
+
# worker thread. The fiber yields until the worker pushes the result back.
|
|
52
|
+
#
|
|
53
|
+
# Reply-queue lifecycle invariant: `@reply_pool` always contains queues
|
|
54
|
+
# that are empty. We check one out, hand it to the worker, the worker
|
|
55
|
+
# pushes exactly one result, we pop it, then return the queue to the
|
|
56
|
+
# pool. If `app.call` raises, the worker still pushes a 500 result — see
|
|
57
|
+
# `spawn_worker`.
|
|
58
|
+
def call(app, request)
|
|
59
|
+
reply = @reply_pool.pop
|
|
60
|
+
@inbox << [:call, app, request, reply]
|
|
61
|
+
result = reply.pop
|
|
62
|
+
@reply_pool << reply
|
|
63
|
+
result
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def shutdown
|
|
67
|
+
@size.times { @inbox << SHUTDOWN }
|
|
68
|
+
@workers.each { |t| t.join(5) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def spawn_worker
|
|
74
|
+
Thread.new do
|
|
75
|
+
loop do
|
|
76
|
+
job = @inbox.pop
|
|
77
|
+
break if job.equal?(SHUTDOWN)
|
|
78
|
+
|
|
79
|
+
case job[0]
|
|
80
|
+
when :connection
|
|
81
|
+
_, socket, app = job
|
|
82
|
+
# Worker thread owns the connection for its full lifetime. Pass
|
|
83
|
+
# thread_pool: nil so Connection#call_app inlines Adapter::Rack.call
|
|
84
|
+
# — the worker IS the pool, no further hop required.
|
|
85
|
+
begin
|
|
86
|
+
Hyperion::Connection.new.serve(socket, app)
|
|
87
|
+
rescue StandardError => e
|
|
88
|
+
Hyperion.logger.error do
|
|
89
|
+
{
|
|
90
|
+
message: 'thread pool worker connection raised',
|
|
91
|
+
error: e.message,
|
|
92
|
+
error_class: e.class.name
|
|
93
|
+
}
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
when :call
|
|
97
|
+
_, app, request, reply = job
|
|
98
|
+
reply <<
|
|
99
|
+
begin
|
|
100
|
+
Hyperion::Adapter::Rack.call(app, request)
|
|
101
|
+
rescue StandardError => e
|
|
102
|
+
Hyperion.logger.error do
|
|
103
|
+
{ message: 'thread pool worker raised', error: e.message, error_class: e.class.name }
|
|
104
|
+
end
|
|
105
|
+
[500, { 'content-type' => 'text/plain' }, ['Internal Server Error']]
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
rescue StandardError => e
|
|
110
|
+
Hyperion.logger.error do
|
|
111
|
+
{ message: 'thread pool worker died', error: e.message, error_class: e.class.name }
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
data/lib/hyperion/tls.rb
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openssl'
|
|
4
|
+
|
|
5
|
+
module Hyperion
|
|
6
|
+
# TLS context builder with ALPN configured for HTTP/2 + HTTP/1.1.
|
|
7
|
+
#
|
|
8
|
+
# Phase 7: TLS is opt-in via Server's `tls:` kwarg. ALPN lets the client
|
|
9
|
+
# negotiate `h2` (HTTP/2) or `http/1.1` during the handshake; the server
|
|
10
|
+
# then dispatches to either Http2Handler or Connection accordingly.
|
|
11
|
+
module TLS
|
|
12
|
+
SUPPORTED_PROTOCOLS = %w[h2 http/1.1].freeze
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def context(cert:, key:)
|
|
17
|
+
ctx = OpenSSL::SSL::SSLContext.new
|
|
18
|
+
ctx.cert = cert
|
|
19
|
+
ctx.key = key
|
|
20
|
+
ctx.min_version = OpenSSL::SSL::TLS1_2_VERSION
|
|
21
|
+
ctx.alpn_protocols = SUPPORTED_PROTOCOLS
|
|
22
|
+
ctx.alpn_select_cb = lambda do |client_protocols|
|
|
23
|
+
# Prefer h2 if the client offered it; else fall back to http/1.1.
|
|
24
|
+
SUPPORTED_PROTOCOLS.find { |p| client_protocols.include?(p) }
|
|
25
|
+
end
|
|
26
|
+
ctx
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'socket'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
|
|
6
|
+
module Hyperion
|
|
7
|
+
# Worker process. Receives a listening socket and runs a
|
|
8
|
+
# `Hyperion::Server` (fiber accept loop) until SIGTERM.
|
|
9
|
+
#
|
|
10
|
+
# Two listener sources, picked by the master per-OS:
|
|
11
|
+
#
|
|
12
|
+
# - `:share` mode (macOS / BSD): the master forwards a pre-bound
|
|
13
|
+
# `TCPServer` / `OpenSSL::SSL::SSLServer` via the `listener:` kwarg.
|
|
14
|
+
# The worker uses it as-is — the fd was inherited across fork.
|
|
15
|
+
# - `:reuseport` mode (Linux): no listener is passed. The worker binds
|
|
16
|
+
# its own `Socket` with `SO_REUSEPORT` set so the kernel can hash
|
|
17
|
+
# incoming connections across the sibling sockets.
|
|
18
|
+
class Worker
|
|
19
|
+
def initialize(host:, port:, app:, read_timeout:, tls: nil,
|
|
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
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def run
|
|
34
|
+
scheme = @tls ? 'https' : 'http'
|
|
35
|
+
Hyperion.logger.info do
|
|
36
|
+
{
|
|
37
|
+
message: 'worker listening',
|
|
38
|
+
pid: Process.pid,
|
|
39
|
+
worker_index: @worker_index,
|
|
40
|
+
url: "#{scheme}://#{@host}:#{@port}"
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
server = Server.new(host: @host, port: @port, app: @app,
|
|
45
|
+
read_timeout: @read_timeout, tls: @tls,
|
|
46
|
+
thread_count: @thread_count)
|
|
47
|
+
tcp_server = @listener || build_reuseport_listener
|
|
48
|
+
server.adopt_listener(tcp_server)
|
|
49
|
+
|
|
50
|
+
Signal.trap('TERM') { server.stop }
|
|
51
|
+
Signal.trap('INT') { server.stop }
|
|
52
|
+
|
|
53
|
+
# `on_worker_boot` runs in the child after fork, after the listener is
|
|
54
|
+
# ready, and before we start accepting. App code reconnects DB/Redis
|
|
55
|
+
# pools here so each worker has its own. Index identifies the slot
|
|
56
|
+
# (0..workers-1) so apps can shard background work if they want.
|
|
57
|
+
@config.on_worker_boot.each { |h| h.call(@worker_index) }
|
|
58
|
+
|
|
59
|
+
begin
|
|
60
|
+
server.start
|
|
61
|
+
ensure
|
|
62
|
+
# `on_worker_shutdown` fires when the accept loop exits — either
|
|
63
|
+
# due to graceful SIGTERM or a hard error. Use it to flush metrics,
|
|
64
|
+
# close DB connections cleanly, etc.
|
|
65
|
+
@config.on_worker_shutdown.each { |h| h.call(@worker_index) }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def build_reuseport_listener
|
|
72
|
+
addr = ::Socket.getaddrinfo(@host, @port, nil, :STREAM).first
|
|
73
|
+
sock = ::Socket.new(addr[4], ::Socket::SOCK_STREAM, 0)
|
|
74
|
+
sock.setsockopt(::Socket::SOL_SOCKET, ::Socket::SO_REUSEADDR, 1)
|
|
75
|
+
sock.setsockopt(::Socket::SOL_SOCKET, ::Socket::SO_REUSEPORT, 1)
|
|
76
|
+
sock.bind(::Socket.pack_sockaddr_in(@port, addr[3]))
|
|
77
|
+
sock.listen(::Socket::SOMAXCONN)
|
|
78
|
+
|
|
79
|
+
if @tls
|
|
80
|
+
ctx = Hyperion::TLS.context(cert: @tls[:cert], key: @tls[:key])
|
|
81
|
+
ssl = ::OpenSSL::SSL::SSLServer.new(sock, ctx)
|
|
82
|
+
ssl.start_immediately = false
|
|
83
|
+
ssl
|
|
84
|
+
else
|
|
85
|
+
# Hyperion::Server#adopt_listener accepts any object responding to
|
|
86
|
+
# #accept_nonblock, #accept, #close — which Socket does.
|
|
87
|
+
sock
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
data/lib/hyperion.rb
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'hyperion/version'
|
|
4
|
+
require_relative 'hyperion/logger'
|
|
5
|
+
require_relative 'hyperion/metrics'
|
|
6
|
+
require_relative 'hyperion/config'
|
|
7
|
+
|
|
8
|
+
module Hyperion
|
|
9
|
+
class Error < StandardError; end
|
|
10
|
+
class ParseError < Error; end
|
|
11
|
+
class UnsupportedError < Error; end
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def logger
|
|
15
|
+
@logger ||= Logger.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
attr_writer :logger, :log_requests
|
|
19
|
+
|
|
20
|
+
def metrics
|
|
21
|
+
@metrics ||= Metrics.new
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def stats
|
|
25
|
+
metrics.snapshot
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Per-request access logging is ON by default — matches Puma/Rails operator
|
|
29
|
+
# expectations (Rails::Rack::Logger emits one line per request out of the
|
|
30
|
+
# box). Operators can disable it via `--no-log-requests`,
|
|
31
|
+
# `HYPERION_LOG_REQUESTS=0|false|no|off`, or programmatically via
|
|
32
|
+
# `Hyperion.log_requests = false`. When false, Connection skips ALL
|
|
33
|
+
# access-log work — no Process.clock_gettime, no hash build, nothing.
|
|
34
|
+
#
|
|
35
|
+
# The hot path uses Logger#access (single-interpolation line build,
|
|
36
|
+
# per-thread cached timestamp, lock-free emit) so default-ON throughput
|
|
37
|
+
# stays well above Puma's default-OFF baseline.
|
|
38
|
+
def log_requests?
|
|
39
|
+
return @log_requests unless @log_requests.nil?
|
|
40
|
+
|
|
41
|
+
env = ENV['HYPERION_LOG_REQUESTS']&.downcase
|
|
42
|
+
@log_requests =
|
|
43
|
+
case env
|
|
44
|
+
when '0', 'false', 'no', 'off' then false
|
|
45
|
+
when '1', 'true', 'yes', 'on' then true
|
|
46
|
+
else true # default ON
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Runtime guard: warn early if the host app pulled openssl 4.x in despite the
|
|
53
|
+
# gemspec pin. Some Rails apps mutate `OpenSSL::SSL::SSLContext::DEFAULT_PARAMS`
|
|
54
|
+
# (e.g. the AWS SDK pattern that injects ciphers); 4.0 froze that hash, so the
|
|
55
|
+
# mutation now raises FrozenError on boot. We don't fix the host app — we just
|
|
56
|
+
# point at the source so the operator doesn't think it's a Hyperion bug.
|
|
57
|
+
if defined?(::OpenSSL::VERSION) &&
|
|
58
|
+
::Gem::Version.new(::OpenSSL::VERSION) >= ::Gem::Version.new('4.0.0') &&
|
|
59
|
+
::OpenSSL::SSL::SSLContext::DEFAULT_PARAMS.frozen?
|
|
60
|
+
Hyperion.logger.warn do
|
|
61
|
+
{
|
|
62
|
+
message: 'openssl froze SSLContext::DEFAULT_PARAMS — apps mutating that hash crash on boot',
|
|
63
|
+
openssl_version: ::OpenSSL::VERSION,
|
|
64
|
+
remediation: 'pin openssl < 4.0 in your Gemfile until the upstream initializer is updated'
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
require_relative 'hyperion/pool'
|
|
70
|
+
require_relative 'hyperion/fiber_local'
|
|
71
|
+
require_relative 'hyperion/request'
|
|
72
|
+
require_relative 'hyperion/parser'
|
|
73
|
+
require_relative 'hyperion/c_parser'
|
|
74
|
+
require_relative 'hyperion/adapter/rack'
|
|
75
|
+
require_relative 'hyperion/response_writer'
|
|
76
|
+
require_relative 'hyperion/thread_pool'
|
|
77
|
+
require_relative 'hyperion/connection'
|
|
78
|
+
require_relative 'hyperion/tls'
|
|
79
|
+
require_relative 'hyperion/http2_handler'
|
|
80
|
+
require_relative 'hyperion/server'
|
|
81
|
+
require_relative 'hyperion/worker'
|
|
82
|
+
require_relative 'hyperion/master'
|