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.
@@ -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
- @request_body = +''
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
- validate_request_headers! if first_block && !protocol_error?
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
- @request_body << data
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
- next unless stream.request_complete
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: stream.request_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
- payload = +''
1159
- body_chunks.each { |c| payload << c.to_s }
1160
- body_chunks.close if body_chunks.respond_to?(:close)
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
- def send_body(stream, payload, writer_ctx)
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
- writer_ctx.encode_mutex.synchronize { stream.send_data('', ::Protocol::HTTP2::END_STREAM) }
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
- flags = offset >= bytesize ? ::Protocol::HTTP2::END_STREAM : 0
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