hyperion-rb 1.3.1 → 1.4.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 154fbd1bc72c4eeb5e0c740354ced74c6fa8ee4295fab8e8551ae83f78313c53
4
- data.tar.gz: 4eaea8250dc8315318152c4c54a16803cc8a7b81236ecaa5dad9adee1d1ab95b
3
+ metadata.gz: 64221d424c994e0757262dca6984cd2b65bf9ec3da6c85094daa717c716da906
4
+ data.tar.gz: 236188ff1777b49178bb4b2bfe75fbea9309ec6f5d56ec01329943dfa1afbb36
5
5
  SHA512:
6
- metadata.gz: bed63c053e0f6d24876cde01346809ce830e3548382aab430798ee60b7056f926609190064a38d4f39a0c56b43945f4cc9b1b57fe0c938f24f3e95f1a2e499da
7
- data.tar.gz: 79decaf41c5b5755e2af9e0ee5475fce3c74bade418dcd7032309a4fa9d9061388cf3757a8ca7191b373ada6c31d2b3eb2453827f569f86a9caeb7970ff27f48
6
+ metadata.gz: 9b196f8d046c828546f8f5fbac91e0300e8704a70f11b47096a9b0fb05caf2ec34f63e2f33768ab53b41413aaa4e957ac499aa4779ecd54b6a987deef7fd464e
7
+ data.tar.gz: cb3d64f757736bcbc2632492e24b0794a67c98bfdcae7a1e53d89f583cf2eecc404e060fd3ec10e441ddfdcd11273c41e0d46fa9b80be904b29a7718fe0258ba
data/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.4.1] - 2026-04-27
4
+
5
+ ### Fixed
6
+ - **`Hyperion::Metrics` fiber-key bug** — pre-1.4.1 the metrics module stored counters via `Thread.current[:key]`, which is FIBER-local in Ruby 1.9+. Under an `Async::Scheduler` (TLS / h2 / `--async-io` plain HTTP/1.1) every handler fiber got its own private counters Hash that `Hyperion.stats` could never see — increments were stranded, the dispatch counters and `:bytes_written` etc. read as zero from any non-handler-fiber observer (including the Prometheus `/-/metrics` exporter when scraped from a different fiber). Switched to `Thread#thread_variable_*` (truly thread-local across fibers) plus direct counter-Hash list storage so snapshots also survive thread death. Verified via 4 new specs: cross-fiber on same thread, cross-thread, cross-fiber-on-different-thread, many-fibers-on-same-thread (210 increments aggregated correctly). Surfaced by hyperion-async-pg 0.4.0's bench round, which couldn't read `:requests_async_dispatched` from spec assertions even though the increments were firing.
7
+
8
+ ## [1.4.0] - 2026-04-27
9
+
10
+ Default-behaviour change for TLS users: HTTP/1.1-over-TLS now dispatches inline on the calling fiber instead of hopping through the worker thread pool. Fiber-cooperative libraries (`hyperion-async-pg`, `async-redis`) work on the TLS h1 path without `--async-io`. No code-path changes for plain HTTP/1.1 default behaviour.
11
+
12
+ ### Changed
13
+ - **TLS h1 inline dispatch by default** — `Hyperion::Server#dispatch` now serves HTTP/1.1-over-TLS inline on the accept-loop fiber under `Async::Scheduler`. Rationale: the TLS path already wraps the accept loop in `Async {}` for ALPN handshake + h2 streams; handing the post-handshake socket to a worker thread strips that scheduler context for no perf benefit (the Async-loop cost is already paid) and defeats fiber-cooperative I/O on TLS. Operators no longer need to pair `--tls-cert/--tls-key` with `--async-io` to get `hyperion-async-pg` working on TLS — it just works.
14
+ - **`async_io` config is now three-way** — was Boolean (`true` / `false`, default `false`). Now `nil` (default, "auto" — pool on plain HTTP/1.1, inline on TLS h1), `true` (force inline-on-fiber everywhere — required for `hyperion-async-pg` on plain HTTP/1.1), `false` (force pool hop everywhere — explicit opt-out for the rare operator who wants TLS+threadpool, e.g. CPU-bound synchronous handlers competing for OS threads).
15
+ - **Server / Worker constructor defaults** — `Hyperion::Server#initialize` and `Hyperion::Worker#initialize` now default `async_io: nil`. `Hyperion::Config::DEFAULTS[:async_io]` is `nil`.
16
+
17
+ ### Migration
18
+ - **Most users want the new default and should do nothing.** Wait-bound TLS workloads paired with fiber-cooperative I/O libraries (async-pg, async-redis) are now strictly faster on TLS — no flag flip required.
19
+ - **CPU-bound TLS handlers that want true OS-thread parallelism** (synchronous Rack handlers holding a global mutex, no Async-aware libraries in the stack) should set `async_io false` in their `config/hyperion.rb` (or pass `async_io: false` to `Server.new`). This restores the 1.3.x pool-hop behaviour for TLS h1.
20
+ - The plain HTTP/1.1 default path is unchanged: still pool dispatch, still the raw-loop perf-bypass; `--async-io` / `async_io: true` semantics for plain HTTP/1.1 are unchanged.
21
+
22
+ ### Added
23
+ - **`spec/hyperion/server_tls_dispatch_spec.rb`** — three new examples covering the matrix (nil + TLS → inline; false + TLS → pool; true + TLS → inline). Behavioural assertions verify `Fiber.scheduler` presence and which OS thread ran the handler (accept-loop vs pool worker).
24
+ - **README** — TLS + async-pg note rewritten for 1.4.0; config-DSL example block now documents the three-way `async_io` setting.
25
+
26
+ ### Fixed
27
+ - N/A — pure default-behaviour change with explicit opt-out.
28
+
3
29
  ## [1.3.1] - 2026-04-27
