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
data/lib/hyperion/cli.rb
ADDED
|
@@ -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
|