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.
@@ -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
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hyperion
4
+ VERSION = '1.0.0.rc17'
5
+ 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'