4
30
 
5
31
  Documentation + observability follow-ups for the 1.3.0 `--async-io` feature. No behaviour changes to existing code paths.
data/README.md CHANGED
@@ -95,16 +95,23 @@ Ubuntu 24.04 / 16 vCPU / Ruby 3.3.3, Postgres 17 over WAN, `wrk -t4 -c200 -d20s`
95
95
  1. **Linear scaling with pool size** under `--async-io` — `r/s ≈ pool × 12` on this WAN bench. Single-worker pool=200 hits 2381 r/s, **42× Puma `-t 5`** and **5.9× Puma's best** (`-t 30`).
96
96
  2. **Mixed workload doesn't kill the win** — Hyperion `--async-io` pool=128 actually goes *up* on mixed (1740 vs 1344 r/s) because CPU work overlaps other fibers' PG-wait windows. This is the honest "what happens to a real Rails handler" answer.
97
97
  3. **Hyperion ≈ Falcon within 3-7%** across pool sizes; both fiber-native architectures extract similar value from `hyperion-async-pg`.
98
- 4. **RSS at single-worker scale isn't the architectural moat** — Linux thread stacks are demand-paged; PG connection buffers dominate RSS at pool sizes ≤ 200. The MB-vs-GB story shows up at **idle keep-alive connection scale** (10k+ conns), not in this PG-bound throughput bench. See [Concurrency at scale](#concurrency-at-scale-architectural-advantages) for the connection-count win.
98
+ 4. **RSS at single-worker scale isn't the architectural moat** — Linux thread stacks are demand-paged; PG connection buffers dominate RSS at pool sizes ≤ 200. The architectural win is **handler concurrency under load**, not idle memory: Hyperion's fiber path runs thousands of in-flight handler invocations per OS thread, so wait-bound handlers don't queue at `max_threads`. See [Concurrency at scale](#concurrency-at-scale-architectural-advantages) for both the throughput-under-load row and a measured 10k-idle-keepalive RSS sweep against Puma and Falcon.
99
99
  5. **`-w 4` cold-start caveat** — multi-worker p99 inflates because the bench rackup uses lazy per-process pool init (each worker pays full pool fill on its first request). Production apps avoid this with `on_worker_boot { Hyperion::AsyncPg::FiberPool.new(...).fill }`.
