hyperion-rb 2.11.0 → 2.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +566 -0
- data/README.md +102 -5
- data/ext/hyperion_http/extconf.rb +41 -0
- data/ext/hyperion_http/io_uring_loop.c +710 -0
- data/ext/hyperion_http/page_cache.c +1032 -0
- data/ext/hyperion_http/page_cache_internal.h +132 -0
- data/lib/hyperion/connection.rb +14 -0
- data/lib/hyperion/dispatch_mode.rb +19 -1
- data/lib/hyperion/http2_handler.rb +123 -5
- data/lib/hyperion/metrics.rb +38 -0
- data/lib/hyperion/prometheus_exporter.rb +76 -1
- data/lib/hyperion/server/connection_loop.rb +159 -0
- data/lib/hyperion/server.rb +183 -0
- data/lib/hyperion/thread_pool.rb +23 -7
- data/lib/hyperion/version.rb +1 -1
- metadata +4 -1
data/lib/hyperion/server.rb
CHANGED
|
@@ -6,6 +6,7 @@ require 'async'
|
|
|
6
6
|
require 'async/scheduler'
|
|
7
7
|
|
|
8
8
|
require_relative 'server/route_table'
|
|
9
|
+
require_relative 'server/connection_loop'
|
|
9
10
|
|
|
10
11
|
module Hyperion
|
|
11
12
|
# Phase 2a server: bind a TCPServer, accept connections, schedule each on its
|
|
@@ -400,7 +401,16 @@ module Hyperion
|
|
|
400
401
|
# a worker via the thread pool, or are served inline when no pool is
|
|
401
402
|
# configured (thread_count: 0). Matches the dispatch contract used by
|
|
402
403
|
# the TLS path; just skips the irrelevant h2/ALPN branch.
|
|
404
|
+
#
|
|
405
|
+
# 2.12-C — when the route table is composed entirely of `StaticEntry`
|
|
406
|
+
# registrations (and at least one is present), and the C ext is
|
|
407
|
+
# available, the entire accept-and-serve loop runs in C via
|
|
408
|
+
# `Hyperion::Http::PageCache.run_static_accept_loop`. Ruby is only
|
|
409
|
+
# re-entered for lifecycle hooks (gated by a C-side flag) and for
|
|
410
|
+
# the handoff path when a request doesn't match any StaticEntry.
|
|
403
411
|
def start_raw_loop
|
|
412
|
+
return run_c_accept_loop if engage_c_accept_loop?
|
|
413
|
+
|
|
404
414
|
until @stopped
|
|
405
415
|
socket = accept_or_nil
|
|
406
416
|
next unless socket
|
|
@@ -429,6 +439,179 @@ module Hyperion
|
|
|
429
439
|
end
|
|
430
440
|
end
|
|
431
441
|
|
|
442
|
+
# Whether all engagement conditions hold for the C accept loop.
|
|
443
|
+
def engage_c_accept_loop?
|
|
444
|
+
return false if @tls
|
|
445
|
+
return false unless ConnectionLoop.available?
|
|
446
|
+
return false unless ConnectionLoop.eligible_route_table?(@route_table)
|
|
447
|
+
|
|
448
|
+
true
|
|
449
|
+
end
|
|
450
|
+
private :engage_c_accept_loop?
|
|
451
|
+
|
|
452
|
+
# Hand control of the listening fd to the C loop. Wires up the
|
|
453
|
+
# lifecycle + handoff callbacks first; on return (clean stop or
|
|
454
|
+
# `:crashed` sentinel from C), bumps the dispatch metric and
|
|
455
|
+
# falls through to the regular Ruby accept loop on `:crashed`
|
|
456
|
+
# so an unrecoverable C-side accept error doesn't leave the
|
|
457
|
+
# listener idle.
|
|
458
|
+
def run_c_accept_loop
|
|
459
|
+
pc = ::Hyperion::Http::PageCache
|
|
460
|
+
pc.set_lifecycle_callback(ConnectionLoop.build_lifecycle_callback(@runtime))
|
|
461
|
+
pc.set_lifecycle_active(@runtime.has_request_hooks?)
|
|
462
|
+
pc.set_handoff_callback(ConnectionLoop.build_handoff_callback(self))
|
|
463
|
+
# 2.12-E — register the per-worker request counter family on the
|
|
464
|
+
# runtime's metrics sink BEFORE the C loop starts ticking. The
|
|
465
|
+
# PrometheusExporter's C-loop fold-in is gated on the family
|
|
466
|
+
# already existing in the snapshot (so spec-only sinks that
|
|
467
|
+
# never tick stay clean), and the C accept loop bypasses
|
|
468
|
+
# `Connection#serve` — without an explicit boot-time register,
|
|
469
|
+
# a 100% C-loop worker would scrape zero requests even with
|
|
470
|
+
# the atomic happily ticking.
|
|
471
|
+
runtime_metrics.ensure_worker_request_family_registered! \
|
|
472
|
+
if runtime_metrics.respond_to?(:ensure_worker_request_family_registered!)
|
|
473
|
+
|
|
474
|
+
# 2.12-D — io_uring path takes precedence over accept4 when the
|
|
475
|
+
# operator opted in AND the runtime probe at boot succeeds. The
|
|
476
|
+
# C-side `run_static_io_uring_loop` returns `:unavailable` if
|
|
477
|
+
# `io_uring_queue_init` fails; we treat that as "fall back to
|
|
478
|
+
# the 2.12-C accept4 path" without operator intervention. A
|
|
479
|
+
# `:crashed` from io_uring also falls back — the contract
|
|
480
|
+
# mirrors 2.12-C's `:crashed -> Ruby accept loop`.
|
|
481
|
+
use_io_uring = ConnectionLoop.io_uring_eligible?
|
|
482
|
+
mode_name = use_io_uring ? :c_accept_loop_io_uring_h1 : :c_accept_loop_h1
|
|
483
|
+
mode = DispatchMode.new(mode_name)
|
|
484
|
+
record_dispatch(mode)
|
|
485
|
+
runtime_logger.info do
|
|
486
|
+
{ message: 'engaging C accept loop',
|
|
487
|
+
variant: use_io_uring ? :io_uring : :accept4,
|
|
488
|
+
static_routes: @route_table.size,
|
|
489
|
+
host: @host,
|
|
490
|
+
port: @port }
|
|
491
|
+
end
|
|
492
|
+
result = if use_io_uring
|
|
493
|
+
io_uring_result = pc.run_static_io_uring_loop(@tcp_server.fileno)
|
|
494
|
+
if io_uring_result == :unavailable
|
|
495
|
+
runtime_logger.warn do
|
|
496
|
+
{ message: 'io_uring runtime probe failed; falling back to accept4 loop' }
|
|
497
|
+
end
|
|
498
|
+
pc.run_static_accept_loop(@tcp_server.fileno)
|
|
499
|
+
else
|
|
500
|
+
io_uring_result
|
|
501
|
+
end
|
|
502
|
+
else
|
|
503
|
+
pc.run_static_accept_loop(@tcp_server.fileno)
|
|
504
|
+
end
|
|
505
|
+
if result == :crashed
|
|
506
|
+
runtime_logger.warn do
|
|
507
|
+
{ message: 'C accept loop crashed; falling back to Ruby accept loop' }
|
|
508
|
+
end
|
|
509
|
+
# Fall back to the Ruby loop so the listener doesn't go silent.
|
|
510
|
+
until @stopped
|
|
511
|
+
socket = accept_or_nil
|
|
512
|
+
next unless socket
|
|
513
|
+
|
|
514
|
+
apply_timeout(socket)
|
|
515
|
+
dispatch_one_h1(socket)
|
|
516
|
+
end
|
|
517
|
+
else
|
|
518
|
+
runtime_logger.info do
|
|
519
|
+
{ message: 'C accept loop exited', requests_served: result.to_i,
|
|
520
|
+
variant: use_io_uring ? :io_uring : :accept4 }
|
|
521
|
+
end
|
|
522
|
+
runtime_metrics.increment(:c_accept_loop_requests, result.to_i)
|
|
523
|
+
end
|
|
524
|
+
ensure
|
|
525
|
+
# Best-effort: clear the lifecycle callback so a subsequent
|
|
526
|
+
# Server boot in the same process (test harnesses) doesn't see
|
|
527
|
+
# stale state.
|
|
528
|
+
pc&.set_lifecycle_active(false) if defined?(pc)
|
|
529
|
+
pc&.set_lifecycle_callback(nil) if defined?(pc)
|
|
530
|
+
pc&.set_handoff_callback(nil) if defined?(pc)
|
|
531
|
+
end
|
|
532
|
+
private :run_c_accept_loop
|
|
533
|
+
|
|
534
|
+
# Dispatch a connection that the C accept loop handed off to Ruby
|
|
535
|
+
# because it couldn't be served from the static cache (path miss,
|
|
536
|
+
# malformed request, body present, h2 upgrade requested, etc.).
|
|
537
|
+
# `partial` is the partial header buffer the C loop already read
|
|
538
|
+
# off the fd, or nil if the C loop hadn't started reading.
|
|
539
|
+
#
|
|
540
|
+
# The fd is owned by Ruby from this point on — the C loop will
|
|
541
|
+
# not touch it again. We wrap it in a `::Socket` (matches the
|
|
542
|
+
# `accept_nonblock` path's return type) and dispatch through the
|
|
543
|
+
# existing thread-pool / inline path.
|
|
544
|
+
def dispatch_handed_off(fd, partial)
|
|
545
|
+
require 'socket'
|
|
546
|
+
socket = ::Socket.for_fd(fd)
|
|
547
|
+
socket.autoclose = true
|
|
548
|
+
apply_timeout(socket)
|
|
549
|
+
# 2.12-E — `partial.present?` was a Rails-ism that crashed the
|
|
550
|
+
# handoff path with NoMethodError on plain Ruby. Hit by every
|
|
551
|
+
# request that lands on a static-only server (e.g. /-/metrics
|
|
552
|
+
# against `bench/hello_static.ru`) — the C loop hands off, the
|
|
553
|
+
# handoff dispatch raised, and the connection was force-closed
|
|
554
|
+
# with `:c_loop_handoff_failed`. Surfaced by the 2.12-E audit
|
|
555
|
+
# harness which scrapes /-/metrics on a handle_static-only
|
|
556
|
+
# cluster; pre-existing bug, fixed here so the metric is
|
|
557
|
+
# actually readable. The `is_a?(String)` guard is deliberate —
|
|
558
|
+
# it both narrows the contract (the C loop never hands off
|
|
559
|
+
# anything but a plain String or nil) and pins the shape so
|
|
560
|
+
# `rubocop-rails`'s Style/Present autocorrect can't rewrite
|
|
561
|
+
# this back to `partial.present?`.
|
|
562
|
+
carry = partial.is_a?(String) && !partial.empty? ? partial.dup.b : nil
|
|
563
|
+
|
|
564
|
+
if @thread_pool
|
|
565
|
+
mode = DispatchMode.new(:threadpool_h1)
|
|
566
|
+
# 2.12-E — thread the carry through to the worker so the
|
|
567
|
+
# `Connection#@inbuf` is preloaded with the partial header
|
|
568
|
+
# bytes the C accept loop already drained off the fd. Pre-2.12-E
|
|
569
|
+
# the threadpool handoff path silently dropped the buffer
|
|
570
|
+
# (only the inline-no-pool branch wired it), so every
|
|
571
|
+
# `-t N>0` server with the C accept loop engaged returned
|
|
572
|
+
# "Request Timeout" on every handed-off request — including
|
|
573
|
+
# the audit harness's own `/-/metrics` scrape.
|
|
574
|
+
if @thread_pool.submit_connection(socket, @app,
|
|
575
|
+
max_request_read_seconds: @max_request_read_seconds,
|
|
576
|
+
carry: carry)
|
|
577
|
+
record_dispatch(mode)
|
|
578
|
+
else
|
|
579
|
+
reject_connection(socket)
|
|
580
|
+
end
|
|
581
|
+
else
|
|
582
|
+
mode = DispatchMode.new(:inline_h1_no_pool)
|
|
583
|
+
record_dispatch(mode)
|
|
584
|
+
connection = Connection.new(runtime: @explicit_runtime ? @runtime : nil,
|
|
585
|
+
max_in_flight_per_conn: @max_in_flight_per_conn,
|
|
586
|
+
route_table: @route_table)
|
|
587
|
+
connection.instance_variable_set(:@inbuf, +carry.b) if carry
|
|
588
|
+
connection.serve(socket, @app,
|
|
589
|
+
max_request_read_seconds: @max_request_read_seconds)
|
|
590
|
+
end
|
|
591
|
+
end
|
|
592
|
+
private :dispatch_handed_off
|
|
593
|
+
|
|
594
|
+
def dispatch_one_h1(socket)
|
|
595
|
+
if @thread_pool
|
|
596
|
+
mode = DispatchMode.new(:threadpool_h1)
|
|
597
|
+
if @thread_pool.submit_connection(socket, @app,
|
|
598
|
+
max_request_read_seconds: @max_request_read_seconds)
|
|
599
|
+
record_dispatch(mode)
|
|
600
|
+
else
|
|
601
|
+
reject_connection(socket)
|
|
602
|
+
end
|
|
603
|
+
else
|
|
604
|
+
mode = DispatchMode.new(:inline_h1_no_pool)
|
|
605
|
+
record_dispatch(mode)
|
|
606
|
+
Connection.new(runtime: @explicit_runtime ? @runtime : nil,
|
|
607
|
+
max_in_flight_per_conn: @max_in_flight_per_conn,
|
|
608
|
+
route_table: @route_table).serve(
|
|
609
|
+
socket, @app, max_request_read_seconds: @max_request_read_seconds
|
|
610
|
+
)
|
|
611
|
+
end
|
|
612
|
+
end
|
|
613
|
+
private :dispatch_one_h1
|
|
614
|
+
|
|
432
615
|
# TLS / h2-capable accept loop. The Async wrapper is required because
|
|
433
616
|
# h2 streams (inside Http2Handler) and the ALPN handshake yield
|
|
434
617
|
# cooperatively via the scheduler.
|
data/lib/hyperion/thread_pool.rb
CHANGED
|
@@ -85,10 +85,20 @@ module Hyperion
|
|
|
85
85
|
# not strict. Off-by-one over the configured cap during a thundering
|
|
86
86
|
# accept burst is acceptable; the cost of stricter sync would be a
|
|
87
87
|
# mutex on every enqueue, which we won't pay on the hot path.
|
|
88
|
-
def submit_connection(socket, app, max_request_read_seconds: 60)
|
|
88
|
+
def submit_connection(socket, app, max_request_read_seconds: 60, carry: nil)
|
|
89
89
|
return false if @max_pending && @inbox.size >= @max_pending
|
|
90
90
|
|
|
91
|
-
|
|
91
|
+
# 2.12-E — `carry:` carries any partial header bytes the C accept
|
|
92
|
+
# loop already read off the fd before deciding to hand the
|
|
93
|
+
# connection off to Ruby. The worker thread pre-loads them into
|
|
94
|
+
# `Connection#@inbuf` so the parser sees the full request, not a
|
|
95
|
+
# short read that times out. Pre-2.12-E the threadpool handoff
|
|
96
|
+
# path silently dropped the partial buffer (the inline-no-pool
|
|
97
|
+
# path was the only one wired) — a server with `-t N>0` and the
|
|
98
|
+
# C accept loop engaged returned "Request Timeout" on every
|
|
99
|
+
# handed-off request, including the audit harness's own
|
|
100
|
+
# `/-/metrics` scrape.
|
|
101
|
+
@inbox << [:connection, socket, app, max_request_read_seconds, carry]
|
|
92
102
|
true
|
|
93
103
|
end
|
|
94
104
|
|
|
@@ -139,17 +149,23 @@ module Hyperion
|
|
|
139
149
|
|
|
140
150
|
case job[0]
|
|
141
151
|
when :connection
|
|
142
|
-
_, socket, app, max_request_read_seconds = job
|
|
152
|
+
_, socket, app, max_request_read_seconds, carry = job
|
|
143
153
|
# Worker thread owns the connection for its full lifetime. Pass
|
|
144
154
|
# thread_pool: nil so Connection#call_app inlines Adapter::Rack.call
|
|
145
155
|
# — the worker IS the pool, no further hop required. 2.3-B
|
|
146
156
|
# threads `max_in_flight_per_conn` so the per-conn fairness
|
|
147
157
|
# cap (if configured) takes effect on this worker's serve loop.
|
|
148
158
|
begin
|
|
149
|
-
Hyperion::Connection
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
159
|
+
connection = Hyperion::Connection
|
|
160
|
+
.new(max_in_flight_per_conn: @max_in_flight_per_conn,
|
|
161
|
+
route_table: @route_table)
|
|
162
|
+
# 2.12-E — preload `@inbuf` with the partial buffer the C
|
|
163
|
+
# accept loop already drained off the fd, mirroring the
|
|
164
|
+
# inline-no-pool branch in `Server#dispatch_handed_off`.
|
|
165
|
+
# `carry` is nil on the regular accept path; only the C
|
|
166
|
+
# loop's handoff path supplies it.
|
|
167
|
+
connection.instance_variable_set(:@inbuf, +carry.b) if carry.is_a?(String) && !carry.empty?
|
|
168
|
+
connection.serve(socket, app, max_request_read_seconds: max_request_read_seconds)
|
|
153
169
|
rescue StandardError => e
|
|
154
170
|
Hyperion.logger.error do
|
|
155
171
|
{
|
data/lib/hyperion/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hyperion-rb
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.12.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrey Lobanov
|
|
@@ -164,11 +164,13 @@ files:
|
|
|
164
164
|
- ext/hyperion_h2_codec/src/lib.rs
|
|
165
165
|
- ext/hyperion_http/extconf.rb
|
|
166
166
|
- ext/hyperion_http/h2_codec_glue.c
|
|
167
|
+
- ext/hyperion_http/io_uring_loop.c
|
|
167
168
|
- ext/hyperion_http/llhttp/api.c
|
|
168
169
|
- ext/hyperion_http/llhttp/http.c
|
|
169
170
|
- ext/hyperion_http/llhttp/llhttp.c
|
|
170
171
|
- ext/hyperion_http/llhttp/llhttp.h
|
|
171
172
|
- ext/hyperion_http/page_cache.c
|
|
173
|
+
- ext/hyperion_http/page_cache_internal.h
|
|
172
174
|
- ext/hyperion_http/parser.c
|
|
173
175
|
- ext/hyperion_http/sendfile.c
|
|
174
176
|
- ext/hyperion_http/websocket.c
|
|
@@ -207,6 +209,7 @@ files:
|
|
|
207
209
|
- lib/hyperion/response_writer.rb
|
|
208
210
|
- lib/hyperion/runtime.rb
|
|
209
211
|
- lib/hyperion/server.rb
|
|
212
|
+
- lib/hyperion/server/connection_loop.rb
|
|
210
213
|
- lib/hyperion/server/route_table.rb
|
|
211
214
|
- lib/hyperion/static_preload.rb
|
|
212
215
|
- lib/hyperion/thread_pool.rb
|