hyperion-rb 2.10.1 → 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.
@@ -74,9 +74,22 @@ module Hyperion
74
74
  hyperion_threadpool_queue_depth: {
75
75
  help: 'In-flight count in the worker ThreadPool inbox (snapshot at scrape time)',
76
76
  type: 'gauge'
77
+ },
78
+ # 2.12-E — per-worker request counter for the SO_REUSEPORT
79
+ # load-balancing audit. One series per worker (label_value =
80
+ # `Process.pid.to_s`); ticks on every dispatched request from
81
+ # every dispatch shape. Operators scrape /-/metrics N times in
82
+ # cluster mode to gather distribution across workers.
83
+ hyperion_requests_dispatch_total: {
84
+ help: 'Requests dispatched per worker (PID-labeled), across all dispatch modes',
85
+ type: 'counter'
77
86
  }
78
87
  }.freeze
79
88
 
89
+ # 2.12-E — name of the per-worker request counter family. Hoisted to
90
+ # a constant so the C-loop fold-in below stays declarative.
91
+ REQUESTS_DISPATCH_TOTAL = :hyperion_requests_dispatch_total
92
+
80
93
  def render(stats)
81
94
  buf = +''
82
95
  grouped_status = {}
@@ -129,10 +142,72 @@ module Hyperion
129
142
  buf << render(metrics_sink.snapshot)
130
143
  buf << render_histograms(metrics_sink.histogram_snapshot)
131
144
  buf << render_gauges(metrics_sink.gauge_snapshot)
132
- buf << render_labeled_counters(metrics_sink.labeled_counter_snapshot)
145
+ labeled = merge_c_loop_into_dispatch_snapshot(metrics_sink.labeled_counter_snapshot)
146
+ buf << render_labeled_counters(labeled)
133
147
  buf
134
148
  end
135
149
 
150
+ # 2.12-E — merge `Hyperion::Http::PageCache.c_loop_requests_total`
151
+ # (process-global atomic ticked by the C accept4 + io_uring loops)
152
+ # into the `hyperion_requests_dispatch_total{worker_id=PID}` series
153
+ # for the current worker. Without this fold-in, a `-w 4` cluster
154
+ # serving from the C accept loop would scrape zeros from every
155
+ # worker even though the loop's atomic counter is ticking — the
156
+ # loop bypasses `Connection#serve`, so no Ruby-side
157
+ # `tick_worker_request` call ever lands.
158
+ #
159
+ # We only fold in when the labeled-counter family is ALREADY in
160
+ # the snapshot — i.e., something on the Ruby side has called
161
+ # `Metrics#tick_worker_request` at least once on this sink. The
162
+ # `Server#run_c_accept_loop` boot path performs a single
163
+ # registration tick on the runtime's metrics sink so this
164
+ # condition holds for any production cluster engaging the C loop.
165
+ #
166
+ # Why the gate matters: spec fixtures that `Hyperion::Metrics.new`
167
+ # to assert "empty body when no metrics have been recorded" would
168
+ # otherwise pull in a process-global atomic bumped by an earlier
169
+ # spec's C-loop run. Those fixtures never register the family, so
170
+ # the fold-in skips them cleanly.
171
+ #
172
+ # Idempotent on snapshots that already contain a series for the
173
+ # current PID. Pure on the input — we build a shallow copy of the
174
+ # snapshot so the live Hash behind `Metrics#labeled_counter_snapshot`
175
+ # isn't mutated.
176
+ #
177
+ # Defensive: when the C ext isn't loaded (JRuby / TruffleRuby) we
178
+ # silently skip — the snapshot stays Ruby-only.
179
+ def merge_c_loop_into_dispatch_snapshot(snap)
180
+ family = snap[REQUESTS_DISPATCH_TOTAL]
181
+ return snap if family.nil?
182
+
183
+ c_loop_count = c_loop_requests_total
184
+ return snap if c_loop_count <= 0
185
+
186
+ pid_label = Process.pid.to_s
187
+ merged_series = family[:series].dup
188
+ key = [pid_label].freeze
189
+ existing_key = merged_series.keys.find { |k| k.first == pid_label } || key
190
+ merged_series[existing_key] = (merged_series[existing_key] || 0) + c_loop_count
191
+
192
+ merged = snap.dup
193
+ merged[REQUESTS_DISPATCH_TOTAL] = {
194
+ meta: family[:meta] || { label_keys: %w[worker_id].freeze },
195
+ series: merged_series
196
+ }
197
+ merged
198
+ end
199
+
200
+ def c_loop_requests_total
201
+ return 0 unless defined?(::Hyperion::Http::PageCache)
202
+ return 0 unless ::Hyperion::Http::PageCache.respond_to?(:c_loop_requests_total)
203
+
204
+ ::Hyperion::Http::PageCache.c_loop_requests_total.to_i
205
+ rescue StandardError
206
+ # The scrape path must never raise — observability code degrades
207
+ # to "no fold-in" rather than failing the metrics endpoint.
208
+ 0
209
+ end
210
+
136
211
  def render_histograms(snap)