100
100
 
101
101
  Three things must all be true to get this win:
102
102
  1. **`async_io: true`** in your Hyperion config (or `--async-io` CLI flag). Default is off to keep 1.2.0's raw-loop perf for fiber-unaware apps.
103
103
  2. **`hyperion-async-pg`** installed: `gem 'hyperion-async-pg', require: 'hyperion/async_pg'` + `Hyperion::AsyncPg.install!` at boot.
104
- 3. **Fiber-aware connection pool.** The popular `connection_pool` gem is NOT — its Mutex blocks the OS thread. Use [`async-pool`](https://github.com/socketry/async-pool), `Async::Semaphore`, or hand-roll one (see `bench/pg_concurrent.ru` for a ~30-line FiberPool example).
104
+ 3. **Fiber-aware connection pool.** The popular `connection_pool` gem is NOT — its Mutex blocks the OS thread. Use `Hyperion::AsyncPg::FiberPool` (ships with hyperion-async-pg 0.3.0+), [`async-pool`](https://github.com/socketry/async-pool), or `Async::Semaphore`.
105
105
 
106
106
  Skip any of these and you get parity with Puma at the same `-t`. Run the bench yourself: `MODE=async DATABASE_URL=... PG_POOL_SIZE=200 bundle exec hyperion --async-io -t 5 bench/pg_concurrent.ru` (in the [hyperion-async-pg](https://github.com/andrew-woblavobla/hyperion-async-pg) repo).
107
107
 
108
+ > **TLS + async-pg note (1.4.0+).** TLS / HTTPS already runs each connection on a fiber under `Async::Scheduler` (the TLS path always uses `start_async_loop` for the ALPN handshake). **As of 1.4.0, the post-handshake `app.call` for HTTP/1.1-over-TLS dispatches inline on the calling fiber by default** — so fiber-cooperative libraries (`hyperion-async-pg`, `async-redis`) work on the TLS h1 path without needing `--async-io`. The Async-loop cost is already paid for the handshake; running the handler under the existing scheduler just preserves that context instead of stripping it on a thread-pool hop. h2 streams are always fiber-dispatched and benefit from async-pg without the flag.
109
+ >
110
+ > Operators who specifically want **TLS + threadpool dispatch** (e.g. CPU-heavy handlers competing for OS threads, where you'd rather not pay fiber yields and want true OS-thread parallelism on a synchronous handler) can pass `async_io: false` in the config to force the pool branch back on. The three-way `async_io` setting:
111
+ > - `nil` (default): plain HTTP/1.1 → pool, TLS h1 → inline.
112
+ > - `true`: plain HTTP/1.1 → inline, TLS h1 → inline (force fiber dispatch everywhere; needed for `hyperion-async-pg` on plain HTTP).
113
+ > - `false`: plain HTTP/1.1 → pool, TLS h1 → pool (explicit opt-out for TLS+threadpool).
114
+
108
115
  ### CPU-bound JSON workload
109
116
 
110
117
  `bench/work.ru` — handler builds a 50-key fixture, JSON-encodes a fresh response per request (~8 KB body), processes a 6-cookie header chain. wrk `-t4 -c200 -d15s`, macOS arm64 / Ruby 3.3.3, 1.2.0:
@@ -169,7 +176,21 @@ These workloads demonstrate structural differences between Hyperion's fiber-per-
169
176
  | Hyperion `-w 1 -t 10` | 93,090 | 6,910 | 3,446 | 27.01 s |
170
177
  | Puma `-w 1 -t 10:10` | 77,340 | 22,660 | 706 | 109.59 s |
171
178
 
172
- Hyperion holds each connection in a ~1 KB fiber stack; Puma needs an OS thread (~1–8 MB each, capped at `max_threads`). At 10k concurrent connections Hyperion serves **~5× the throughput** of Puma with **~20% fewer dropped requests**, while the per-connection bookkeeping cost is bounded by fiber size, not by `max_threads`.
179
+ At 10k concurrent connections under load Hyperion serves **~5× the throughput** of Puma with **~20% fewer dropped requests**. The per-connection bookkeeping cost is bounded by fiber size, not by `max_threads` — workers don't get pinned to long-lived sockets, so a slow handler doesn't starve other connections.
180
+
181
+ **Memory at idle keep-alive scale — 10,000 idle HTTP/1.1 keep-alive connections:**
182
+
183
+ Each client opens a TCP connection, sends one keep-alive GET, drains the response, then holds the socket open without sending a follow-up request. RSS is sampled once a second across a 30s idle hold. Same hello-world rackup, single worker, no TLS. Hyperion runs with `async_io true` (fiber-per-connection on the plain HTTP/1.1 path).
184
+
185
+ | | held | dropped | peak RSS | RSS after drain |
186
+ |---|---:|---:|---:|---:|
187
+ | Hyperion `-w 1 -t 5 --async-io` | 10,000 / 10,000 | 0 | 173 MB | 155 MB |
188
+ | Puma `-w 0 -t 100` | 10,000 / 10,000 | 0 | 101 MB | 104 MB |
189
+ | Falcon `--count 1` | 10,000 / 10,000 | 0 | 429 MB | 440 MB |
190
+
191
+ All three hold 10k idle conns without OOMing or dropping — the "MB-per-thread" intuition that thread-based servers can't reach this scale doesn't survive contact with Linux's demand-paged thread stacks plus Puma's reactor-based keep-alive handling. Per-conn RSS lands at ~14 KB (Hyperion fiber + parser state), ~7 KB (Puma reactor entry + tiny thread share), ~36 KB (Falcon Async::Task + protocol-http stack). Bounded, not unbounded — for all three.
192
+
193
+ The architectural difference shows up under **load**, not at idle: Puma can only run `max_threads` handler invocations concurrently, so wait-bound handlers (DB, HTTP, Redis) starve at higher request concurrency than `max_threads`. Hyperion's fiber-per-connection model + `--async-io` gives one OS thread thousands of in-flight handler executions, paired with [hyperion-async-pg](https://github.com/exodusgaming-io/hyperion-async-pg) for non-blocking DB. The 10k-conn throughput row above (5× Puma) is the consequence — same idle RSS shape, very different behaviour once the handlers actually do work.
173
194
 
174
195
  **HTTP/2 multiplexing — 1 connection × 100 concurrent streams (handler sleeps 50 ms):**
175
196
 
@@ -187,6 +208,9 @@ Hyperion fans 100 in-flight streams across separate fibers within a single TCP c
187
208
  bundle exec ruby bench/compare.rb
188
209
  HYPERION_WORKERS=4 PUMA_WORKERS=4 FALCON_COUNT=4 bundle exec ruby bench/compare.rb
189
210
 
211
+ # Idle keep-alive RSS sweep (1k / 5k / 10k conns, 30s hold per server)
212
+ ./bench/keepalive_memory.sh
213
+
190
214
  # Real Rails / Grape: see bench/db.ru for the schema
191
215
  ```
192
216
 
@@ -264,7 +288,7 @@ log_requests true
264
288
 
265
289
  fiber_local_shim false
266
290
 
267
- async_io false # When true, the plain HTTP/1.1 accept loop runs each connection on a fiber under Async::Scheduler instead of handing it to a worker thread. Required for fiber-cooperative I/O (e.g. hyperion-async-pg). ~5% throughput hit on hello-world; in exchange one OS thread serves N concurrent in-flight DB queries on wait-bound workloads. TLS / HTTP/2 paths always use the async loop and ignore this flag.
291
+ async_io nil # Three-way (1.4.0+): nil (default, auto: inline-on-fiber for TLS h1, pool hop for plain HTTP/1.1), true (force inline-on-fiber everywhere required for hyperion-async-pg on plain HTTP/1.1), false (force pool hop everywhere explicit opt-out for TLS+threadpool with CPU-heavy handlers). ~5% throughput hit on hello-world when inline; in exchange one OS thread serves N concurrent in-flight DB queries on wait-bound workloads. TLS / HTTP/2 accept loops always run under Async::Scheduler regardless of this flag.
268
292
 
269
293
  before_fork do
270
294
  ActiveRecord::Base.connection_handler.clear_all_connections! if defined?(ActiveRecord)
@@ -31,7 +31,7 @@ module Hyperion
31
31
  admin_token: nil, # String. When set, exposes admin endpoints (POST /-/quit triggers graceful drain; GET /-/metrics returns Prometheus-format Hyperion.stats). Same token guards both. nil disables admin entirely (paths fall through to the app).
32
32
  max_pending: nil, # Integer, e.g. 256. When the per-worker accept inbox has this many queued connections, additional accepts are rejected with HTTP 503 + Retry-After:1 instead of being queued. nil disables (current behaviour: unbounded queue).
33
33
  max_request_read_seconds: 60, # Numeric. Total wallclock budget (seconds) for reading the request line + headers + body for ONE request. Defends against slowloris-style drips that satisfy the per-recv read_timeout but never finish the request. Resets between requests on a keep-alive connection. nil disables.
34
- async_io: false, # When true, the plain HTTP/1.1 accept loop runs each connection on a fiber under Async::Scheduler instead of handing it to a worker thread. Required for fiber-cooperative I/O (e.g. hyperion-async-pg). Costs ~5% throughput on hello-world; in exchange one OS thread can serve N concurrent in-flight DB queries on wait-bound workloads. TLS / HTTP/2 paths always use the async loop and ignore this flag.
34
+ async_io: nil, # Three-way: nil (default, auto: inline on TLS h1 / pool on plain HTTP/1.1), true (force inline-on-fiber for plain HTTP/1.1 too required for fiber-cooperative I/O like hyperion-async-pg on plain HTTP), false (force pool hop everywhere — explicit opt-out for operators who specifically want TLS+threadpool with CPU-bound handlers). Costs ~5% throughput on hello-world when inline; in exchange one OS thread can serve N concurrent in-flight DB queries on wait-bound workloads. TLS / HTTP/2 paths always run the Async accept loop regardless of this flag.
35
35
  h2_max_concurrent_streams: 128, # HTTP/2 SETTINGS_MAX_CONCURRENT_STREAMS — cap on simultaneously-open streams per connection. Falcon: 64. nil leaves protocol-http2 default (0xFFFFFFFF).
36
36
  h2_initial_window_size: 1_048_576, # HTTP/2 SETTINGS_INITIAL_WINDOW_SIZE (octets) — flow-control window per stream at open. Bigger = fewer WINDOW_UPDATE round-trips on large bodies. Spec default is 65535. nil → leave protocol default.
37
37
  h2_max_frame_size: 1_048_576, # HTTP/2 SETTINGS_MAX_FRAME_SIZE (octets) — biggest DATA/HEADERS frame we'll accept. Spec floor 16384, ceiling 16777215. We pick 1 MiB to match common CDNs without unbounded buffer growth. nil → leave protocol default (16384).
@@ -7,6 +7,22 @@ module Hyperion
7
7
  # all threads that have ever incremented (one short mutex section, only
8
8
  # taken when the operator asks for stats).
9
9
  #
10
+ # Storage: counters live behind `Thread#thread_variable_*`, which is the
11
+ # only TRUE thread-local in Ruby 1.9+ — `Thread.current[:key]` is in fact
12
+ # FIBER-local, so under an `Async::Scheduler` (TLS path, h2 streams, the
13
+ # 1.3.0+ `--async-io` plain HTTP/1.1 path) every handler fiber would get
14
+ # its own private counters Hash that `snapshot` could never find.
15
+ # Verified with hyperion-async-pg 0.4.0's bench round; before the fix
16
+ # the dispatch counters dropped requests entirely under `--async-io` and
17
+ # an external scrape (Prometheus exporter on a different fiber than the
18
+ # handler) saw the dispatch buckets at zero.
19
+ #
20
+ # Cross-fiber races on the same OS thread: the `+=` is technically read-
21
+ # modify-write, but Ruby's fiber scheduler only preempts at IO boundaries
22
+ # (Fiber.scheduler-aware system calls), and `Hash#[]=` is purely Ruby —
23
+ # no preemption mid-increment, no torn writes. Two fibers cannot
24
+ # interleave a single `+=` on the same OS thread.
25
+ #
10
26
  # Reset semantics: counters monotonically increase. Operators that want
11
27
  # rate-of-change should snapshot, sleep, snapshot, diff.
12
28
  #
@@ -14,16 +30,40 @@ module Hyperion
14
30
  # Hyperion.stats -> Hash with all current values across all threads.
15
31
  class Metrics
16
32
  def initialize
17
- @threads = Set.new
18
- @threads_mutex = Mutex.new
19
- # Each Metrics instance has its own thread-local key so spec runs that
20
- # build fresh Metrics objects don't share state across examples.
33
+ # Direct list of every per-thread counters Hash ever allocated through
34
+ # this Metrics instance. We hold the Hash refs ourselves (instead of
35
+ # holding Thread refs and looking the Hash up via thread-local
36
+ # storage) so snapshot survives thread death counters from a
37
+ # short-lived worker that already exited still aggregate. Tiny per-
38
+ # thread footprint (one Hash + one slot in this Array).
39
+ @thread_counters = []
40
+ @counters_mutex = Mutex.new
41
+ # Per-instance thread-local key so spec runs that build fresh Metrics
42
+ # objects don't share state across examples.
21
43
  @thread_key = :"__hyperion_metrics_#{object_id}__"
22
44
  end
23
45
 
24
- # Hot path: one TLS lookup + one hash op. No mutex.
46
+ # Hot path: one thread-variable lookup + one hash op. No mutex on the
47
+ # increment fast path; the mutex is taken only on first allocation per
48
+ # OS thread (very rare) and on snapshot.
49
+ #
50
+ # Storage uses Thread#thread_variable_*, which is the only TRUE thread-
51
+ # local in Ruby 1.9+ — Thread.current[:key] is in fact FIBER-local, so
52
+ # under an Async::Scheduler (TLS path, h2 streams, the 1.3.0+ --async-io
53
+ # plain HTTP/1.1 path) every handler fiber would get its own private
54
+ # counters Hash that snapshot could never aggregate. Verified with
55
+ # hyperion-async-pg 0.4.0's bench round; before the fix the dispatch
56
+ # counters dropped requests under --async-io.
57
+ #
58
+ # Cross-fiber races on the same OS thread: the `+=` is read-modify-write,
59
+ # but Ruby's fiber scheduler only preempts at IO boundaries (Fiber-
60
+ # scheduler-aware system calls). Hash#[]= is purely Ruby — no
61
+ # preemption mid-increment, no torn writes. Two fibers cannot
62
+ # interleave a single `+=` on the same OS thread.
25
63
  def increment(key, by = 1)
26
- counters = Thread.current[@thread_key] ||= register_thread_counters
64
+ thread = Thread.current
65
+ counters = thread.thread_variable_get(@thread_key)
66
+ counters = register_thread_counters(thread) if counters.nil?
27
67
  counters[key] += by
28
68
  end
29
69
 
@@ -37,14 +77,9 @@ module Hyperion
37
77
 
38
78
  def snapshot
39
79
  result = Hash.new(0)
40
- @threads_mutex.synchronize do
41
- @threads.delete_if { |t| !t.alive? }
42
- @threads.each do |t|
43
- counters = t[@thread_key]
44
- next unless counters
45
-
46
- counters.each { |k, v| result[k] += v }
47
- end
80
+ counters_snapshot = @counters_mutex.synchronize { @thread_counters.dup }
81
+ counters_snapshot.each do |counters|
82
+ counters.each { |k, v| result[k] += v }
48
83
  end
49
84
  result.default = nil
50
85
  result
@@ -52,16 +87,17 @@ module Hyperion
52
87
 
53
88
  # Tests can call .reset! between examples to avoid cross-spec leakage.
54
89
  def reset!
55
- @threads_mutex.synchronize do
56
- @threads.each { |t| t[@thread_key]&.clear }
90
+ @counters_mutex.synchronize do
91
+ @thread_counters.each(&:clear)
57
92
  end
58
93
  end
59
94
 
60
95
  private
61
96
 
62
- def register_thread_counters
97
+ def register_thread_counters(thread)
63
98
  counters = Hash.new(0)
64
- @threads_mutex.synchronize { @threads << Thread.current }
99
+ thread.thread_variable_set(@thread_key, counters)
100
+ @counters_mutex.synchronize { @thread_counters << counters }
65
101
  counters
66
102
  end
67
103
  end
@@ -42,7 +42,7 @@ module Hyperion
42
42
 
43
43
  def initialize(app:, host: '127.0.0.1', port: 9292, read_timeout: DEFAULT_READ_TIMEOUT_SECONDS,
44
44
  tls: nil, thread_count: DEFAULT_THREAD_COUNT, max_pending: nil,
45
- max_request_read_seconds: 60, h2_settings: nil, async_io: false)
45
+ max_request_read_seconds: 60, h2_settings: nil, async_io: nil)
46
46
  @host = host
47
47
  @port = port
48
48
  @app = app
@@ -111,7 +111,10 @@ module Hyperion
111
111
  if @tls || @async_io
112
112
  # TLS path: ALPN may pick `h2`, and h2 spawns one fiber per stream
113
113
  # inside Http2Handler. Keep the Async wrapper so the scheduler is
114
- # available for those fibers and for handshake yields.
114
+ # available for those fibers and for handshake yields. Plain
115
+ # HTTP/1.1-over-TLS dispatch is also handled inline on the calling
116
+ # fiber by default in 1.4.0+ (see #dispatch) — fiber-cooperative
117
+ # libraries (async-pg, async-redis) work without --async-io.
115
118
  #
116
119
  # async_io: true: operator opt-in for plain HTTP/1.1. The Async wrap
117
120
  # is required when callers want fiber cooperative I/O — e.g.
@@ -120,9 +123,10 @@ module Hyperion
120
123
  # OS thread can serve N concurrent in-flight DB queries instead of 1.
121
124
  start_async_loop
122
125
  else
123
- # Plain HTTP/1.1, async_io: false (default): the worker thread owns
124
- # each connection for its lifetime, so the Async wrapper adds zero
125
- # value (no fibers ever run on this loop's task). Skip it — pure
126
+ # Plain HTTP/1.1, async_io: nil (default with no TLS) or
127
+ # async_io: false (explicit opt-out): the worker thread owns each
128
+ # connection for its lifetime, so the Async wrapper adds zero value
129
+ # (no fibers ever run on this loop's task). Skip it — pure
126
130
  # IO.select + accept_nonblock shaves measurable overhead off the
127
131
  # accept hot path.
128
132
  start_raw_loop
@@ -187,19 +191,26 @@ module Hyperion
187
191
  # counters live inside Http2Handler; we don't bump either of the
188
192
  # H1 dispatch buckets here — neither fits the h2 model cleanly.
189
193
  Http2Handler.new(app: @app, thread_pool: @thread_pool, h2_settings: @h2_settings).serve(socket)
190
- elsif @async_io
191
- # async_io plain HTTP/1.1: serve inline on the calling fiber so the
192
- # request runs *under* Async::Scheduler. This is what makes
193
- # hyperion-async-pg (and other Async-aware libraries) actually
194
- # cooperate each fiber yields the OS thread on socket waits, so
195
- # one thread can serve N concurrent in-flight DB queries. The
196
- # thread pool is intentionally bypassed here: handing the socket
197
- # to a worker thread strips the scheduler context.
194
+ elsif inline_h1_dispatch?
195
+ # Inline-on-fiber HTTP/1.1 dispatch. Two ways to land here:
196
+ # 1. async_io: true operator explicitly opted into fiber I/O on
197
+ # the plain HTTP/1.1 path.
198
+ # 2. async_io: nil (default) AND TLS configured TLS already
199
+ # runs the Async accept loop for ALPN handshake + h2 streams,
200
+ # so the scheduler is current on this fiber. Handing the
201
+ # socket to a worker thread would strip the scheduler context
202
+ # for no perf benefit (we paid the Async-loop cost already)
203
+ # and would defeat hyperion-async-pg / async-redis on the
204
+ # TLS h1 path.
205
+ # Operators who specifically want TLS+threadpool (e.g. CPU-heavy
206
+ # handlers competing for OS threads) can pass async_io: false to
207
+ # force the pool branch below.
198
208
  Hyperion.metrics.increment(:requests_async_dispatched)
199
209
  Connection.new.serve(socket, @app, max_request_read_seconds: @max_request_read_seconds)
200
210
  elsif @thread_pool
201
- # HTTP/1.1 (e.g. TLS-wrapped after ALPN picked http/1.1): hand the
202
- # connection to a worker thread. The fiber that called dispatch
211
+ # HTTP/1.1 default plain-HTTP path, OR explicit async_io: false on
212
+ # TLS (operator opted out of inline-on-fiber dispatch). Hand the
213
+ # connection to a worker thread; the fiber that called dispatch
203
214
  # returns immediately. On overflow, reject with 503 + close.
204
215
  if @thread_pool.submit_connection(socket, @app,
205
216
  max_request_read_seconds: @max_request_read_seconds)
@@ -208,14 +219,29 @@ module Hyperion
208
219
  reject_connection(socket)
209
220
  end
210
221
  else
211
- # No pool (thread_count: 0) on the TLS / async-wrap path. Rare
212
- # config — neither dispatch bucket fits cleanly. Leave un-counted
213
- # rather than misclassify; the request still shows up in
214
- # :requests_total via Connection.
222
+ # No pool (thread_count: 0) on the TLS / async-wrap path with
223
+ # async_io: false. Rare config — neither dispatch bucket fits
224
+ # cleanly. Leave un-counted rather than misclassify; the request
225
+ # still shows up in :requests_total via Connection.
215
226
  Connection.new.serve(socket, @app, max_request_read_seconds: @max_request_read_seconds)
216
227
  end
217
228
  end
218
229
 
230
+ # Decide whether to serve HTTP/1.1 inline on the calling fiber instead
231
+ # of hopping through the worker thread pool. The matrix:
232
+ # async_io == true → inline always (plain h1 + TLS h1).
233
+ # async_io == nil + TLS → inline (TLS already runs Async loop, so
234
+ # the scheduler is current; preserve it).
235
+ # async_io == nil + plain → pool (pure HTTP/1.1 fast path; no scheduler).
236
+ # async_io == false → pool always (explicit opt-out).
237
+ def inline_h1_dispatch?
238
+ return true if @async_io == true
239
+ return false if @async_io == false
240
+
241
+ # @async_io.nil? — auto: inline on TLS, pool on plain.
242
+ !@tls.nil?
243
+ end
244
+
219
245
  # Backpressure rejection. Emits a pre-built 503 + closes the socket.
220
246
  # No Rack env, no app dispatch, no access-log line — the overload
221
247
  # path must stay cheap so we don't pile rejection cost on top of the
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hyperion
4
- VERSION = '1.3.1'
4
+ VERSION = '1.4.1'
5
5
  end
@@ -20,7 +20,7 @@ module Hyperion
20
20
  thread_count: Server::DEFAULT_THREAD_COUNT,
21
21
  config: nil, worker_index: 0, listener: nil,
22
22
  max_pending: nil, max_request_read_seconds: 60,
23
- h2_settings: nil, async_io: false)
23
+ h2_settings: nil, async_io: nil)
24
24
  @host = host
25
25
  @port = port
26
26
  @app = app
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: 1.3.1
4
+ version: 1.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrey Lobanov