hyperion-rb 2.13.0 → 2.14.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.
@@ -88,8 +88,28 @@ module Hyperion
88
88
  # On a non-match (any path / method not registered here) the
89
89
  # request falls through to the regular Rack adapter dispatch
90
90
  # — existing behaviour for un-handled routes is unchanged.
91
- def self.handle(method_sym, path, handler)
92
- route_table.register(method_sym, path, handler)
91
+ def self.handle(method_sym, path, handler = nil, &block)
92
+ raise ArgumentError, 'pass a handler OR a block, not both' if handler && block
93
+ raise ArgumentError, 'must pass a handler or block' if handler.nil? && block.nil?
94
+
95
+ if block
96
+ # 2.14-A — block form: `Server.handle(:GET, '/x') { |env| ... }`.
97
+ # Wraps the block in a `DynamicBlockEntry` so the C accept loop
98
+ # (when engaged) can recognise the entry and dispatch via the
99
+ # registered C-loop helper. The block receives a Rack env hash
100
+ # — same shape Rack apps see — and must return a `[status,
101
+ # headers, body]` triple per the Rack spec.
102
+ method_key = method_sym.to_s.upcase.to_sym
103
+ entry = RouteTable::DynamicBlockEntry.new(method_key, path.dup.freeze, block).freeze
104
+ route_table.register(method_sym, path, entry)
105
+ entry
106
+ else
107
+ # Legacy 2.10-D handler form: `handler#call(request)` returning
108
+ # a `[status, headers, body]` triple. The C accept loop does
109
+ # NOT engage on these — they fall through to the Connection
110
+ # path so the Hyperion::Request shape contract holds.
111
+ route_table.register(method_sym, path, handler)
112
+ end
93
113
  end
94
114
 
95
115
  # 2.10-D — register a direct-dispatch route whose response is
@@ -370,13 +390,150 @@ module Hyperion
370
390
  @admin_listener&.stop
371
391
  end
372
392
 
393
+ # 2.14-B — graceful stop sequence.
394
+ #
395
+ # Pre-2.14-B this was three lines: flip the Ruby `@stopped` flag,
396
+ # `close()` the listener, drop the references. That was enough
397
+ # for the Ruby/Async accept loops on every kernel — those poll
398
+ # `@stopped` every 100 ms via the `IO.select` timeout in
399
+ # `accept_or_nil` and exit at the next tick. It was NOT enough
400
+ # for the C accept loop introduced by 2.12-C: that loop calls a
401
+ # blocking `accept(2)` with the GVL released and only checks
402
+ # `hyp_cl_stop` between accepts. On Linux ≥ 6.x, calling
403
+ # `close()` on a listening socket from one thread does NOT
404
+ # interrupt another thread that is currently parked in
405
+ # `accept(2)` on that same fd — so the C loop stayed parked
406
+ # until a real connection arrived. SIGTERM-driven graceful
407
+ # shutdown then hung until the master's `graceful_timeout`
408
+ # (default 30 s) expired and SIGKILL fired. See CHANGELOG
409
+ # ### 2.13-C for the full discovery story.
410
+ #
411
+ # Fix surface: only the C accept loop needs the wake-connect
412
+ # dance. The wake gate (`wake_required?`) keeps the change
413
+ # surgical: TLS, async-IO, and thread-pool servers see the same
414
+ # close-then-drop sequence they had pre-2.14-B; only the C-loop
415
+ # server pays the burst cost. Wiring the wake into the Async
416
+ # path would be unnecessary (it polls @stopped) and would
417
+ # introduce a close-vs-`IO.select`-EBADF race on macOS kqueue.
418
+ #
419
+ # Order rationale (C-loop case).
420
+ # 1. The wake-connect dial happens BEFORE `close_listeners` so
421
+ # THIS process's listener fd is still in the SO_REUSEPORT
422
+ # pool when the kernel hashes the SYN. Closing first would
423
+ # drop us from the pool — every dial would hash to a sibling
424
+ # worker (in `:reuseport` cluster mode) and never reach our
425
+ # own parked accept thread.
426
+ # 2. The burst (`WAKE_CONNECT_BURST` dials) drives the miss
427
+ # probability down for the SO_REUSEPORT-distributes-unevenly
428
+ # case. Single-server / `:share` cluster mode (Darwin/BSD)
429
+ # just sees K extra zero-byte connects — cheap.
430
+ # 3. `close_listeners` runs last as a belt-and-braces close on
431
+ # macOS / *BSD where the close-on-accept-wake guarantee still
432
+ # holds, and to release the bound port to the OS promptly.
433
+ #
434
+ # Idempotent: a second `stop` call is a no-op — `wake_target`
435
+ # returns `[nil, nil]` once the listener references are nilled,
436
+ # and `close_listeners` swallows the EBADF.
373
437
  def stop