137
212
  buf = +''
138
213
  snap.each do |name, payload|
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hyperion
4
+ class Server
5
+ # 2.12-C — Connection lifecycle in C.
6
+ #
7
+ # Engaged by `Server#start_raw_loop` when ALL of the following hold:
8
+ #
9
+ # * The listener is plain TCP (no TLS, no h2 ALPN dance).
10
+ # * The route table has at least one `RouteTable::StaticEntry`
11
+ # registration (i.e. `Server.handle_static` was called).
12
+ # * The route table has NO non-StaticEntry registrations
13
+ # (any `Server.handle(:GET, '/api', dynamic_handler)` disables
14
+ # the C path; the C loop only knows how to write prebuilt
15
+ # responses).
16
+ # * The `HYPERION_C_ACCEPT_LOOP` env knob is not set to `"0"` /
17
+ # `"off"` (operator escape hatch for debug).
18
+ #
19
+ # On engage, the Ruby accept loop is *not* run for this listener;
20
+ # `Hyperion::Http::PageCache.run_static_accept_loop` drives the
21
+ # accept-and-serve loop entirely in C and only re-enters Ruby for:
22
+ #
23
+ # 1. Per-request lifecycle hooks
24
+ # (`Runtime#fire_request_start` / `fire_request_end`), gated
25
+ # by a single C-side integer flag so the no-hook hot path
26
+ # stays one syscall.
27
+ # 2. Connection handoff: requests that don't match a `StaticEntry`
28
+ # (or are malformed, h2/upgrade, or carry a body) are passed
29
+ # back as `(fd, partial_buffer)` — Ruby resumes ownership of
30
+ # the fd and dispatches via the regular `Connection` path.
31
+ #
32
+ # The wiring lives in this module so the conditional logic stays
33
+ # out of the Server hot-path entry methods.
34
+ module ConnectionLoop
35
+ module_function
36
+
37
+ # Whether the C accept loop is available and the env didn't
38
+ # disable it.
39
+ def available?
40
+ return false unless defined?(::Hyperion::Http::PageCache)
41
+ return false unless ::Hyperion::Http::PageCache.respond_to?(:run_static_accept_loop)
42
+
43
+ env = ENV['HYPERION_C_ACCEPT_LOOP']
44
+ env.nil? || !%w[0 off false no].include?(env.downcase)
45
+ end
46
+
47
+ # 2.12-D — whether to engage the io_uring accept loop variant
48
+ # over the 2.12-C `accept4` loop. All four conditions must hold:
49
+ #
50
+ # 1. Operator opted in via `HYPERION_IO_URING_ACCEPT=1`. This
51
+ # is OFF by default for 2.12.0 — flipping the default to ON
52
+ # is a 2.13 decision after production-soak.
53
+ # 2. The C ext was compiled with `HAVE_LIBURING` (probed at
54
+ # gem-install time via `extconf.rb` — needs `liburing-dev`
55
+ # headers). Builds without it ship the stub method that
56
+ # returns `:unavailable` regardless of the env var.
57
+ # 3. `Hyperion::Http::PageCache.run_static_io_uring_loop` is
58
+ # defined (paranoia: the symbol always exists on builds
59
+ # that loaded the C ext, but the check keeps us from
60
+ # NameError'ing on partial installs).
61
+ # 4. A liburing runtime probe — opening a tiny ring with
62
+ # `io_uring_queue_init`. The probe lives inside the C
63
+ # method itself (`run_static_io_uring_loop` returns
64
+ # `:unavailable` if `io_uring_queue_init` fails); we
65
+ # don't pre-probe here because that would require holding
66
+ # a ring open across the eligibility check, and the
67
+ # penalty for "engaged but probe-fail at run time" is
68
+ # one cheap fall-through to the `accept4` path.
69
+ def io_uring_eligible?
70
+ return false unless available?
71
+ return false unless ::Hyperion::Http::PageCache.respond_to?(:run_static_io_uring_loop)
72
+ return false unless ::Hyperion::Http::PageCache.respond_to?(:io_uring_loop_compiled?) &&
73
+ ::Hyperion::Http::PageCache.io_uring_loop_compiled?
74
+
75
+ env = ENV['HYPERION_IO_URING_ACCEPT']
76
+ return false unless env
77
+
78
+ %w[1 on true yes].include?(env.downcase)
79
+ end
80
+
81
+ # Whether the route table is C-loop eligible: only `StaticEntry`
82
+ # handlers, at least one of them, no dynamic handlers anywhere.
83
+ def eligible_route_table?(route_table)
84
+ return false unless route_table
85
+
86
+ any_static = false
87
+ route_table.instance_variable_get(:@routes).each_value do |path_table|
88
+ path_table.each_value do |handler|
89
+ return false unless handler.is_a?(::Hyperion::Server::RouteTable::StaticEntry)
90
+
91
+ any_static = true
92
+ end
93
+ end
94
+ any_static
95
+ end
96
+
97
+ # Build a lifecycle callback that, when invoked from the C loop
98
+ # with `(method_str, path_str)`, fires the runtime's
99
+ # `fire_request_start` / `fire_request_end` hooks against a
100
+ # minimal `Hyperion::Request` value object. `env=nil` and the
101
+ # response slot carries the `:c_static` symbol (just a marker —
102
+ # the wire write already happened in C and we have no
103
+ # `[status, headers, body]` tuple to hand back).
104
+ #
105
+ # The proc captures `runtime` so multi-tenant deployments with
106
+ # per-Server runtimes route hooks to the right observer
107
+ # registry. Allocation cost: one Request per request when
108
+ # hooks are active. The C loop only invokes this callback when
109
+ # `lifecycle_active?` is true; the no-hook path pays nothing.
110
+ def build_lifecycle_callback(runtime)
111
+ lambda do |method_str, path_str|
112
+ request = ::Hyperion::Request.new(
113
+ method: method_str,
114
+ path: path_str,
115
+ query_string: nil,
116
+ http_version: 'HTTP/1.1',
117
+ headers: {},
118
+ body: nil
119
+ )
120
+ if runtime.has_request_hooks?
121
+ runtime.fire_request_start(request, nil)
122
+ runtime.fire_request_end(request, nil, :c_static, nil)
123
+ end
124
+ nil
125
+ rescue StandardError
126
+ # Hook errors are already swallowed inside `Runtime#fire_*`;
127
+ # this rescue catches Request allocation oddities so a
128
+ # misbehaving observer can't take down the C loop.
129
+ nil
130
+ end
131
+ end
132
+
133
+ # Build the handoff callback the C loop invokes when a
134
+ # connection's first request can't be served from the static
135
+ # cache. Receives `(fd, partial_buffer_or_nil)` — Ruby owns
136
+ # the fd from that point on. We wrap the fd in a `Socket`
137
+ # (so `apply_timeout` and the rest of the Connection path see
138
+ # the same surface they always see) and dispatch through the
139
+ # server's existing `dispatch_handed_off` helper.
140
+ def build_handoff_callback(server)
141
+ lambda do |fd, partial|
142
+ server.send(:dispatch_handed_off, fd, partial)
143
+ rescue StandardError => e
144
+ server.send(:runtime_logger).warn do
145
+ { message: 'C loop handoff dispatch failed',
146
+ error: e.message, error_class: e.class.name }
147
+ end
148
+ # Always close the fd if dispatch raised — Ruby owns it.
149
+ begin
150
+ require 'socket'
151
+ ::Socket.for_fd(fd).close
152
+ rescue StandardError
153
+ nil
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
@@ -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.
@@ -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
- @inbox << [:connection, socket, app, max_request_read_seconds]
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
- .new(max_in_flight_per_conn: @max_in_flight_per_conn,
151
- route_table: @route_table)
152
- .serve(socket, app, max_request_read_seconds: max_request_read_seconds)
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
  {
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hyperion
4
- VERSION = '2.10.1'
4
+ VERSION = '2.12.0'
5
5
  end
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.10.1
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