hyperion-rb 2.12.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.
@@ -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,7 +440,8 @@ 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
@@ -290,6 +461,15 @@ module Hyperion
290
461
  @window_available = ::Async::Notification.new
291
462
  @protocol_error_reason = nil
292
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
293
473
  end
294
474
 
295
475
  # Used by the dispatch loop to decide whether to invoke the app or
@@ -312,10 +492,18 @@ module Hyperion
312
492
  # Run RFC 7540 §8.1.2 validation as soon as we have a complete header
313
493
  # block. We do it here (not at end_stream) so the dispatcher sees the
314
494
  # error flag before it spawns a fiber for the request.
315
- 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
316
501
  if frame.end_stream?
317
502
  validate_body_length! unless protocol_error?
318
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
319
507
  end
320
508
  decoded
321
509
  end
@@ -324,17 +512,44 @@ module Hyperion
324
512
  data = super
325
513
  # rubocop:disable Rails/Present
326
514
  if data && !data.empty?
327
- @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
328
527
  @request_body_bytes += data.bytesize
329
528
  end
330
529
  # rubocop:enable Rails/Present
331
530
  if frame.end_stream?
332
531
  validate_body_length! unless protocol_error?
333
532
  @request_complete = true
533
+ @streaming_input&.close_writer
334
534
  end
335
535
  data
336
536
  end
337
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
+
338
553
  # RFC 7540 §8.1.2 — request header validation. Sets
339
554
  # `@protocol_error_reason` on the first violation we hit; the dispatch
340
555
  # loop turns that into RST_STREAM PROTOCOL_ERROR.
@@ -422,6 +637,42 @@ module Hyperion
422
637
  @window_available.wait
423
638
  end
424
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
+
425
676
  private
426
677
 
427
678
  # Look up a pseudo-header by name (e.g. `:method`) by scanning the raw
@@ -800,7 +1051,10 @@ module Hyperion
800
1051
  ready_ids.uniq.each do |sid|
801
1052
  stream = server.streams[sid]
802
1053
  next unless stream.is_a?(RequestStream)
803
- 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?
804
1058
  next if stream.closed?
805
1059
  next if stream.instance_variable_get(:@hyperion_dispatched)
806
1060
 
@@ -1122,13 +1376,22 @@ module Hyperion
1122
1376
  hyperion_headers = regular
1123
1377
  hyperion_headers['host'] ||= authority if authority
1124
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
1125
1388
  request = Hyperion::Request.new(
1126
1389
  method: method,
1127
1390
  path: path,
1128
1391
  query_string: query || '',
1129
1392
  http_version: 'HTTP/2',
1130
1393
  headers: hyperion_headers,
1131
- body: stream.request_body,
1394
+ body: body,
1132
1395
  peer_address: peer_addr
1133
1396
  )
1134
1397
 
@@ -1173,19 +1436,22 @@ module Hyperion
1173
1436
  end
1174
1437
  end
1175
1438
 
1176
- # 2.12-F — gRPC support: bodies that respond to `:trailers` carry a
1439
+ # 2.12-F — gRPC unary support: bodies that respond to `:trailers` carry a
1177
1440
  # final HEADERS frame (with END_STREAM=1) right after the DATA frames.
1178
1441
  # The Rack 3 contract is "iterate body first, then call body.trailers"
1179
- # — so we materialise the payload, then *before* `body.close`
1180
- # (`Rack::BodyProxy` clears state on close) snapshot the trailers Hash.
1181
- # `nil` / empty Hash → no trailing frame. Non-Hash values are coerced
1182
- # to a Hash defensively; a misbehaving app must not be able to crash
1183
- # the connection.
1184
- payload = String.new(encoding: Encoding::ASCII_8BIT)
1185
- body_chunks.each { |c| payload << c.to_s }
1186
- response_trailers = collect_response_trailers(body_chunks)
1187
- body_chunks.close if body_chunks.respond_to?(:close)
1188
-
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
+ #
1189
1455
  # Hotfix C2: empty-body responses (RFC 7230 §3.3.3 — 204/304 + HEAD)
1190
1456
  # MUST NOT carry a DATA frame. Folding END_STREAM onto the HEADERS
1191
1457
  # frame collapses the response to one encoder-mutex acquisition and
@@ -1201,13 +1467,32 @@ module Hyperion
1201
1467
  writer_ctx.encode_mutex.synchronize do
1202
1468
  stream.send_headers(out_headers, ::Protocol::HTTP2::END_STREAM)
1203
1469
  end
1204
- elsif have_trailers?(response_trailers)
1205
- # gRPC / Rack-3-trailers path: HEADERS (no END_STREAM), DATA frames
1206
- # (no END_STREAM on last DATA), final HEADERS with END_STREAM=1.
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).
1207
1476
  writer_ctx.encode_mutex.synchronize { stream.send_headers(out_headers) }
1208
- send_body(stream, payload, writer_ctx, end_stream: false)
1209
- send_trailers(stream, response_trailers, writer_ctx)
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
1210
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)
1211
1496
  writer_ctx.encode_mutex.synchronize { stream.send_headers(out_headers) }
1212
1497
  send_body(stream, payload, writer_ctx)
1213
1498
  end
@@ -1310,6 +1595,48 @@ module Hyperion
1310
1595
  end
1311
1596
  end
1312
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
+
1313
1640
  # 2.12-F — pull a trailers Hash off the response body if Rack 3
1314
1641
  # `body.trailers` is implemented. Called AFTER the body has been
1315
1642
  # fully iterated (Rack 3 contract: trailers are computed by the body