374
438
  @stopped = true
439
+ if wake_required?
440
+ # C-loop path: flip the C-side flag, dial the wake-connect
441
+ # burst, THEN close. The wake makes any thread parked in
442
+ # `accept(2)` return; the loop checks the flag, exits cleanly.
443
+ stop_c_accept_loop
444
+ host, port = wake_target
445
+ ConnectionLoop.wake_listener(host, port, count: ConnectionLoop::WAKE_CONNECT_BURST) \
446
+ if host && port
447
+ end
448
+ # Pre-2.14-B `close` path. For TLS / async-IO / thread-pool
449
+ # servers this is the entire stop sequence and matches the
450
+ # behaviour the spec suite (and operators) have been observing
451
+ # since 1.0 — the wake-connect dance is a no-op for them and
452
+ # has been deliberately gated out via `wake_required?`.
453
+ close_listeners
454
+ nil
455
+ end
456
+
457
+ private
458
+
459
+ # 2.14-B — predicate: is the wake-connect needed for THIS server
460
+ # instance? Only servers driving the C accept loop need it; the
461
+ # Ruby/Async paths poll `@stopped` and exit on the next 100 ms
462
+ # `IO.select` tick. We piggyback on the existing
463
+ # `engage_c_accept_loop?` predicate so the wake gate stays in
464
+ # sync with engagement: if the runtime ever changes the C-loop
465
+ # eligibility rules, both call sites update together.
466
+ #
467
+ # Async-IO path explicitly excluded: even if the route table
468
+ # would otherwise be C-loop-eligible, `start_async_loop` runs
469
+ # the Ruby accept fibers (the C loop never engages alongside
470
+ # Async). Adding wake-connect there would race close()-on-fd
471
+ # vs. an IO.select that's already parked on the listener — on
472
+ # macOS kqueue that surfaces as `Errno::EBADF` from
473
+ # `select_internal_with_gvl:kevent`, propagating up through
474
+ # `start_async_loop`'s rescue-wait.
475
+ def wake_required?
476
+ return false if @tls
477
+ return false if @async_io
478
+
479
+ engage_c_accept_loop?
480
+ end
481
+
482
+ # Capture the bound `(host, port)` of the listener BEFORE we close
483
+ # it. We deliberately read `@host` (the configured bind addr —
484
+ # `127.0.0.1` / `0.0.0.0` / a real interface IP) rather than
485
+ # `@server.addr` because:
486
+ #
487
+ # 1. Once `close()` lands the addr struct is gone — we'd dial
488
+ # against a stale value.
489
+ # 2. The wake-connect target only needs to reach this kernel's
490
+ # listener fd; localhost works for any bound address (the
491
+ # kernel routes locally).
492
+ #
493
+ # Special case: bind addr `0.0.0.0` / `::` / empty — dial 127.0.0.1
494
+ # (loopback always reaches the worker's own listener). Same trick
495
+ # the spec helper uses.
496
+ def wake_target
497
+ return [nil, nil] unless @port && @port.positive?
498
+
499
+ host = @host
500
+ host = '127.0.0.1' if host.nil? || host.empty? || host == '0.0.0.0'
501
+ host = '::1' if host == '::'
502
+ [host, @port]
503
+ end
504
+
505
+ # Flip the C-side stop flag so the C accept loop (2.12-C / 2.12-D
506
+ # / 2.14-A variants) drops out at the next `accept(2)` return.
507
+ # Idempotent — flipping the flag twice is harmless. The C ext may
508
+ # be absent on JRuby / TruffleRuby; the `respond_to?` guard keeps
509
+ # those builds working.
510
+ def stop_c_accept_loop
511
+ pc = defined?(::Hyperion::Http::PageCache) ? ::Hyperion::Http::PageCache : nil
512
+ pc.stop_accept_loop if pc.respond_to?(:stop_accept_loop)
513
+ rescue StandardError
514
+ # Best-effort. Stop must never raise — it's called from a signal
515
+ # handler thread, where an unhandled exception would hang the
516
+ # whole worker.
517
+ nil
518
+ end
519
+
520
+ # Close + nil-out both listener references. The pre-2.14-B
521
+ # `close` is preserved as the primary signal for non-Linux
522
+ # platforms and as a belt-and-braces measure on Linux for the
523
+ # case where the wake-connect raced ahead of us.
524
+ def close_listeners
375
525
  @server&.close
