hyperion-rb 2.11.0 → 2.13.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 +1079 -0
- data/README.md +220 -5
- data/ext/hyperion_http/extconf.rb +41 -0
- data/ext/hyperion_http/io_uring_loop.c +710 -0
- data/ext/hyperion_http/page_cache.c +1032 -0
- data/ext/hyperion_http/page_cache_internal.h +132 -0
- data/ext/hyperion_http/parser.c +382 -51
- data/lib/hyperion/adapter/rack.rb +18 -4
- data/lib/hyperion/connection.rb +78 -3
- data/lib/hyperion/dispatch_mode.rb +19 -1
- data/lib/hyperion/http2_handler.rb +458 -13
- data/lib/hyperion/metrics.rb +212 -38
- data/lib/hyperion/prometheus_exporter.rb +76 -1
- data/lib/hyperion/server/connection_loop.rb +159 -0
- data/lib/hyperion/server.rb +183 -0
- data/lib/hyperion/thread_pool.rb +23 -7
- data/lib/hyperion/version.rb +1 -1
- metadata +4 -1
|
@@ -259,6 +259,176 @@ module Hyperion
|
|
|
259
259
|
# regular headers, and any DATA frame body bytes for later dispatch.
|
|
260
260
|
# Also exposes a `window_available` notification fan-out so the
|
|
261
261
|
# response-writer fiber can sleep until WINDOW_UPDATE arrives.
|
|
262
|
+
# 2.13-D — IO-shaped streaming request body backing `rack.input` for
|
|
263
|
+
# gRPC client-streaming and bidirectional RPCs. Push side is the
|
|
264
|
+
# `RequestStream#process_data` callback (one push per inbound DATA
|
|
265
|
+
# frame); read side is the Rack app's `env['rack.input'].read` calls.
|
|
266
|
+
#
|
|
267
|
+
# Reads block the calling fiber via `Async::Notification` until either
|
|
268
|
+
# bytes arrive or the writer side calls `close_writer` (END_STREAM on
|
|
269
|
+
# the request).
|
|
270
|
+
#
|
|
271
|
+
# Contract notes:
|
|
272
|
+
# * `read(n)` returns up to n bytes, or fewer at EOF (Rack 3 §11
|
|
273
|
+
# IO-conformance: `read(length)` returns nil on EOF).
|
|
274
|
+
# * `read` (no argument) reads until EOF and returns the rest.
|
|
275
|
+
# * `each` yields chunk-by-chunk (one yield per pushed chunk).
|
|
276
|
+
# * `rewind` is a no-op — streaming bodies aren't seekable.
|
|
277
|
+
# * `close` is a no-op on the read side; the writer drives
|
|
278
|
+
# end-of-stream via `close_writer`.
|
|
279
|
+
#
|
|
280
|
+
# The class is intentionally narrow — it implements only the methods
|
|
281
|
+
# gRPC handlers and Rack apps actually call against `rack.input`. A
|
|
282
|
+
# real Rack app that does `rack.input.size` or `rack.input.gets` will
|
|
283
|
+
# raise (Rack 3 §11 doesn't require those for streaming inputs).
|
|
284
|
+
class StreamingInput
|
|
285
|
+
EMPTY_BIN = String.new('', encoding: Encoding::ASCII_8BIT).freeze
|
|
286
|
+
|
|
287
|
+
def initialize
|
|
288
|
+
@chunks = []
|
|
289
|
+
@notify = ::Async::Notification.new
|
|
290
|
+
@writer_closed = false
|
|
291
|
+
@reader_closed = false
|
|
292
|
+
@bytes_buffered = 0
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# 2.13-D — push a DATA-frame's bytes onto the queue. Called from
|
|
296
|
+
# `RequestStream#process_data`. Empty / already-closed pushes are
|
|
297
|
+
# silently dropped (the END_STREAM path uses `close_writer` instead).
|
|
298
|
+
def push(bytes)
|
|
299
|
+
return if @writer_closed
|
|
300
|
+
return if bytes.nil?
|
|
301
|
+
|
|
302
|
+
s = bytes.to_s
|
|
303
|
+
return if s.empty?
|
|
304
|
+
|
|
305
|
+
# Force ASCII-8BIT so gRPC binary payloads survive; matches the
|
|
306
|
+
# encoding contract on `RequestStream#@request_body`.
|
|
307
|
+
s = s.b unless s.encoding == Encoding::ASCII_8BIT
|
|
308
|
+
@chunks << s
|
|
309
|
+
@bytes_buffered += s.bytesize
|
|
310
|
+
@notify.signal
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# 2.13-D — signal end-of-request. Wakes any reader fiber parked on
|
|
314
|
+
# `read` so it can return EOF (`nil` from `read(n)`, accumulated
|
|
315
|
+
# buffer from `read` with no arg).
|
|
316
|
+
def close_writer
|
|
317
|
+
@writer_closed = true
|
|
318
|
+
@notify.signal
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def writer_closed?
|
|
322
|
+
@writer_closed
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
attr_reader :bytes_buffered
|
|
326
|
+
|
|
327
|
+
# Read up to `length` bytes (or all remaining bytes when `length` is
|
|
328
|
+
# nil). Blocks the fiber until at least one chunk is available OR
|
|
329
|
+
# the writer has closed. Returns `nil` on EOF when `length` is given
|
|
330
|
+
# (Rack 3 §11 IO-conformance), otherwise the empty string on EOF
|
|
331
|
+
# when `length` is nil.
|
|
332
|
+
def read(length = nil)
|
|
333
|
+
if length.nil?
|
|
334
|
+
# Drain everything until EOF.
|
|
335
|
+
out = String.new(encoding: Encoding::ASCII_8BIT)
|
|
336
|
+
loop do
|
|
337
|
+
wait_for_data
|
|
338
|
+
while (chunk = @chunks.shift)
|
|
339
|
+
out << chunk
|
|
340
|
+
@bytes_buffered -= chunk.bytesize
|
|
341
|
+
end
|
|
342
|
+
break if @writer_closed && @chunks.empty?
|
|
343
|
+
end
|
|
344
|
+
out
|
|
345
|
+
else
|
|
346
|
+
return nil if length.zero?
|
|
347
|
+
|
|
348
|
+
out = String.new(encoding: Encoding::ASCII_8BIT)
|
|
349
|
+
remaining = length
|
|
350
|
+
while remaining.positive?
|
|
351
|
+
wait_for_data
|
|
352
|
+
if @chunks.empty? && @writer_closed
|
|
353
|
+
return out.empty? ? nil : out
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
chunk = @chunks.first
|
|
357
|
+
break unless chunk # writer-closed race
|
|
358
|
+
|
|
359
|
+
if chunk.bytesize <= remaining
|
|
360
|
+
out << chunk
|
|
361
|
+
remaining -= chunk.bytesize
|
|
362
|
+
@bytes_buffered -= chunk.bytesize
|
|
363
|
+
@chunks.shift
|
|
364
|
+
else
|
|
365
|
+
out << chunk.byteslice(0, remaining)
|
|
366
|
+
# Mutate head chunk: take the tail.
|
|
367
|
+
@chunks[0] = chunk.byteslice(remaining, chunk.bytesize - remaining)
|
|
368
|
+
@bytes_buffered -= remaining
|
|
369
|
+
remaining = 0
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
out
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def each
|
|
377
|
+
return enum_for(:each) unless block_given?
|
|
378
|
+
|
|
379
|
+
loop do
|
|
380
|
+
wait_for_data
|
|
381
|
+
while (chunk = @chunks.shift)
|
|
382
|
+
@bytes_buffered -= chunk.bytesize
|
|
383
|
+
yield chunk
|
|
384
|
+
end
|
|
385
|
+
break if @writer_closed && @chunks.empty?
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def gets
|
|
390
|
+
# Rack 3 streaming-input contract doesn't require gets; emulate
|
|
391
|
+
# naively for apps that call it: read until \n or EOF.
|
|
392
|
+
out = String.new(encoding: Encoding::ASCII_8BIT)
|
|
393
|
+
loop do
|
|
394
|
+
ch = read(1)
|
|
395
|
+
return out.empty? ? nil : out if ch.nil?
|
|
396
|
+
|
|
397
|
+
out << ch
|
|
398
|
+
break if ch == "\n".b
|
|
399
|
+
end
|
|
400
|
+
out
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def rewind
|
|
404
|
+
# Streaming bodies aren't seekable; Rack 3 §11 allows read-only
|
|
405
|
+
# streaming inputs. We return false instead of raising so apps
|
|
406
|
+
# that defensively rewind() (Rack::Multipart::Parser, etc.) keep
|
|
407
|
+
# working — they just won't get the data they expected on a gRPC
|
|
408
|
+
# streaming body, which is fine because such apps aren't gRPC
|
|
409
|
+
# services.
|
|
410
|
+
false
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def close
|
|
414
|
+
@reader_closed = true
|
|
415
|
+
nil
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def closed?
|
|
419
|
+
@reader_closed
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
private
|
|
423
|
+
|
|
424
|
+
def wait_for_data
|
|
425
|
+
return if @chunks.any?
|
|
426
|
+
return if @writer_closed
|
|
427
|
+
|
|
428
|
+
@notify.wait
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
|
|
262
432
|
class RequestStream < ::Protocol::HTTP2::Stream
|
|
263
433
|
# RFC 7540 §8.1.2.1 — the only pseudo-headers a server MUST accept on a
|
|
264
434
|
# request. Anything else (notably `:status`, which is response-only, or
|
|
@@ -270,17 +440,36 @@ module Hyperion
|
|
|
270
440
|
# in HTTP/2 requests; their semantics are folded into HTTP/2 framing.
|
|
271
441
|
FORBIDDEN_HEADERS = %w[connection transfer-encoding keep-alive upgrade proxy-connection].freeze
|
|
272
442
|
|
|
273
|
-
attr_reader :request_headers, :request_body, :request_complete, :protocol_error_reason
|
|
443
|
+
attr_reader :request_headers, :request_body, :request_complete, :protocol_error_reason,
|
|
444
|
+
:streaming_input
|
|
274
445
|
|
|
275
446
|
def initialize(*)
|
|
276
447
|
super
|
|
277
448
|
@request_headers = []
|
|
278
|
-
|
|
449
|
+
# 2.12-F — gRPC carries opaque protobuf bytes
|
|
450
|
+
# ([1-byte compressed flag][4-byte length-prefix][message bytes]) in the
|
|
451
|
+
# request body. The default UTF-8 encoding on a `+''` literal would
|
|
452
|
+
# break valid_encoding? on byte sequences that don't form UTF-8
|
|
453
|
+
# codepoints, leading to a Rack app reading `body.string` and getting
|
|
454
|
+
# a String that misreports its bytesize / corrupts when string-
|
|
455
|
+
# interpolated. ASCII_8BIT (binary) preserves bytes verbatim and is
|
|
456
|
+
# the encoding gRPC Ruby clients expect. Same change is applied to
|
|
457
|
+
# the HTTP/1.1 path as a separate concern; see Connection.
|
|
458
|
+
@request_body = String.new(encoding: Encoding::ASCII_8BIT)
|
|
279
459
|
@request_body_bytes = 0
|
|
280
460
|
@request_complete = false
|
|
281
461
|
@window_available = ::Async::Notification.new
|
|
282
462
|
@protocol_error_reason = nil
|
|
283
463
|
@declared_content_length = nil
|
|
464
|
+
# 2.13-D — gRPC streaming RPCs. When the request HEADERS block carries
|
|
465
|
+
# `content-type: application/grpc*` AND `te: trailers`, we promote the
|
|
466
|
+
# stream into "streaming-input mode": DATA frames are pushed into a
|
|
467
|
+
# `StreamingInput` queue (vs. accumulated into `@request_body`) and
|
|
468
|
+
# the dispatch loop fires the app on HEADERS arrival (vs. END_STREAM),
|
|
469
|
+
# so client-streaming + bidirectional RPCs work. Plain HTTP/2 traffic
|
|
470
|
+
# keeps the pre-2.13-D buffered semantic.
|
|
471
|
+
@streaming_input = nil
|
|
472
|
+
@streaming_dispatch_ready = false
|
|
284
473
|
end
|
|
285
474
|
|
|
286
475
|
# Used by the dispatch loop to decide whether to invoke the app or
|
|
@@ -303,10 +492,18 @@ module Hyperion
|
|
|
303
492
|
# Run RFC 7540 §8.1.2 validation as soon as we have a complete header
|
|
304
493
|
# block. We do it here (not at end_stream) so the dispatcher sees the
|
|
305
494
|
# error flag before it spawns a fiber for the request.
|
|
306
|
-
|
|
495
|
+
if first_block && !protocol_error?
|
|
496
|
+
validate_request_headers!
|
|
497
|
+
# 2.13-D — promote to streaming-input mode for gRPC requests so
|
|
498
|
+
# client-streaming + bidi RPCs see DATA frames as they arrive.
|
|
499
|
+
maybe_promote_streaming_input! unless protocol_error?
|
|
500
|
+
end
|
|
307
501
|
if frame.end_stream?
|
|
308
502
|
validate_body_length! unless protocol_error?
|
|
309
503
|
@request_complete = true
|
|
504
|
+
# 2.13-D — closing the writer side wakes any reader fiber the
|
|
505
|
+
# app has parked on `rack.input.read`.
|
|
506
|
+
@streaming_input&.close_writer
|
|
310
507
|
end
|
|
311
508
|
decoded
|
|
312
509
|
end
|
|
@@ -315,17 +512,44 @@ module Hyperion
|
|
|
315
512
|
data = super
|
|
316
513
|
# rubocop:disable Rails/Present
|
|
317
514
|
if data && !data.empty?
|
|
318
|
-
@
|
|
515
|
+
if @streaming_input
|
|
516
|
+
# 2.13-D — gRPC streaming-input: push DATA-frame bytes into the
|
|
517
|
+
# queue Rack apps read from via `env['rack.input']`. Tracking
|
|
518
|
+
# `@request_body_bytes` for content-length cross-check still
|
|
519
|
+
# applies (a streaming gRPC request that advertises content-
|
|
520
|
+
# length would still be wire-validated), but we deliberately
|
|
521
|
+
# SKIP `@request_body << data` — the streaming path doesn't
|
|
522
|
+
# buffer bytes a second time.
|
|
523
|
+
@streaming_input.push(data)
|
|
524
|
+
else
|
|
525
|
+
@request_body << data
|
|
526
|
+
end
|
|
319
527
|
@request_body_bytes += data.bytesize
|
|
320
528
|
end
|
|
321
529
|
# rubocop:enable Rails/Present
|
|
322
530
|
if frame.end_stream?
|
|
323
531
|
validate_body_length! unless protocol_error?
|
|
324
532
|
@request_complete = true
|
|
533
|
+
@streaming_input&.close_writer
|
|
325
534
|
end
|
|
326
535
|
data
|
|
327
536
|
end
|
|
328
537
|
|
|
538
|
+
# 2.13-D — was this stream marked dispatchable on HEADERS arrival?
|
|
539
|
+
# The serve-loop dispatches both `request_complete` streams (unary
|
|
540
|
+
# path: app fires after END_STREAM) AND `streaming_dispatch_ready`
|
|
541
|
+
# streams (gRPC streaming path: app fires after HEADERS so it can
|
|
542
|
+
# read DATA frames as they land).
|
|
543
|
+
def streaming_dispatch_ready?
|
|
544
|
+
@streaming_dispatch_ready
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
# 2.13-D — generic predicate used by the serve loop instead of
|
|
548
|
+
# `request_complete`. True for both code paths.
|
|
549
|
+
def dispatchable?
|
|
550
|
+
@request_complete || @streaming_dispatch_ready
|
|
551
|
+
end
|
|
552
|
+
|
|
329
553
|
# RFC 7540 §8.1.2 — request header validation. Sets
|
|
330
554
|
# `@protocol_error_reason` on the first violation we hit; the dispatch
|
|
331
555
|
# loop turns that into RST_STREAM PROTOCOL_ERROR.
|
|
@@ -413,6 +637,42 @@ module Hyperion
|
|
|
413
637
|
@window_available.wait
|
|
414
638
|
end
|
|
415
639
|
|
|
640
|
+
# 2.13-D — promote the stream into streaming-input mode when the
|
|
641
|
+
# request HEADERS look like a gRPC RPC. Detection rules (intentionally
|
|
642
|
+
# narrow so we don't accidentally streaming-promote plain HTTP/2):
|
|
643
|
+
#
|
|
644
|
+
# * `content-type` starts with `application/grpc` (covers `+proto`,
|
|
645
|
+
# `+json`, etc.). gRPC's MIME-type registry guarantees this prefix.
|
|
646
|
+
# * `te: trailers` is present (RFC 7230 §4 — gRPC requires it on every
|
|
647
|
+
# request). HTTP/2 §8.1.2.2 already constrains `te` to `trailers`,
|
|
648
|
+
# so any request that carries `te` at all hit our validator first.
|
|
649
|
+
#
|
|
650
|
+
# When promoted, we allocate the `StreamingInput` queue and arm
|
|
651
|
+
# `@streaming_dispatch_ready` so the serve loop can dispatch the app
|
|
652
|
+
# immediately (instead of waiting for END_STREAM).
|
|
653
|
+
#
|
|
654
|
+
# The `:method` check (POST only) is defensive: gRPC RPCs are POST.
|
|
655
|
+
# GET-shaped requests with these headers are almost certainly a bug
|
|
656
|
+
# in the caller, not an intentional gRPC streaming request — fall
|
|
657
|
+
# through to the buffered path and let the app sort it out.
|
|
658
|
+
def maybe_promote_streaming_input!
|
|
659
|
+
method = pseudo_value(':method')
|
|
660
|
+
return unless method == 'POST'
|
|
661
|
+
|
|
662
|
+
ct = nil
|
|
663
|
+
te = nil
|
|
664
|
+
@request_headers.each do |pair|
|
|
665
|
+
name = pair[0].to_s
|
|
666
|
+
ct ||= pair[1].to_s if name == 'content-type'
|
|
667
|
+
te ||= pair[1].to_s if name == 'te'
|
|
668
|
+
end
|
|
669
|
+
return unless ct && ct.start_with?('application/grpc')
|
|
670
|
+
return unless te && te.downcase.strip == 'trailers'
|
|
671
|
+
|
|
672
|
+
@streaming_input = StreamingInput.new
|
|
673
|
+
@streaming_dispatch_ready = true
|
|
674
|
+
end
|
|
675
|
+
|
|
416
676
|
private
|
|
417
677
|
|
|
418
678
|
# Look up a pseudo-header by name (e.g. `:method`) by scanning the raw
|
|
@@ -506,6 +766,10 @@ module Hyperion
|
|
|
506
766
|
@logger = Hyperion.logger
|
|
507
767
|
end
|
|
508
768
|
@h2_admission = h2_admission
|
|
769
|
+
# 2.12-E — per-worker request counter label. Identical caching
|
|
770
|
+
# rationale to Connection#initialize: process-constant ID, looked
|
|
771
|
+
# up once and held in the ivar.
|
|
772
|
+
@worker_id = Process.pid.to_s
|
|
509
773
|
@h2_codec_available = Hyperion::H2Codec.available?
|
|
510
774
|
# 2.5-B [breaking-default-change]: native HPACK now defaults to ON
|
|
511
775
|
# when the Rust crate is available. The 2026-04-30 Rails-shape
|
|
@@ -787,7 +1051,10 @@ module Hyperion
|
|
|
787
1051
|
ready_ids.uniq.each do |sid|
|
|
788
1052
|
stream = server.streams[sid]
|
|
789
1053
|
next unless stream.is_a?(RequestStream)
|
|
790
|
-
|
|
1054
|
+
# 2.13-D — `dispatchable?` covers both unary (request_complete on
|
|
1055
|
+
# END_STREAM) and gRPC streaming-input (dispatch_ready on first
|
|
1056
|
+
# HEADERS). Pre-2.13-D this was a `request_complete` check.
|
|
1057
|
+
next unless stream.dispatchable?
|
|
791
1058
|
next if stream.closed?
|
|
792
1059
|
next if stream.instance_variable_get(:@hyperion_dispatched)
|
|
793
1060
|
|
|
@@ -1109,18 +1376,32 @@ module Hyperion
|
|
|
1109
1376
|
hyperion_headers = regular
|
|
1110
1377
|
hyperion_headers['host'] ||= authority if authority
|
|
1111
1378
|
|
|
1379
|
+
# 2.13-D — when streaming-input was promoted on this stream, hand the
|
|
1380
|
+
# `StreamingInput` queue to the Rack adapter as the request body. The
|
|
1381
|
+
# adapter detects the non-String body and sets `env['rack.input']`
|
|
1382
|
+
# directly to the queue (no StringIO wrap). When unary, the legacy
|
|
1383
|
+
# path uses the buffered String body. Spec FakeStream stand-ins
|
|
1384
|
+
# don't define `streaming_input`, so we duck-type via `respond_to?`
|
|
1385
|
+
# rather than rely on the method's existence.
|
|
1386
|
+
body = (stream.respond_to?(:streaming_input) ? stream.streaming_input : nil) ||
|
|
1387
|
+
stream.request_body
|
|
1112
1388
|
request = Hyperion::Request.new(
|
|
1113
1389
|
method: method,
|
|
1114
1390
|
path: path,
|
|
1115
1391
|
query_string: query || '',
|
|
1116
1392
|
http_version: 'HTTP/2',
|
|
1117
1393
|
headers: hyperion_headers,
|
|
1118
|
-
body:
|
|
1394
|
+
body: body,
|
|
1119
1395
|
peer_address: peer_addr
|
|
1120
1396
|
)
|
|
1121
1397
|
|
|
1122
1398
|
@metrics.increment(:requests_total)
|
|
1123
1399
|
@metrics.increment(:requests_in_flight)
|
|
1400
|
+
# 2.12-E — per-worker request counter, ticked once per h2 stream.
|
|
1401
|
+
# Same family as Connection#serve so the audit metric reflects
|
|
1402
|
+
# cluster distribution across BOTH transports without operators
|
|
1403
|
+
# needing to alert on two separate counters.
|
|
1404
|
+
@metrics.tick_worker_request(@worker_id)
|
|
1124
1405
|
# 2.1.0 (WS-1): HTTP/2 hijack is intentionally NOT plumbed here.
|
|
1125
1406
|
# Rack 3 hijack over HTTP/2 requires Extended CONNECT (RFC 8441 +
|
|
1126
1407
|
# RFC 9220) — a separate feature with its own SETTINGS handshake,
|
|
@@ -1155,21 +1436,63 @@ module Hyperion
|
|
|
1155
1436
|
end
|
|
1156
1437
|
end
|
|
1157
1438
|
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1439
|
+
# 2.12-F — gRPC unary support: bodies that respond to `:trailers` carry a
|
|
1440
|
+
# final HEADERS frame (with END_STREAM=1) right after the DATA frames.
|
|
1441
|
+
# The Rack 3 contract is "iterate body first, then call body.trailers"
|
|
1442
|
+
# — so for the unary trailer case we materialise the payload, then
|
|
1443
|
+
# *before* `body.close` (`Rack::BodyProxy` clears state on close)
|
|
1444
|
+
# snapshot the trailers Hash.
|
|
1445
|
+
#
|
|
1446
|
+
# 2.13-D — gRPC streaming support: when the body responds to `:trailers`
|
|
1447
|
+
# we iterate it chunk-by-chunk and emit ONE DATA frame per yielded
|
|
1448
|
+
# chunk (no inter-chunk coalescing). Server-streaming RPCs yield one
|
|
1449
|
+
# gRPC-framed message per `each` iteration; preserving chunk boundaries
|
|
1450
|
+
# on the wire is what lets clients read messages incrementally. The
|
|
1451
|
+
# h2 max-frame-size split inside `send_body_chunk` still applies if a
|
|
1452
|
+
# single message exceeds the peer's MAX_FRAME_SIZE, but a small message
|
|
1453
|
+
# is still one DATA frame.
|
|
1454
|
+
#
|
|
1162
1455
|
# Hotfix C2: empty-body responses (RFC 7230 §3.3.3 — 204/304 + HEAD)
|
|
1163
1456
|
# MUST NOT carry a DATA frame. Folding END_STREAM onto the HEADERS
|
|
1164
1457
|
# frame collapses the response to one encoder-mutex acquisition and
|
|
1165
1458
|
# one writer-fiber wakeup instead of two. Any body the app returned
|
|
1166
1459
|
# for HEAD is discarded here per spec (the bytes were already
|
|
1167
1460
|
# built — that's a Rack-app smell, not our problem to fix).
|
|
1461
|
+
#
|
|
1462
|
+
# Trailers on body-suppressed responses (HEAD/204/304) are dropped:
|
|
1463
|
+
# the response is end-of-stream after HEADERS, with no place to put
|
|
1464
|
+
# a trailing HEADERS frame. This matches what curl --http2 / grpc
|
|
1465
|
+
# clients do (HEAD + gRPC isn't a meaningful combination).
|
|
1168
1466
|
if body_suppressed?(method, status)
|
|
1169
1467
|
writer_ctx.encode_mutex.synchronize do
|
|
1170
1468
|
stream.send_headers(out_headers, ::Protocol::HTTP2::END_STREAM)
|
|
1171
1469
|
end
|
|
1470
|
+
body_chunks.close if body_chunks.respond_to?(:close)
|
|
1471
|
+
elsif body_chunks.respond_to?(:trailers)
|
|
1472
|
+
# gRPC / Rack-3-trailers path: HEADERS (no END_STREAM), one DATA
|
|
1473
|
+
# frame per yielded chunk (no END_STREAM on the last DATA), final
|
|
1474
|
+
# HEADERS with END_STREAM=1 carrying the trailers (or just
|
|
1475
|
+
# END_STREAM on the last DATA when trailers turn out to be empty).
|
|
1476
|
+
writer_ctx.encode_mutex.synchronize { stream.send_headers(out_headers) }
|
|
1477
|
+
send_body_streaming(stream, body_chunks, writer_ctx)
|
|
1478
|
+
response_trailers = collect_response_trailers(body_chunks)
|
|
1479
|
+
body_chunks.close if body_chunks.respond_to?(:close)
|
|
1480
|
+
if have_trailers?(response_trailers)
|
|
1481
|
+
send_trailers(stream, response_trailers, writer_ctx)
|
|
1482
|
+
else
|
|
1483
|
+
# No trailers materialised — close the stream with an empty
|
|
1484
|
+
# END_STREAM DATA frame so the peer sees end-of-response.
|
|
1485
|
+
writer_ctx.encode_mutex.synchronize do
|
|
1486
|
+
stream.send_data('', ::Protocol::HTTP2::END_STREAM)
|
|
1487
|
+
end
|
|
1488
|
+
end
|
|
1172
1489
|
else
|
|
1490
|
+
# Pre-2.12-F shape: HEADERS → DATA frames with END_STREAM on last DATA.
|
|
1491
|
+
# Buffer the payload (already the legacy semantic) and let send_body
|
|
1492
|
+
# do max-frame-size splitting.
|
|
1493
|
+
payload = String.new(encoding: Encoding::ASCII_8BIT)
|
|
1494
|
+
body_chunks.each { |c| payload << c.to_s }
|
|
1495
|
+
body_chunks.close if body_chunks.respond_to?(:close)
|
|
1173
1496
|
writer_ctx.encode_mutex.synchronize { stream.send_headers(out_headers) }
|
|
1174
1497
|
send_body(stream, payload, writer_ctx)
|
|
1175
1498
|
end
|
|
@@ -1229,9 +1552,24 @@ module Hyperion
|
|
|
1229
1552
|
#
|
|
1230
1553
|
# The encode_mutex protects HPACK state and per-stream frame ordering;
|
|
1231
1554
|
# the actual socket write happens off-fiber via the writer task.
|
|
1232
|
-
|
|
1555
|
+
#
|
|
1556
|
+
# 2.12-F — `end_stream:` controls whether the LAST DATA frame carries
|
|
1557
|
+
# the END_STREAM flag. The default `true` preserves pre-2.12-F semantics
|
|
1558
|
+
# (final DATA frame closes the stream). Callers that intend to send a
|
|
1559
|
+
# trailing HEADERS frame after the body pass `end_stream: false` so the
|
|
1560
|
+
# final DATA frame leaves the stream half-open from the server side
|
|
1561
|
+
# and the trailer HEADERS frame can carry END_STREAM=1.
|
|
1562
|
+
def send_body(stream, payload, writer_ctx, end_stream: true)
|
|
1233
1563
|
if payload.empty?
|
|
1234
|
-
|
|
1564
|
+
if end_stream
|
|
1565
|
+
writer_ctx.encode_mutex.synchronize do
|
|
1566
|
+
stream.send_data('', ::Protocol::HTTP2::END_STREAM)
|
|
1567
|
+
end
|
|
1568
|
+
end
|
|
1569
|
+
# When end_stream is false AND payload is empty, we deliberately
|
|
1570
|
+
# send NO DATA frame at all — gRPC trailers-only responses (the
|
|
1571
|
+
# error-without-payload shape) are HEADERS → trailer-HEADERS, no
|
|
1572
|
+
# DATA in between. send_trailers handles the closing END_STREAM.
|
|
1235
1573
|
return
|
|
1236
1574
|
end
|
|
1237
1575
|
|
|
@@ -1250,12 +1588,119 @@ module Hyperion
|
|
|
1250
1588
|
|
|
1251
1589
|
chunk = payload.byteslice(offset, available)
|
|
1252
1590
|
offset += chunk.bytesize
|
|
1253
|
-
|
|
1591
|
+
last_chunk = offset >= bytesize
|
|
1592
|
+
flags = last_chunk && end_stream ? ::Protocol::HTTP2::END_STREAM : 0
|
|
1254
1593
|
|
|
1255
1594
|
writer_ctx.encode_mutex.synchronize { stream.send_data(chunk, flags) }
|
|
1256
1595
|
end
|
|
1257
1596
|
end
|
|
1258
1597
|
|
|
1598
|
+
# 2.13-D — server-streaming send path. Iterates the Rack body via `#each`
|
|
1599
|
+
# and emits ONE DATA frame per yielded chunk (no coalescing), so each
|
|
1600
|
+
# gRPC message lands as its own frame and clients can decode messages
|
|
1601
|
+
# incrementally. The h2 max-frame-size clamping inside `send_body_chunk`
|
|
1602
|
+
# still applies — a single chunk that exceeds the peer's MAX_FRAME_SIZE
|
|
1603
|
+
# is split across multiple DATA frames, all without END_STREAM. The
|
|
1604
|
+
# caller emits the final END_STREAM (either via `send_trailers` or via
|
|
1605
|
+
# an empty trailing DATA frame) AFTER this method returns.
|
|
1606
|
+
#
|
|
1607
|
+
# Empty chunks (zero-byte Strings the app yielded) are skipped — sending
|
|
1608
|
+
# a zero-byte DATA frame is legal HTTP/2 but pointless and would inflate
|
|
1609
|
+
# frame counts on the wire.
|
|
1610
|
+
def send_body_streaming(stream, body, writer_ctx)
|
|
1611
|
+
body.each do |chunk|
|
|
1612
|
+
bytes = chunk.to_s
|
|
1613
|
+
next if bytes.empty?
|
|
1614
|
+
|
|
1615
|
+
send_body_chunk(stream, bytes, writer_ctx)
|
|
1616
|
+
end
|
|
1617
|
+
end
|
|
1618
|
+
|
|
1619
|
+
# 2.13-D — write a single chunk as one or more DATA frames (none with
|
|
1620
|
+
# END_STREAM). Splits across frames only if the chunk exceeds the peer's
|
|
1621
|
+
# available_frame_size (max-frame-size + flow-control window). The hot
|
|
1622
|
+
# path for gRPC streaming is a single message under MAX_FRAME_SIZE, which
|
|
1623
|
+
# results in one `send_data` call.
|
|
1624
|
+
def send_body_chunk(stream, payload, writer_ctx)
|
|
1625
|
+
offset = 0
|
|
1626
|
+
bytesize = payload.bytesize
|
|
1627
|
+
while offset < bytesize
|
|
1628
|
+
available = stream.available_frame_size
|
|
1629
|
+
if available <= 0
|
|
1630
|
+
stream.wait_for_window
|
|
1631
|
+
next
|
|
1632
|
+
end
|
|
1633
|
+
|
|
1634
|
+
chunk = payload.byteslice(offset, available)
|
|
1635
|
+
offset += chunk.bytesize
|
|
1636
|
+
writer_ctx.encode_mutex.synchronize { stream.send_data(chunk, 0) }
|
|
1637
|
+
end
|
|
1638
|
+
end
|
|
1639
|
+
|
|
1640
|
+
# 2.12-F — pull a trailers Hash off the response body if Rack 3
|
|
1641
|
+
# `body.trailers` is implemented. Called AFTER the body has been
|
|
1642
|
+
# fully iterated (Rack 3 contract: trailers are computed by the body
|
|
1643
|
+
# while it streams; reading them before iteration is undefined).
|
|
1644
|
+
# Returns nil when the body doesn't expose trailers, when the call
|
|
1645
|
+
# raises, or when the result isn't a Hash-coercible map. Defensive
|
|
1646
|
+
# by design: a misbehaving app must not crash the dispatch loop.
|
|
1647
|
+
def collect_response_trailers(body)
|
|
1648
|
+
return nil unless body.respond_to?(:trailers)
|
|
1649
|
+
|
|
1650
|
+
raw = body.trailers
|
|
1651
|
+
return nil if raw.nil?
|
|
1652
|
+
return raw if raw.is_a?(Hash)
|
|
1653
|
+
return raw.to_h if raw.respond_to?(:to_h)
|
|
1654
|
+
|
|
1655
|
+
nil
|
|
1656
|
+
rescue StandardError => e
|
|
1657
|
+
@logger.warn do
|
|
1658
|
+
{ message: 'h2 body.trailers raised; ignoring',
|
|
1659
|
+
error: e.message, error_class: e.class.name }
|
|
1660
|
+
end
|
|
1661
|
+
nil
|
|
1662
|
+
end
|
|
1663
|
+
|
|
1664
|
+
# 2.12-F — predicate for "we have trailers worth sending". Defined as
|
|
1665
|
+
# a method (rather than the more idiomatic `!h.nil? && !h.empty?` /
|
|
1666
|
+
# `h&.any?`) because rubocop-rails on the hot path autocorrects both
|
|
1667
|
+
# of those forms to `h.present?`, which raises NoMethodError on a
|
|
1668
|
+
# plain Hash outside ActiveSupport. Hyperion is a stand-alone gem;
|
|
1669
|
+
# we don't depend on ActiveSupport, so we route through this helper
|
|
1670
|
+
# to keep the rubocop-rails formatter quiet without adding a Cop
|
|
1671
|
+
# disable comment everywhere a nil-or-empty Hash check appears.
|
|
1672
|
+
def have_trailers?(trailers)
|
|
1673
|
+
return false if trailers.nil?
|
|
1674
|
+
return false if trailers.respond_to?(:empty?) && trailers.empty?
|
|
1675
|
+
|
|
1676
|
+
true
|
|
1677
|
+
end
|
|
1678
|
+
|
|
1679
|
+
# 2.12-F — emit the final HEADERS frame carrying response trailers.
|
|
1680
|
+
# The wire shape is one HEADERS frame with END_STREAM=1; HPACK
|
|
1681
|
+
# encodes the trailer block exactly like a regular HEADERS frame.
|
|
1682
|
+
# Trailer keys MUST be lowercased (RFC 7540 §8.1.2) — same rule as
|
|
1683
|
+
# regular HTTP/2 headers. We strip CR/LF from values defensively
|
|
1684
|
+
# (a header-injection guard) and split multi-line values on \n the
|
|
1685
|
+
# same way the regular response-header path does.
|
|
1686
|
+
def send_trailers(stream, trailers, writer_ctx)
|
|
1687
|
+
pairs = []
|
|
1688
|
+
trailers.each do |k, v|
|
|
1689
|
+
name = k.to_s.downcase
|
|
1690
|
+
# Pseudo-headers and forbidden names cannot appear in trailers.
|
|
1691
|
+
next if name.empty?
|
|
1692
|
+
next if name.start_with?(':')
|
|
1693
|
+
next if RequestStream::FORBIDDEN_HEADERS.include?(name)
|
|
1694
|
+
|
|
1695
|
+
Array(v).each do |val|
|
|
1696
|
+
val.to_s.split("\n").each { |line| pairs << [name, line] }
|
|
1697
|
+
end
|
|
1698
|
+
end
|
|
1699
|
+
writer_ctx.encode_mutex.synchronize do
|
|
1700
|
+
stream.send_headers(pairs, ::Protocol::HTTP2::END_STREAM)
|
|
1701
|
+
end
|
|
1702
|
+
end
|
|
1703
|
+
|
|
1259
1704
|
# Drain bytes off the per-connection send queue onto the real socket.
|
|
1260
1705
|
# This fiber is the SOLE writer to `socket` for the connection's
|
|
1261
1706
|
# lifetime, which satisfies SSLSocket's "no concurrent writes from
|