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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +604 -0
- data/README.md +301 -792
- data/ext/hyperion_http/page_cache.c +538 -43
- data/lib/hyperion/adapter/rack.rb +285 -0
- data/lib/hyperion/server/connection_loop.rb +104 -6
- data/lib/hyperion/server/route_table.rb +64 -0
- data/lib/hyperion/server.rb +202 -2
- data/lib/hyperion/version.rb +1 -1
- metadata +1 -1
data/lib/hyperion/server.rb
CHANGED
|
@@ -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
|
-
|
|
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.).
|
data/lib/hyperion/version.rb
CHANGED