526
+ rescue IOError, Errno::EBADF
527
+ # Listener already closed — `stop` was called twice or the
528
+ # C accept loop tore it down via its own error path.
529
+ nil
530
+ ensure
376
531
  @server = nil
377
532
  @tcp_server = nil
378
533
  end
379
534
 
535
+ public
536
+
380
537
  # 2.10-E — Walk every configured preload directory, populate
381
538
  # `Hyperion::Http::PageCache`, and mark every entry immutable when
382
539
  # asked. Called from `start` once per worker. Idempotent — second
@@ -460,6 +617,13 @@ module Hyperion
460
617
  pc.set_lifecycle_callback(ConnectionLoop.build_lifecycle_callback(@runtime))
461
618
  pc.set_lifecycle_active(@runtime.has_request_hooks?)
462
619
  pc.set_handoff_callback(ConnectionLoop.build_handoff_callback(self))
620
+
621
+ # 2.14-A — wire up the dynamic-block dispatch surface. Registers
622
+ # every `RouteTable::DynamicBlockEntry` with the C-side path
623
+ # registry and stashes the bound dispatch closure on the C loop
624
+ # so per-request hits can call back into Ruby with the right
625
+ # runtime context.
626
+ register_dynamic_blocks_with_c_loop(pc) if pc.respond_to?(:register_dynamic_block)
463
627
  # 2.12-E — register the per-worker request counter family on the
464
628
  # runtime's metrics sink BEFORE the C loop starts ticking. The
465
629
  # PrometheusExporter's C-loop fold-in is gated on the family
@@ -528,9 +692,45 @@ module Hyperion
528
692
  pc&.set_lifecycle_active(false) if defined?(pc)
529
693
  pc&.set_lifecycle_callback(nil) if defined?(pc)
530
694
  pc&.set_handoff_callback(nil) if defined?(pc)
695
+ # 2.14-A — also clear dynamic block registrations + dispatch
696
+ # callback so a re-engage with a different runtime / route
697
+ # table starts clean.
698
+ if defined?(pc) && pc.respond_to?(:clear_dynamic_blocks!)
699
+ pc.clear_dynamic_blocks!
700
+ pc.set_dynamic_dispatch_callback(nil)
701
+ end
531
702
  end
532
703
  private :run_c_accept_loop
533
704
 
705
+ # 2.14-A — Walk `@route_table` and push every `DynamicBlockEntry`
706
+ # into the C-side path registry. Also installs the dispatch
707
+ # callback that the C loop invokes per dynamic-block hit; the
708
+ # callback closes over `@runtime` so per-tenant Hyperion::Runtime
709
+ # observers see the right server's hooks fire.
710
+ def register_dynamic_blocks_with_c_loop(pc)
711
+ runtime = @runtime
712
+ pc.set_dynamic_dispatch_callback(
713
+ lambda do |method_str, path_str, query_str, host_str,
714
+ headers_blob, remote_addr, block, keep_alive|
715
+ ::Hyperion::Adapter::Rack.dispatch_for_c_loop(
716
+ method_str, path_str, query_str, host_str,
717
+ headers_blob, remote_addr, block, keep_alive, runtime
718
+ )
719
+ end
720
+ )
721
+ pc.clear_dynamic_blocks!
722
+ @route_table.instance_variable_get(:@routes).each do |method_sym, path_table|
723
+ next unless %i[GET HEAD].include?(method_sym)
724
+
725
+ path_table.each do |path, handler|
726
+ next unless handler.is_a?(::Hyperion::Server::RouteTable::DynamicBlockEntry)
727
+
728
+ pc.register_dynamic_block(path, method_sym, handler.block)
729
+ end
730
+ end
731
+ end
732
+ private :register_dynamic_blocks_with_c_loop
733
+
534
734
  # Dispatch a connection that the C accept loop handed off to Ruby
535
735
  # because it couldn't be served from the static cache (path miss,
536
736
  # malformed request, body present, h2 upgrade requested, etc.).
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hyperion
4
- VERSION = '2.13.0'
4
+ VERSION = '2.14.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.13.0
4
+ version: 2.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrey Lobanov