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,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'etc'
4
+ require 'openssl'
5
+ require 'optparse'
6
+ require 'rack'
7
+ require_relative '../hyperion'
8
+
9
+ module Hyperion
10
+ class CLI
11
+ DEFAULT_CONFIG_PATH = 'config/hyperion.rb'
12
+
13
+ def self.run(argv)
14
+ cli_opts = {}
15
+ config_path = nil
16
+
17
+ parser = OptionParser.new do |o|
18
+ o.banner = 'Usage: hyperion [options] config.ru'
19
+ o.on('-C', '--config PATH', "Hyperion config file (default ./#{DEFAULT_CONFIG_PATH} if it exists)") do |p|
20
+ config_path = p
21
+ end
22
+ o.on('-b', '--bind HOST', 'host (default 127.0.0.1)') { |h| cli_opts[:host] = h }
23
+ o.on('-p', '--port PORT', Integer, 'port (default 9292)') { |p| cli_opts[:port] = p }
24
+ o.on('-w', '--workers N', Integer, 'worker processes (0 = nprocessors)') { |w| cli_opts[:workers] = w }
25
+ o.on('-t', '--threads N', Integer, 'Rack handler thread pool size (0 disables)') do |t|
26
+ cli_opts[:thread_count] = t
27
+ end
28
+ o.on('--tls-cert PATH', 'TLS certificate (PEM)') do |p|
29
+ cli_opts[:tls_cert] = OpenSSL::X509::Certificate.new(File.read(p))
30
+ end
31
+ o.on('--tls-key PATH', 'TLS private key (PEM)') do |p|
32
+ cli_opts[:tls_key] = OpenSSL::PKey.read(File.read(p))
33
+ end
34
+ o.on('--log-level LEVEL', %w[debug info warn error fatal], 'log level (default info)') do |l|
35
+ cli_opts[:log_level] = l.to_sym
36
+ end
37
+ o.on('--log-format FORMAT', %w[text json auto],
38
+ 'log format: text | json | auto (default auto: json on RAILS_ENV/RACK_ENV=production, colored text on TTY, json otherwise)') do |f|
39
+ cli_opts[:log_format] = f.to_sym
40
+ end
41
+ o.on('--[no-]log-requests',
42
+ 'Per-request access log line (default ON; pass --no-log-requests to disable).') do |v|
43
+ cli_opts[:log_requests] = v
44
+ end
45
+ o.on('--fiber-local-shim', 'Patch Thread.current[] to be fiber-local (Rails-compat for older gems)') do
46
+ cli_opts[:fiber_local_shim] = true
47
+ end
48
+ o.on('-h', '--help', 'show help') do
49
+ puts o
50
+ exit 0
51
+ end
52
+ end
53
+ parser.parse!(argv)
54
+
55
+ # Precedence: CLI > config file > built-in default. We auto-load
56
+ # config/hyperion.rb if present so operators can drop a file in their
57
+ # repo and have it take effect without having to remember -C.
58
+ config_path ||= DEFAULT_CONFIG_PATH if File.exist?(DEFAULT_CONFIG_PATH)
59
+ config = config_path ? Hyperion::Config.load(config_path) : Hyperion::Config.new
60
+ config.merge_cli!(cli_opts)
61
+
62
+ # Install logger early so every subsequent log call honours the operator's
63
+ # chosen format/level (config file or CLI) before anything else logs.
64
+ if config.log_level || config.log_format
65
+ Hyperion.logger = Hyperion::Logger.new(level: config.log_level, format: config.log_format)
66
+ end
67
+
68
+ # Propagate log_requests so every Connection picks it up via
69
+ # `Hyperion.log_requests?` without needing to thread it through
70
+ # Server/ThreadPool/Master plumbing. Default is ON; nil means "don't
71
+ # touch — fall through to the env/default chain in Hyperion.log_requests?".
72
+ Hyperion.log_requests = config.log_requests unless config.log_requests.nil?
73
+
74
+ rackup = argv.first || 'config.ru'
75
+ abort("[hyperion] no such rackup file: #{rackup}") unless File.exist?(rackup)
76
+
77
+ if config.fiber_local_shim
78
+ Hyperion::FiberLocal.install!
79
+ Hyperion.logger.info { { message: 'FiberLocal shim installed' } }
80
+ end
81
+
82
+ app = load_rack_app(rackup)
83
+ workers = config.workers.zero? ? Etc.nprocessors : config.workers
84
+
85
+ if workers <= 1
86
+ run_single(config, app)
87
+ else
88
+ run_cluster(config, app, workers)
89
+ end
90
+ end
91
+
92
+ def self.run_single(config, app)
93
+ tls = build_tls_from_config(config)
94
+ server = Server.new(host: config.host, port: config.port, app: app,
95
+ tls: tls, thread_count: config.thread_count,
96
+ read_timeout: config.read_timeout)
97
+ server.listen
98
+ scheme = tls ? 'https' : 'http'
99
+ Hyperion.logger.info { { message: 'listening', url: "#{scheme}://#{server.host}:#{server.port}" } }
100
+
101
+ # Single-worker mode reuses the lifecycle hooks: before_fork is a no-op
102
+ # here (no fork happens), and on_worker_boot/on_worker_shutdown fire
103
+ # for the lone in-process "worker" so app code that opens DB pools etc.
104
+ # gets the same lifecycle whether you run 1 or N workers.
105
+ config.on_worker_boot.each { |h| h.call(0) }
106
+
107
+ shutdown_r, shutdown_w = IO.pipe
108
+ %w[INT TERM].each do |sig|
109
+ Signal.trap(sig) do
110
+ shutdown_w.write_nonblock('!')
111
+ rescue StandardError
112
+ nil
113
+ end
114
+ end
115
+
116
+ shutdown_thread = Thread.new do
117
+ shutdown_r.read(1)
118
+ server.stop
119
+ end
120
+ shutdown_thread.report_on_exception = false
121
+
122
+ server.start
123
+ shutdown_thread.join
124
+ config.on_worker_shutdown.each { |h| h.call(0) }
125
+ end
126
+
127
+ def self.run_cluster(config, app, workers)
128
+ tls = build_tls_from_config(config)
129
+ Master.new(host: config.host, port: config.port, app: app,
130
+ workers: workers, tls: tls, thread_count: config.thread_count,
131
+ read_timeout: config.read_timeout, config: config).run
132
+ end
133
+
134
+ # Rack 3's parse_file returns a single app value; Rack 2 returned [app, options].
135
+ # Normalize so we get just the app either way.
136
+ def self.load_rack_app(path)
137
+ result = ::Rack::Builder.parse_file(path)
138
+ result.is_a?(Array) ? result.first : result
139
+ end
140
+ private_class_method :load_rack_app
141
+
142
+ def self.build_tls_from_config(config)
143
+ return nil unless config.tls_cert || config.tls_key
144
+
145
+ abort('[hyperion] tls_cert and tls_key must be supplied together') unless config.tls_cert && config.tls_key
146
+
147
+ { cert: config.tls_cert, key: config.tls_key }
148
+ end
149
+ private_class_method :build_tls_from_config
150
+ end
151
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hyperion
4
+ # Mutable configuration container — populated by the DSL evaluator
5
+ # (Hyperion::Config.load) and then read by CLI / Server / Master / Worker /
6
+ # Connection / Logger.
7
+ #
8
+ # All settings have safe defaults that match the per-class DEFAULT_* constants
9
+ # so that running Hyperion without a config file works identically to the
10
+ # pre-rc14 behaviour.
11
+ class Config
12
+ DEFAULTS = {
13
+ host: '127.0.0.1',
14
+ port: 9292,
15
+ workers: 1,
16
+ thread_count: 5,
17
+ tls_cert: nil,
18
+ tls_key: nil,
19
+ read_timeout: 30,
20
+ idle_keepalive: 5,
21
+ graceful_timeout: 30,
22
+ max_header_bytes: 64 * 1024,
23
+ max_body_bytes: 16 * 1024 * 1024,
24
+ log_level: nil, # nil → Logger picks from env / default
25
+ log_format: nil, # nil → Logger picks via auto rule
26
+ log_requests: nil, # nil → Hyperion.log_requests? (default true)
27
+ fiber_local_shim: false
28
+ }.freeze
29
+
30
+ HOOKS = %i[before_fork on_worker_boot on_worker_shutdown].freeze
31
+
32
+ attr_accessor(*DEFAULTS.keys)
33
+ attr_reader(*HOOKS)
34
+
35
+ def initialize
36
+ DEFAULTS.each { |k, v| public_send(:"#{k}=", v) }
37
+ HOOKS.each { |h| instance_variable_set(:"@#{h}", []) }
38
+ end
39
+
40
+ HOOKS.each do |hook|
41
+ define_method(:"add_#{hook}") do |&block|
42
+ instance_variable_get(:"@#{hook}") << block if block
43
+ end
44
+ end
45
+
46
+ # Load a Ruby DSL config file. Returns the populated Config.
47
+ # Path is the operator-supplied --config argument; we evaluate it in a
48
+ # DSL context that maps method calls to attribute setters.
49
+ def self.load(path)
50
+ cfg = new
51
+ contents = File.read(path)
52
+ DSL.new(cfg).instance_eval(contents, path)
53
+ cfg
54
+ end
55
+
56
+ # Apply CLI overrides on top of an existing config. Only non-nil values
57
+ # in `overrides` are applied — preserves the precedence ordering
58
+ # (CLI > env > config file > default).
59
+ def merge_cli!(overrides)
60
+ overrides.each do |key, value|
61
+ next if value.nil?
62
+
63
+ public_send(:"#{key}=", value) if respond_to?(:"#{key}=")
64
+ end
65
+ self
66
+ end
67
+
68
+ # DSL receiver. Each method call on the DSL maps to a Config setter or
69
+ # to a hook registration. Unknown methods raise NoMethodError so typos
70
+ # surface immediately at boot rather than as silent ignores.
71
+ class DSL
72
+ def initialize(config)
73
+ @config = config
74
+ end
75
+
76
+ # `bind` is the Puma-style alias for `host` — operators expect it.
77
+ def bind(value)
78
+ @config.host = value
79
+ end
80
+
81
+ Config::DEFAULTS.each_key do |key|
82
+ define_method(key) do |value|
83
+ @config.public_send(:"#{key}=", value)
84
+ end
85
+ end
86
+
87
+ Config::HOOKS.each do |hook|
88
+ define_method(hook) do |&block|
89
+ @config.public_send(:"add_#{hook}", &block)
90
+ end
91
+ end
92
+
93
+ # `tls_cert_path` / `tls_key_path` are convenience aliases that read
94
+ # the file off disk so the DSL stays terse. The parsed cert/key are
95
+ # stored on the config and Server consumes them directly.
96
+ def tls_cert_path(path)
97
+ require 'openssl'
98
+ @config.tls_cert = OpenSSL::X509::Certificate.new(File.read(path))
99
+ end
100
+
101
+ def tls_key_path(path)
102
+ require 'openssl'
103
+ @config.tls_key = OpenSSL::PKey.read(File.read(path))
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,338 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hyperion
4
+ # Drives one TCP connection through its lifecycle:
5
+ # read until headers complete + body, parse, dispatch via Rack adapter, write, close.
6
+ # Phase 2 adds fiber scheduling and keep-alive; the public surface (#serve)
7
+ # is stable.
8
+ #
9
+ # Phase 1 assumes blocking I/O: socket.read(N) blocks until N bytes or EOF, so
10
+ # `break if chunk.nil? || chunk.empty?` correctly detects EOF in read_request.
11
+ # Phase 2 (fiber scheduler) introduces non-blocking semantics where short reads
12
+ # and EAGAIN must be distinguished from EOF — read_request will need to handle
13
+ # IO::WaitReadable explicitly at that point.
14
+ class Connection
15
+ READ_CHUNK = 16 * 1024
16
+ MAX_HEADER_BYTES = 64 * 1024
17
+ MAX_BODY_BYTES = 16 * 1024 * 1024 # 16 MB cap. Phase 5 introduces streaming bodies.
18
+ HEADER_TERM = "\r\n\r\n"
19
+ TIMEOUT_SENTINEL = :__hyperion_read_timeout__
20
+ IDLE_KEEPALIVE_TIMEOUT_SECONDS = 5
21
+
22
+ # Default parser is the C-extension `CParser` when the extension built;
23
+ # otherwise we fall back to the pure-Ruby `Parser`. Evaluated each call
24
+ # because Ruby evaluates default kwargs at call time.
25
+ def self.default_parser
26
+ defined?(::Hyperion::CParser) ? ::Hyperion::CParser.new : ::Hyperion::Parser.new
27
+ end
28
+
29
+ def initialize(parser: self.class.default_parser, writer: ResponseWriter.new, thread_pool: nil,
30
+ log_requests: nil)
31
+ @parser = parser
32
+ @writer = writer
33
+ @thread_pool = thread_pool
34
+ # Cache module-level singletons once per Connection instance so the hot
35
+ # path doesn't re-dispatch through Hyperion.metrics / Hyperion.logger
36
+ # (each was a method call + ivar nil-check on every request).
37
+ @metrics = Hyperion.metrics
38
+ @logger = Hyperion.logger
39
+ # Per-request access logging is ON by default (matches Puma+Rails
40
+ # operator expectation). The hot path is optimised end-to-end: one
41
+ # Process.clock_gettime per request, per-thread cached timestamp,
42
+ # hand-rolled line builder, lock-free emit. Operator disables via
43
+ # `--no-log-requests` or `HYPERION_LOG_REQUESTS=0`.
44
+ @log_requests = log_requests.nil? ? Hyperion.log_requests? : log_requests
45
+ end
46
+
47
+ def serve(socket, app)
48
+ request_count = 0
49
+ carry = +'' # bytes already pulled off the socket but past the prev request boundary
50
+ peer_addr = peer_address(socket)
51
+ @metrics.increment(:connections_accepted)
52
+ @metrics.increment(:connections_active)
53
+ loop do
54
+ buffer = read_request(socket, carry)
55
+ return unless buffer
56
+
57
+ if buffer == TIMEOUT_SENTINEL
58
+ # Idle timeout between keep-alive requests: close silently — the peer
59
+ # never started a new request, so there's nothing to 408 about.
60
+ @metrics.increment(:read_timeouts)
61
+ return if request_count.positive?
62
+
63
+ safe_write_error(socket, 408, 'Request Timeout')
64
+ @metrics.increment_status(408)
65
+ return
66
+ end
67
+
68
+ request, body_end = @parser.parse(buffer)
69
+ carry = +(buffer.byteslice(body_end, buffer.bytesize - body_end) || '')
70
+ request = enrich_with_peer(request, peer_addr) if peer_addr && request.peer_address.nil?
71
+
72
+ @metrics.increment(:requests_total)
73
+ @metrics.increment(:requests_in_flight)
74
+ request_started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) if @log_requests
75
+ begin
76
+ status, headers, body = call_app(app, request)
77
+ ensure
78
+ @metrics.decrement(:requests_in_flight)
79
+ end
80
+
81
+ keep_alive = should_keep_alive?(request, status, headers)
82
+ @writer.write(socket, status, headers, body, keep_alive: keep_alive)
83
+ @metrics.increment_status(status)
84
+ log_request(request, status, request_started_at) if @log_requests
85
+ request_count += 1
86
+
87
+ return unless keep_alive
88
+
89
+ # Idle wait between requests: don't hold a fiber forever on a quiet conn.
90
+ set_idle_timeout(socket)
91
+ end
92
+ rescue ParseError => e
93
+ @metrics.increment(:parse_errors)
94
+ @logger.warn { { message: 'parse error', error: e.message, error_class: e.class.name } }
95
+ safe_write_error(socket, 400, 'Bad Request')
96
+ @metrics.increment_status(400)
97
+ rescue UnsupportedError => e
98
+ @logger.warn { { message: 'unsupported request', error: e.message, error_class: e.class.name } }
99
+ safe_write_error(socket, 501, 'Not Implemented')
100
+ @metrics.increment_status(501)
101
+ rescue StandardError => e
102
+ @metrics.increment(:app_errors)
103
+ @logger.error do
104
+ { message: 'unhandled in connection', error: e.message, error_class: e.class.name }
105
+ end
106
+ ensure
107
+ @metrics.decrement(:connections_active)
108
+ # Flush any buffered access-log lines for this thread before letting
109
+ # the connection go idle. Otherwise a low-traffic worker would hold
110
+ # logs in its per-thread buffer indefinitely.
111
+ @logger.flush_access_buffer if @log_requests && @logger.respond_to?(:flush_access_buffer)
112
+ begin
113
+ socket.close unless socket.closed?
114
+ rescue StandardError
115
+ # Already failing; swallow close errors so we don't mask the real cause.
116
+ end
117
+ end
118
+
119
+ private
120
+
121
+ # Route Rack dispatch through the thread pool when one was injected,
122
+ # otherwise run inline on the current fiber. Inline keeps the test path
123
+ # simple (no extra threads spun up for unit specs) and provides a
124
+ # debugging escape hatch via `Server#thread_count: 0`.
125
+ def call_app(app, request)
126
+ if @thread_pool
127
+ @thread_pool.call(app, request)
128
+ else
129
+ Adapter::Rack.call(app, request)
130
+ end
131
+ end
132
+
133
+ # Extract the peer IP from the underlying socket, if available.
134
+ # Works for TCPSocket and OpenSSL::SSL::SSLSocket (via #io). UNIX sockets
135
+ # return AF_UNIX with an empty path — we return nil there so the adapter
136
+ # falls back to its localhost default.
137
+ def peer_address(socket)
138
+ raw = socket.respond_to?(:io) ? socket.io : socket
139
+ return nil unless raw.respond_to?(:peeraddr)
140
+
141
+ addr = raw.peeraddr
142
+ ip = addr[3] || addr[2]
143
+ return nil if ip.nil? || ip.to_s.empty?
144
+
145
+ ip
146
+ rescue StandardError
147
+ nil
148
+ end
149
+
150
+ # Request is frozen — to enrich it we build a new value object with the
151
+ # peer address copied in. Cheap on the fast path because we only do this
152
+ # once per connection (peer_addr is captured before the request loop).
153
+ def enrich_with_peer(request, peer_addr)
154
+ Hyperion::Request.new(
155
+ method: request.method,
156
+ path: request.path,
157
+ query_string: request.query_string,
158
+ http_version: request.http_version,
159
+ headers: request.headers,
160
+ body: request.body,
161
+ peer_address: peer_addr
162
+ )
163
+ end
164
+
165
+ def should_keep_alive?(request, _status, headers)
166
+ # App-emitted Connection: close wins.
167
+ conn_response = headers.find { |k, _| k.to_s.downcase == 'connection' }
168
+ return false if conn_response && conn_response.last.to_s.downcase == 'close'
169
+
170
+ # Request-side Connection header.
171
+ conn_request = request.header('connection')&.downcase
172
+
173
+ case request.http_version
174
+ when 'HTTP/1.1'
175
+ conn_request != 'close'
176
+ when 'HTTP/1.0'
177
+ conn_request == 'keep-alive'
178
+ else
179
+ false
180
+ end
181
+ end
182
+
183
+ def set_idle_timeout(socket)
184
+ socket.timeout = IDLE_KEEPALIVE_TIMEOUT_SECONDS if socket.respond_to?(:timeout=)
185
+ rescue StandardError
186
+ # Best-effort; if the socket type doesn't support it, read_chunk's
187
+ # IO.select fallback still gives us a deadline via read_timeout_for.
188
+ end
189
+
190
+ # Reads one complete request off the socket. `carry` is bytes already
191
+ # buffered from the previous request's trailing read (keep-alive
192
+ # pipelining). Returns the full buffer (with any trailing pipelined
193
+ # bytes intact); the parser's returned end_offset tells the caller
194
+ # where this request ends. On EOF returns nil; on read timeout returns
195
+ # TIMEOUT_SENTINEL.
196
+ def read_request(socket, carry = +'')
197
+ buffer = carry
198
+ until buffer.include?(HEADER_TERM)
199
+ chunk = read_chunk(socket)
200
+ return chunk if chunk.nil? || chunk == TIMEOUT_SENTINEL
201
+ return nil if chunk.empty?
202
+
203
+ buffer << chunk
204
+ raise ParseError, 'header section too large' if buffer.bytesize > MAX_HEADER_BYTES
205
+ end
206
+
207
+ header_end = buffer.index(HEADER_TERM) + HEADER_TERM.bytesize
208
+ headers_part = buffer.byteslice(0, header_end)
209
+
210
+ if chunked?(headers_part)
211
+ until chunked_body_complete?(buffer, header_end)
212
+ raise ParseError, 'chunked body exceeds limit' if buffer.bytesize - header_end > MAX_BODY_BYTES
213
+
214
+ chunk = read_chunk(socket)
215
+ break if chunk.nil? || chunk.empty? || chunk == TIMEOUT_SENTINEL
216
+
217
+ buffer << chunk
218
+ end
219
+ else
220
+ content_length = headers_part[/^content-length:\s*(\d+)/i, 1].to_i
221
+ while buffer.bytesize < header_end + content_length
222
+ chunk = read_chunk(socket)
223
+ break if chunk.nil? || chunk.empty? || chunk == TIMEOUT_SENTINEL
224
+
225
+ buffer << chunk
226
+ end
227
+ end
228
+
229
+ buffer
230
+ end
231
+
232
+ def chunked?(headers_part)
233
+ headers_part.match?(/^transfer-encoding:[ \t]*[^\r\n]*chunked\b/i)
234
+ end
235
+
236
+ # Walks chunked framing in `buffer` starting at `body_start` and
237
+ # returns true once the final 0-sized chunk (and trailer terminator)
238
+ # is fully buffered. Mirrors the parser's dechunk walk; Phase 4's C
239
+ # parser folds these together via incremental parsing.
240
+ def chunked_body_complete?(buffer, body_start)
241
+ cursor = body_start
242
+ loop do
243
+ line_end = buffer.index("\r\n", cursor)
244
+ return false unless line_end
245
+
246
+ size_line = buffer.byteslice(cursor, line_end - cursor)
247
+ size_token = size_line.split(';').first.to_s.strip
248
+ return false if size_token.empty?
249
+
250
+ size = size_token.to_i(16)
251
+ cursor = line_end + 2
252
+
253
+ if size.zero?
254
+ loop do
255
+ nl = buffer.index("\r\n", cursor)
256
+ return false unless nl
257
+ return true if nl == cursor
258
+
259
+ cursor = nl + 2
260
+ end
261
+ end
262
+
263
+ return false if buffer.bytesize < cursor + size + 2
264
+
265
+ cursor += size + 2
266
+ end
267
+ end
268
+
269
+ # Read up to READ_CHUNK bytes, returning whatever's available. Unlike
270
+ # IO#read(N) — which blocks until N bytes or EOF — read_nonblock returns
271
+ # as soon as any data arrives, which is what we need for live HTTP
272
+ # clients that send a small request and then wait for a response on
273
+ # the same socket without closing the write half.
274
+ #
275
+ # Phase 8 perf fix: try read_nonblock FIRST, only fall through to IO.select
276
+ # if no data is buffered. wrk and other benchmarkers pre-buffer the entire
277
+ # request so the first readpartial succeeds and we skip the select syscall
278
+ # entirely. The IO.select fallback still gives us a deterministic deadline
279
+ # against stalled peers (SO_RCVTIMEO and IO#timeout= don't reliably trip
280
+ # readpartial on Ruby 3.3).
281
+ def read_chunk(socket)
282
+ result = socket.read_nonblock(READ_CHUNK, exception: false)
283
+ return result if result.is_a?(String) # hot path: data was buffered, return immediately
284
+ return nil if result.nil? # EOF
285
+
286
+ # :wait_readable — fall back to IO.select with a deadline.
287
+ timeout = read_timeout_for(socket)
288
+ ready, = IO.select([socket], nil, nil, timeout)
289
+ return TIMEOUT_SENTINEL if ready.nil?
290
+
291
+ retry_read_nonblock(socket)
292
+ rescue EOFError
293
+ nil
294
+ rescue Errno::EAGAIN, Errno::EWOULDBLOCK, IO::TimeoutError
295
+ TIMEOUT_SENTINEL
296
+ end
297
+
298
+ def retry_read_nonblock(socket)
299
+ socket.read_nonblock(READ_CHUNK)
300
+ rescue IO::WaitReadable
301
+ TIMEOUT_SENTINEL
302
+ rescue EOFError
303
+ nil
304
+ end
305
+
306
+ def read_timeout_for(socket)
307
+ socket.respond_to?(:timeout) && socket.timeout || 30
308
+ rescue StandardError
309
+ 30
310
+ end
311
+
312
+ def safe_write_error(socket, status, body_text)
313
+ @writer.write(socket, status, { 'content-type' => 'text/plain' }, [body_text])
314
+ rescue StandardError => e
315
+ @logger.error do
316
+ { message: 'failed to write error response', error: e.message, error_class: e.class.name }
317
+ end
318
+ end
319
+
320
+ # Emit one structured access-log line per response. Default ON; operator
321
+ # disables via `--no-log-requests`. Routes through Logger#access which
322
+ # uses a hand-rolled single-interpolation builder + per-thread cached
323
+ # timestamp + lock-free emit (no mutex, no flush) — at 16 threads the
324
+ # default-ON path runs within a few percent of the disabled path.
325
+ def log_request(request, status, started_at)
326
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round(2)
327
+ @logger.access(
328
+ request.method,
329
+ request.path,
330
+ request.query_string,
331
+ status,
332
+ duration_ms,
333
+ request.peer_address,
334
+ request.http_version
335
+ )
336
+ end
337
+ end
338
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hyperion
4
+ # FiberLocal — tooling for fiber-local request scope under the Async scheduler.
5
+ #
6
+ # ## Background
7
+ #
8
+ # Under fiber-per-request concurrency (Hyperion Phase 2+, Falcon), all
9
+ # in-flight requests share the same OS thread. Code that stores per-request
10
+ # state on `Thread.current` would leak between requests.
11
+ #
12
+ # **Ruby 3.2+ already solves the most common case:** `Thread.current[:k] = v`
13
+ # writes to FIBER-local storage, not thread-local storage. Each fiber's
14
+ # `Thread.current[:k]` is independent. This is what Hyperion relies on.
15
+ #
16
+ # The remaining footgun is `Thread.current.thread_variable_set`, which IS
17
+ # genuinely thread-shared and will leak across fiber-scheduled requests.
18
+ # Old Rails code (< 7.0) sometimes used this. Modern Rails uses
19
+ # `ActiveSupport::IsolatedExecutionState` (which routes to Fiber storage),
20
+ # so well-maintained apps are not affected.
21
+ #
22
+ # ## What this module provides
23
+ #
24
+ # `Hyperion::FiberLocal.verify_environment!` — sanity check that the
25
+ # current Ruby actually isolates `Thread.current[:k]` per-fiber. Raises if
26
+ # not (which would only happen on Ruby < 3.2).
27
+ #
28
+ # `Hyperion::FiberLocal.install!` — opt-in monkey-patch that ALSO routes
29
+ # `thread_variable_get`/`thread_variable_set` to fiber storage. Use only
30
+ # if you know your app stores request scope via thread variables and you
31
+ # accept the trade-offs (genuine thread-pool patterns will break).
32
+ module FiberLocal
33
+ @installed = false
34
+
35
+ class << self
36
+ def installed?
37
+ @installed
38
+ end
39
+
40
+ # Confirm that the current Ruby treats Thread.current[:k] as fiber-local.
41
+ # Raises NotImplementedError on older Ruby where the leak still exists.
42
+ def verify_environment!
43
+ marker = :__hyperion_fiber_isolation_check__
44
+ ::Thread.current[marker] = :outer
45
+
46
+ observed = nil
47
+ ::Fiber.new { observed = ::Thread.current[marker] }.resume
48
+
49
+ unless observed.nil?
50
+ raise NotImplementedError,
51
+ 'Thread.current[:k] is NOT fiber-local on this Ruby. ' \
52
+ 'Hyperion requires Ruby 3.2+ for safe fiber-per-request scope. ' \
53
+ "Got Ruby #{RUBY_VERSION}."
54
+ end
55
+
56
+ true
57
+ ensure
58
+ ::Thread.current[marker] = nil
59
+ end
60
+
61
+ # Opt-in patch that routes thread_variable_get/set to fiber storage.
62
+ # Most apps DO NOT need this — Ruby 3.2+ symbol-keyed Thread.current[]
63
+ # is already fiber-local. Only install! if your app uses
64
+ # thread_variable_set for request scope.
65
+ def install!
66
+ return if @installed
67
+
68
+ ::Thread.class_eval do
69
+ alias_method :__hyperion_orig_tvar_get, :thread_variable_get
70
+ alias_method :__hyperion_orig_tvar_set, :thread_variable_set
71
+
72
+ define_method(:thread_variable_get) do |key|
73
+ sym = key.to_sym
74
+ storage = ::Fiber.current.storage
75
+ return storage[sym] if storage&.key?(sym)
76
+
77
+ __hyperion_orig_tvar_get(key)
78
+ end
79
+
80
+ define_method(:thread_variable_set) do |key, value|
81
+ ::Fiber.current.storage ||= {}
82
+ ::Fiber.current.storage[key.to_sym] = value
83
+ end
84
+ end
85
+
86
+ @installed = true
87
+ end
88
+
89
+ # Test-only undo. Not promised for production.
90
+ def uninstall!
91
+ return unless @installed
92
+
93
+ ::Thread.class_eval do
94
+ alias_method :thread_variable_get, :__hyperion_orig_tvar_get
95
+ alias_method :thread_variable_set, :__hyperion_orig_tvar_set
96
+ remove_method :__hyperion_orig_tvar_get
97
+ remove_method :__hyperion_orig_tvar_set
98
+ end
99
+
100
+ @installed = false
101
+ end
102
+ end
103
+ end
104
+ end