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.
@@ -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.11.0'
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.11.0
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