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
|
@@ -151,6 +151,291 @@ module Hyperion
|
|
|
151
151
|
nil
|
|
152
152
|
end
|
|
153
153
|
|
|
154
|
+
# 2.14-A — C-accept-loop dispatch helper.
|
|
155
|
+
#
|
|
156
|
+
# The C accept loop (`PageCache.run_static_accept_loop`) calls
|
|
157
|
+
# this helper, under the GVL, when a request matches a
|
|
158
|
+
# `RouteTable::DynamicBlockEntry`. The C side has already done
|
|
159
|
+
# accept + recv + parse without holding the GVL; this helper
|
|
160
|
+
# owns the `app.call(env)` slice and returns the fully-formed
|
|
161
|
+
# HTTP/1.1 response bytes for C to write (also without the GVL).
|
|
162
|
+
#
|
|
163
|
+
# Args (all positional, all Strings except `block` and
|
|
164
|
+
# `keep_alive` and `runtime`):
|
|
165
|
+
# * `method_str` — e.g. "GET"
|
|
166
|
+
# * `path_str` — request path, no query
|
|
167
|
+
# * `query_str` — query (no leading '?'), or "" if none
|
|
168
|
+
# * `host_str` — `Host:` header value, or ""
|
|
169
|
+
# * `headers_blob` — raw header section as bytes
|
|
170
|
+
# (the slice between request-line CRLF and the closing
|
|
171
|
+
# CRLFCRLF, terminated by a CRLF on the last header). The
|
|
172
|
+
# helper parses this in Ruby — header parse is a few µs
|
|
173
|
+
# even for the 30-header case, dwarfed by `app.call`.
|
|
174
|
+
# * `remote_addr` — peer IP as a String, or "" if unknown
|
|
175
|
+
# * `block` — the registered Proc / lambda
|
|
176
|
+
# * `keep_alive` — true to emit `connection: keep-alive`
|
|
177
|
+
# in the response head, false for `connection: close`
|
|
178
|
+
# * `runtime` — the `Hyperion::Runtime` instance the
|
|
179
|
+
# server was constructed with (for lifecycle hooks); the
|
|
180
|
+
# C loop captures this once at boot via the registered
|
|
181
|
+
# callback closure
|
|
182
|
+
#
|
|
183
|
+
# Returns a single binary String of HTTP/1.1 response bytes
|
|
184
|
+
# (status line + response headers + CRLF + body). The C loop
|
|
185
|
+
# writes this verbatim. On exception, returns a 500 envelope
|
|
186
|
+
# so the C loop can still respond to the peer (better UX than
|
|
187
|
+
# closing the fd silently).
|
|
188
|
+
def dispatch_for_c_loop(method_str, path_str, query_str,
|
|
189
|
+
host_str, headers_blob, remote_addr,
|
|
190
|
+
block, keep_alive, runtime)
|
|
191
|
+
env, input = build_c_loop_env(method_str, path_str, query_str,
|
|
192
|
+
host_str, headers_blob, remote_addr)
|
|
193
|
+
request = nil
|
|
194
|
+
response = nil
|
|
195
|
+
error = nil
|
|
196
|
+
|
|
197
|
+
rt = runtime || Hyperion::Runtime.default
|
|
198
|
+
if rt.has_request_hooks?
|
|
199
|
+
request = c_loop_request_for(env)
|
|
200
|
+
rt.fire_request_start(request, env)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
begin
|
|
204
|
+
response = block.call(env)
|
|
205
|
+
rescue StandardError => e
|
|
206
|
+
error = e
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
if rt.has_request_hooks?
|
|
210
|
+
request ||= c_loop_request_for(env)
|
|
211
|
+
rt.fire_request_end(request, env, response, error)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
if error
|
|
215
|
+
::Hyperion.metrics.increment(:app_errors)
|
|
216
|
+
::Hyperion.logger.error do
|
|
217
|
+
{
|
|
218
|
+
message: 'app raised (c-accept-loop dispatch)',
|
|
219
|
+
error: error.message,
|
|
220
|
+
error_class: error.class.name,
|
|
221
|
+
backtrace: (error.backtrace || []).first(20).join(' | ')
|
|
222
|
+
}
|
|
223
|
+
end
|
|
224
|
+
response = [500, { 'content-type' => 'text/plain' }, ['Internal Server Error']]
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
render_c_loop_response(response, keep_alive)
|
|
228
|
+
ensure
|
|
229
|
+
ENV_POOL.release(env) if env
|
|
230
|
+
INPUT_POOL.release(input) if input
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# 2.14-A — assemble the Rack env for a C-accept-loop dispatch.
|
|
234
|
+
# Mirrors the constants `build_env` sets on the regular path
|
|
235
|
+
# but skips the `connection`/hijack branches: the C accept
|
|
236
|
+
# loop owns the fd; full-hijack semantics are out of scope
|
|
237
|
+
# for this dispatch shape (h1 keep-alive is handled in C).
|
|
238
|
+
# Returns `[env, input]` so the caller can release both back
|
|
239
|
+
# to their pools after the response is rendered.
|
|
240
|
+
def build_c_loop_env(method_str, path_str, query_str,
|
|
241
|
+
host_str, headers_blob, remote_addr)
|
|
242
|
+
server_name, server_port = split_host(host_str || '')
|
|
243
|
+
|
|
244
|
+
env = ENV_POOL.acquire
|
|
245
|
+
input = INPUT_POOL.acquire
|
|
246
|
+
input.string = EMPTY_INPUT_BUFFER
|
|
247
|
+
input.rewind
|
|
248
|
+
|
|
249
|
+
env['REQUEST_METHOD'] = method_str
|
|
250
|
+
env['PATH_INFO'] = path_str
|
|
251
|
+
env['QUERY_STRING'] = query_str || ''
|
|
252
|
+
env['SERVER_PROTOCOL'] = 'HTTP/1.1'
|
|
253
|
+
env['HTTP_VERSION'] = 'HTTP/1.1'
|
|
254
|
+
env['SERVER_NAME'] = server_name
|
|
255
|
+
env['SERVER_PORT'] = server_port
|
|
256
|
+
env['SERVER_SOFTWARE'] = SERVER_SOFTWARE_VALUE
|
|
257
|
+
env['REMOTE_ADDR'] = remote_addr.nil? || remote_addr.empty? ? '127.0.0.1' : remote_addr
|
|
258
|
+
env['rack.url_scheme'] = 'http'
|
|
259
|
+
env['rack.errors'] = $stderr
|
|
260
|
+
env['rack.version'] = RACK_VERSION
|
|
261
|
+
env['rack.multithread'] = true
|
|
262
|
+
env['rack.multiprocess'] = false
|
|
263
|
+
env['rack.run_once'] = false
|
|
264
|
+
env['rack.hijack?'] = false
|
|
265
|
+
env['SCRIPT_NAME'] = ''
|
|
266
|
+
env['rack.input'] = input
|
|
267
|
+
# 2.14-A — guarded `is_a?(String) && !empty?` (rather than
|
|
268
|
+
# `present?`) so rubocop-rails's Style/Present autocorrect
|
|
269
|
+
# can't rewrite the branch to a Rails-only API. Same pattern
|
|
270
|
+
# `Server#dispatch_handed_off` uses for `partial`.
|
|
271
|
+
env['HTTP_HOST'] = host_str if host_str.is_a?(String) && !host_str.empty?
|
|
272
|
+
|
|
273
|
+
parse_c_loop_headers!(env, headers_blob) if headers_blob.is_a?(String) && !headers_blob.empty?
|
|
274
|
+
|
|
275
|
+
[env, input]
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# 2.14-A — parse the raw header block the C accept loop hands
|
|
279
|
+
# us into the env hash. Each line is `name: value\r\n`; the
|
|
280
|
+
# final empty line is already trimmed by the caller (the C
|
|
281
|
+
# loop slices between request-line-end and the closing
|
|
282
|
+
# CRLFCRLF and passes the inner bytes verbatim).
|
|
283
|
+
#
|
|
284
|
+
# We honour the same HTTP_KEY_CACHE the regular adapter path
|
|
285
|
+
# uses, so `equal?` pointer-compares from upstream Rack code
|
|
286
|
+
# (Rack::Attack et al.) keep working.
|
|
287
|
+
def parse_c_loop_headers!(env, headers_blob)
|
|
288
|
+
return if headers_blob.empty?
|
|
289
|
+
|
|
290
|
+
c_upcase = c_upcase_available?
|
|
291
|
+
# The Ruby parser walks line-by-line; allocations are 1
|
|
292
|
+
# String per header (the value). Header names go through
|
|
293
|
+
# the cache hit (no alloc) or the C-ext upcase_underscore
|
|
294
|
+
# (single-call alloc).
|
|
295
|
+
start = 0
|
|
296
|
+
blen = headers_blob.bytesize
|
|
297
|
+
while start < blen
|
|
298
|
+
eol = headers_blob.index("\r\n", start) || blen
|
|
299
|
+
line = headers_blob.byteslice(start, eol - start)
|
|
300
|
+
start = eol + 2
|
|
301
|
+
next if line.empty?
|
|
302
|
+
|
|
303
|
+
colon = line.index(':')
|
|
304
|
+
next unless colon
|
|
305
|
+
|
|
306
|
+
name = line.byteslice(0, colon).downcase
|
|
307
|
+
# Skip the colon, then any leading whitespace.
|
|
308
|
+
v_start = colon + 1
|
|
309
|
+
v_start += 1 while v_start < line.bytesize && [32, 9].include?(line.getbyte(v_start))
|
|
310
|
+
v_end = line.bytesize
|
|
311
|
+
v_end -= 1 while v_end > v_start && [32, 9].include?(line.getbyte(v_end - 1))
|
|
312
|
+
value = line.byteslice(v_start, v_end - v_start)
|
|
313
|
+
|
|
314
|
+
key = HTTP_KEY_CACHE[name] ||
|
|
315
|
+
(c_upcase ? ::Hyperion::CParser.upcase_underscore(name) : "HTTP_#{name.upcase.tr('-', '_')}")
|
|
316
|
+
env[key] = value
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
env['CONTENT_TYPE'] = env['HTTP_CONTENT_TYPE'] if env.key?('HTTP_CONTENT_TYPE')
|
|
320
|
+
env['CONTENT_LENGTH'] = env['HTTP_CONTENT_LENGTH'] if env.key?('HTTP_CONTENT_LENGTH')
|
|
321
|
+
nil
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# 2.14-A — minimal `Hyperion::Request` value for lifecycle
|
|
325
|
+
# hook observers. Only built when hooks are active (the
|
|
326
|
+
# `has_request_hooks?` guard skips the alloc on the no-hook
|
|
327
|
+
# hot path).
|
|
328
|
+
def c_loop_request_for(env)
|
|
329
|
+
::Hyperion::Request.new(
|
|
330
|
+
method: env['REQUEST_METHOD'],
|
|
331
|
+
path: env['PATH_INFO'],
|
|
332
|
+
query_string: env['QUERY_STRING'],
|
|
333
|
+
http_version: 'HTTP/1.1',
|
|
334
|
+
headers: {},
|
|
335
|
+
body: nil
|
|
336
|
+
)
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# 2.14-A — render a Rack `[status, headers, body]` triple to
|
|
340
|
+
# the wire bytes for the C loop. Honours:
|
|
341
|
+
# * `keep_alive` — emit `connection: keep-alive` vs
|
|
342
|
+
# `connection: close`. The C loop honours the
|
|
343
|
+
# `connection: close` request header by passing
|
|
344
|
+
# `keep_alive=false`; ditto on Rack apps that opt in via
|
|
345
|
+
# the response header.
|
|
346
|
+
# * `content-length` — auto-computed from the body bytes
|
|
347
|
+
# unless the app set it explicitly. Required for
|
|
348
|
+
# keep-alive correctness.
|
|
349
|
+
# * `body.each` — collected into a single binary blob; the
|
|
350
|
+
# C loop writes head + body in one syscall.
|
|
351
|
+
# * `body.close` — invoked after iteration per Rack spec.
|
|
352
|
+
#
|
|
353
|
+
# Streaming bodies (Rack 3 `body.call(stream)` shape) are NOT
|
|
354
|
+
# supported in the C-loop dispatch. Apps that need streaming
|
|
355
|
+
# must register via the legacy `Connection#serve` path
|
|
356
|
+
# (don't use the block form of `Server.handle`); the
|
|
357
|
+
# `eligible_route_table?` check refuses to engage the C loop
|
|
358
|
+
# for tables containing those handlers.
|
|
359
|
+
def render_c_loop_response(response, keep_alive)
|
|
360
|
+
unless response.is_a?(Array) && response.length == 3
|
|
361
|
+
response = [500, { 'content-type' => 'text/plain' }, ['Invalid Rack response']]
|
|
362
|
+
end
|
|
363
|
+
status, headers, body = response
|
|
364
|
+
|
|
365
|
+
body_bytes = collect_body_bytes(body)
|
|
366
|
+
headers_out = normalize_response_headers(headers, body_bytes.bytesize, keep_alive)
|
|
367
|
+
head = build_status_line(status) + headers_out + "\r\n"
|
|
368
|
+
|
|
369
|
+
buf = String.new(capacity: head.bytesize + body_bytes.bytesize, encoding: Encoding::ASCII_8BIT)
|
|
370
|
+
buf << head.b << body_bytes
|
|
371
|
+
buf
|
|
372
|
+
ensure
|
|
373
|
+
begin
|
|
374
|
+
body.close if body.respond_to?(:close)
|
|
375
|
+
rescue StandardError
|
|
376
|
+
nil
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# 2.14-A — drain a Rack body into a single binary blob.
|
|
381
|
+
# Honours both Array bodies (the common case — `[body_str]`)
|
|
382
|
+
# and `each`-yielding bodies. Rack 3 streaming bodies (the
|
|
383
|
+
# `call(stream)` variant) raise here; the eligibility check
|
|
384
|
+
# is supposed to refuse them at registration time.
|
|
385
|
+
def collect_body_bytes(body)
|
|
386
|
+
return body[0].b if body.is_a?(Array) && body.length == 1 && body[0].is_a?(String)
|
|
387
|
+
|
|
388
|
+
buf = String.new(encoding: Encoding::ASCII_8BIT)
|
|
389
|
+
body.each { |chunk| buf << chunk.to_s.b } if body.respond_to?(:each)
|
|
390
|
+
buf
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# 2.14-A — Build the response header lines including
|
|
394
|
+
# `content-length`, `connection`, and `server`. Skips any
|
|
395
|
+
# header named `connection`/`content-length`/`transfer-encoding`
|
|
396
|
+
# the app set (we own those in the C-loop path).
|
|
397
|
+
def normalize_response_headers(headers, body_len, keep_alive)
|
|
398
|
+
out = String.new(encoding: Encoding::ASCII_8BIT)
|
|
399
|
+
if headers.is_a?(Hash)
|
|
400
|
+
headers.each do |name, value|
|
|
401
|
+
ln = name.to_s.downcase
|
|
402
|
+
next if %w[connection content-length transfer-encoding].include?(ln)
|
|
403
|
+
|
|
404
|
+
# Multi-value headers (Rack 3: Array of values, or
|
|
405
|
+
# newline-joined String) — emit one line per value.
|
|
406
|
+
vals = value.is_a?(Array) ? value : value.to_s.split("\n")
|
|
407
|
+
vals.each do |v|
|
|
408
|
+
out << ln << ': ' << v.to_s << "\r\n"
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
out << 'content-length: ' << body_len.to_s << "\r\n"
|
|
413
|
+
out << (keep_alive ? "connection: keep-alive\r\n" : "connection: close\r\n")
|
|
414
|
+
out
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# 2.14-A — minimal status line builder. Covers the canonical
|
|
418
|
+
# 200/201/204/301/302/304/400/401/403/404/500 by name; everything
|
|
419
|
+
# else falls back to a generic reason phrase since the Rack
|
|
420
|
+
# body still wins at the protocol level.
|
|
421
|
+
STATUS_LINES = {
|
|
422
|
+
200 => "HTTP/1.1 200 OK\r\n",
|
|
423
|
+
201 => "HTTP/1.1 201 Created\r\n",
|
|
424
|
+
204 => "HTTP/1.1 204 No Content\r\n",
|
|
425
|
+
301 => "HTTP/1.1 301 Moved Permanently\r\n",
|
|
426
|
+
302 => "HTTP/1.1 302 Found\r\n",
|
|
427
|
+
304 => "HTTP/1.1 304 Not Modified\r\n",
|
|
428
|
+
400 => "HTTP/1.1 400 Bad Request\r\n",
|
|
429
|
+
401 => "HTTP/1.1 401 Unauthorized\r\n",
|
|
430
|
+
403 => "HTTP/1.1 403 Forbidden\r\n",
|
|
431
|
+
404 => "HTTP/1.1 404 Not Found\r\n",
|
|
432
|
+
500 => "HTTP/1.1 500 Internal Server Error\r\n"
|
|
433
|
+
}.freeze
|
|
434
|
+
|
|
435
|
+
def build_status_line(status)
|
|
436
|
+
STATUS_LINES[status] || "HTTP/1.1 #{status} OK\r\n"
|
|
437
|
+
end
|
|
438
|
+
|
|
154
439
|
# 2.1.0 (WS-1): `connection:` is the Hyperion::Connection that owns
|
|
155
440
|
# the underlying socket for this request. When non-nil, the env hash
|
|
156
441
|
# advertises Rack 3 full-hijack support — the app can call
|
|
@@ -34,6 +34,92 @@ module Hyperion
|
|
|
34
34
|
module ConnectionLoop
|
|
35
35
|
module_function
|
|
36
36
|
|
|
37
|
+
# 2.14-B — bound applied to the wake-connect dial inside
|
|
38
|
+
# `Server#stop`. The listener is local — a successful connect
|
|
39
|
+
# is sub-millisecond — so the cap exists purely as a sanity
|
|
40
|
+
# bound for the pathological case where the listener was
|
|
41
|
+
# already torn down (Errno::ECONNREFUSED is fast) or the
|
|
42
|
+
# kernel netstack is somehow stuck (e.g. CI under heavy load).
|
|
43
|
+
WAKE_CONNECT_TIMEOUT_SECONDS = 1.0
|
|
44
|
+
|
|
45
|
+
# 2.14-B — number of wake-connect dials issued per `Server#stop`.
|
|
46
|
+
# In single-server / `:share` cluster mode (Darwin/BSD), one dial
|
|
47
|
+
# is enough — the listener is shared and any wake races to a
|
|
48
|
+
# parked accept call. In `:reuseport` cluster mode (Linux), the
|
|
49
|
+
# kernel hashes incoming SYNs across each worker's per-process
|
|
50
|
+
# listener fd; one dial may hash to a sibling whose stop hasn't
|
|
51
|
+
# progressed, leaving THIS worker's accept thread parked. K=8
|
|
52
|
+
# drops the miss probability to <1% for realistic worker counts
|
|
53
|
+
# (≤32 workers per host) and adds at most ~8ms to a stop call —
|
|
54
|
+
# well below the master-side `graceful_timeout` (30s default).
|
|
55
|
+
WAKE_CONNECT_BURST = 8
|
|
56
|
+
|
|
57
|
+
# 2.14-B — Wake any thread parked in `accept(2)` on the listener
|
|
58
|
+
# bound at `host:port` by dialing one (or `count`) throwaway TCP
|
|
59
|
+
# connections.
|
|
60
|
+
#
|
|
61
|
+
# Background. On Linux ≥ 6.x, calling `close()` on a listening
|
|
62
|
+
# socket from one thread does NOT interrupt another thread that
|
|
63
|
+
# is currently blocked in `accept(2)` on that same fd — the
|
|
64
|
+
# kernel silently dropped the close-wake guarantee that
|
|
65
|
+
# `Server#stop` (and 2.13-C's spec teardown) had relied on.
|
|
66
|
+
# Without this helper, the C accept loop stays parked until a
|
|
67
|
+
# real connection arrives, which during a SIGTERM-driven graceful
|
|
68
|
+
# shutdown means "until SIGKILL".
|
|
69
|
+
#
|
|
70
|
+
# The fix is structural: dial a throwaway TCP connection at the
|
|
71
|
+
# listener's bound address. The accept call returns with the new
|
|
72
|
+
# fd, the C loop services it (a 0-byte read drops it), then
|
|
73
|
+
# re-checks `hyp_cl_stop` between accepts and exits cleanly. The
|
|
74
|
+
# 2.13-C connection_loop_spec helper does the same thing in spec
|
|
75
|
+
# land — this is the production-side mirror.
|
|
76
|
+
#
|
|
77
|
+
# Burst semantics. With SO_REUSEPORT (Linux cluster mode), the
|
|
78
|
+
# kernel hashes each SYN to one of the N still-open per-worker
|
|
79
|
+
# listeners. A single dial from worker A may hash to worker B —
|
|
80
|
+
# leaving A's parked accept un-woken. Dialing K times (default
|
|
81
|
+
# `WAKE_CONNECT_BURST`) drives the miss probability down to
|
|
82
|
+
# negligible for typical worker counts.
|
|
83
|
+
#
|
|
84
|
+
# Failure-tolerant by construction:
|
|
85
|
+
# * `Errno::ECONNREFUSED` — listener already closed (the close
|
|
86
|
+
# raced ahead of us). Nothing to wake; bail out of the burst
|
|
87
|
+
# so we don't spend the timeout budget on doomed dials.
|
|
88
|
+
# * `Errno::EADDRNOTAVAIL` — interface gone. Same.
|
|
89
|
+
# * Connect timeout — kernel netstack is stuck; we tried, the
|
|
90
|
+
# caller's `thread.join(timeout)` will surface the symptom.
|
|
91
|
+
# * Any other socket error — log nothing (we may be running
|
|
92
|
+
# inside a signal handler thread); just swallow.
|
|
93
|
+
def wake_listener(host, port, connect_timeout: WAKE_CONNECT_TIMEOUT_SECONDS,
|
|
94
|
+
count: 1)
|
|
95
|
+
return unless host && port
|
|
96
|
+
return if count <= 0
|
|
97
|
+
|
|
98
|
+
count.times do
|
|
99
|
+
break unless dial_wake_once(host, port, connect_timeout)
|
|
100
|
+
end
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# 2.14-B — single dial. Returns true on success (continue
|
|
105
|
+
# bursting), false on a "listener gone" outcome (abort the burst
|
|
106
|
+
# so we don't waste the timeout budget on N×ECONNREFUSED).
|
|
107
|
+
def dial_wake_once(host, port, connect_timeout)
|
|
108
|
+
::Socket.tcp(host, port, connect_timeout: connect_timeout, &:close)
|
|
109
|
+
true
|
|
110
|
+
rescue Errno::ECONNREFUSED, Errno::EADDRNOTAVAIL, Errno::EHOSTUNREACH,
|
|
111
|
+
Errno::ENETUNREACH
|
|
112
|
+
# Listener gone — no point retrying, the kernel will refuse
|
|
113
|
+
# every dial in this burst the same way.
|
|
114
|
+
false
|
|
115
|
+
rescue Errno::ETIMEDOUT, Errno::ECONNRESET, Errno::EPIPE,
|
|
116
|
+
Errno::EBADF, IOError, SocketError
|
|
117
|
+
# Transient — keep bursting in case a later dial races into a
|
|
118
|
+
# still-open sibling listener (REUSEPORT cluster mode).
|
|
119
|
+
true
|
|
120
|
+
end
|
|
121
|
+
private_class_method :dial_wake_once
|
|
122
|
+
|
|
37
123
|
# Whether the C accept loop is available and the env didn't
|
|
38
124
|
# disable it.
|
|
39
125
|
def available?
|
|
@@ -78,20 +164,32 @@ module Hyperion
|
|
|
78
164
|
%w[1 on true yes].include?(env.downcase)
|
|
79
165
|
end
|
|
80
166
|
|
|
81
|
-
# Whether the route table is C-loop eligible:
|
|
82
|
-
#
|
|
167
|
+
# Whether the route table is C-loop eligible: every registered
|
|
168
|
+
# entry is either a `StaticEntry` (2.12-C path) or a
|
|
169
|
+
# `DynamicBlockEntry` (2.14-A path), and the table has at least
|
|
170
|
+
# one of either. Legacy `Server.handle(method, path, handler)`
|
|
171
|
+
# registrations (where `handler` takes a `Hyperion::Request`)
|
|
172
|
+
# disable the C path — those still flow through `Connection#serve`.
|
|
83
173
|
def eligible_route_table?(route_table)
|
|
84
174
|
return false unless route_table
|
|
85
175
|
|
|
86
|
-
|
|
176
|
+
any_eligible = false
|
|
87
177
|
route_table.instance_variable_get(:@routes).each_value do |path_table|
|
|
88
178
|
path_table.each_value do |handler|
|
|
89
|
-
return false unless
|
|
179
|
+
return false unless eligible_entry?(handler)
|
|
90
180
|
|
|
91
|
-
|
|
181
|
+
any_eligible = true
|
|
92
182
|
end
|
|
93
183
|
end
|
|
94
|
-
|
|
184
|
+
any_eligible
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# 2.14-A — predicate split out so specs and the engagement check
|
|
188
|
+
# can introspect single entries. Lives here (rather than on the
|
|
189
|
+
# entry classes) so the eligibility surface stays in one place.
|
|
190
|
+
def eligible_entry?(handler)
|
|
191
|
+
handler.is_a?(::Hyperion::Server::RouteTable::StaticEntry) ||
|
|
192
|
+
handler.is_a?(::Hyperion::Server::RouteTable::DynamicBlockEntry)
|
|
95
193
|
end
|
|
96
194
|
|
|
97
195
|
# Build a lifecycle callback that, when invoked from the C loop
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'stringio'
|
|
4
|
+
|
|
3
5
|
module Hyperion
|
|
4
6
|
class Server
|
|
5
7
|
# 2.10-D — direct-dispatch route registry. Mirrors agoo's
|
|
@@ -86,6 +88,68 @@ module Hyperion
|
|
|
86
88
|
end
|
|
87
89
|
end
|
|
88
90
|
|
|
91
|
+
# 2.14-A — wrapper for a Rack-style block registered via
|
|
92
|
+
# `Server.handle(:GET, '/path') { |env| [...] }`. Differs from
|
|
93
|
+
# `StaticEntry` in that the response is computed per-request
|
|
94
|
+
# rather than baked at registration time — but the route table
|
|
95
|
+
# entry shape is uniform, so the C accept loop can branch on
|
|
96
|
+
# `is_a?(DynamicBlockEntry)` AFTER the StaticEntry check and
|
|
97
|
+
# invoke the block via the registered C-loop dispatch helper.
|
|
98
|
+
#
|
|
99
|
+
# The struct holds:
|
|
100
|
+
# * `method` — request-method symbol (`:GET`, `:POST`, ...)
|
|
101
|
+
# * `path` — exact-match path String (frozen)
|
|
102
|
+
# * `block` — the registered Proc / lambda; receives a Rack
|
|
103
|
+
# env hash and must return a `[status, headers, body]`
|
|
104
|
+
# triple per the Rack spec. The C accept loop hands it a
|
|
105
|
+
# populated env via the `Adapter::Rack.dispatch_for_c_loop`
|
|
106
|
+
# helper; the block sees the same env shape Rack apps
|
|
107
|
+
# normally see (HTTP_*, REQUEST_METHOD, PATH_INFO, etc.).
|
|
108
|
+
#
|
|
109
|
+
# Calling the entry directly (the legacy fall-through path used
|
|
110
|
+
# when the C accept loop is NOT engaged — TLS listeners, mixed
|
|
111
|
+
# tables, operator escape hatch via `HYPERION_C_ACCEPT_LOOP=0`)
|
|
112
|
+
# delegates straight to the block with a freshly-built env via
|
|
113
|
+
# the existing `Adapter::Rack#call` machinery. The Connection
|
|
114
|
+
# path's direct-route dispatcher already handles
|
|
115
|
+
# `respond_to?(:call)` entries by invoking them with a
|
|
116
|
+
# `Hyperion::Request` value object — we route through that
|
|
117
|
+
# surface so the legacy fallback stays bit-identical to a
|
|
118
|
+
# 2.13-shape `Server.handle` registration.
|
|
119
|
+
DynamicBlockEntry = Struct.new(:method, :path, :block) do
|
|
120
|
+
# Legacy direct-route surface: `RouteTable#lookup` → handler →
|
|
121
|
+
# `handler.call(request)` returning a `[status, headers, body]`
|
|
122
|
+
# triple. Used by the Connection path when the C accept loop is
|
|
123
|
+
# disengaged (TLS, mixed tables). We hand the block a minimal
|
|
124
|
+
# env hash so it sees the same Rack-style API regardless of
|
|
125
|
+
# which dispatch shape served the request.
|
|
126
|
+
def call(request)
|
|
127
|
+
env = build_legacy_env(request)
|
|
128
|
+
block.call(env)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def build_legacy_env(request)
|
|
134
|
+
headers = request.respond_to?(:headers) ? (request.headers || {}) : {}
|
|
135
|
+
env = {
|
|
136
|
+
'REQUEST_METHOD' => request.method,
|
|
137
|
+
'PATH_INFO' => request.path,
|
|
138
|
+
'QUERY_STRING' => request.query_string.to_s,
|
|
139
|
+
'SERVER_NAME' => 'localhost',
|
|
140
|
+
'SERVER_PORT' => '80',
|
|
141
|
+
'rack.input' => StringIO.new(request.body.to_s),
|
|
142
|
+
'rack.errors' => $stderr,
|
|
143
|
+
'rack.url_scheme' => 'http'
|
|
144
|
+
}
|
|
145
|
+
headers.each do |name, value|
|
|
146
|
+
key = "HTTP_#{name.to_s.upcase.tr('-', '_')}"
|
|
147
|
+
env[key] = value
|
|
148
|
+
end
|
|
149
|
+
env
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
89
153
|
def initialize
|
|
90
154
|
# Per-method Hash so the lookup is `@routes[:GET][path]`
|
|
91
155
|
# — two integer-keyed-Hash hits. Pre-allocate the seven
|