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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1117 -0
- data/README.md +301 -674
- data/ext/hyperion_http/page_cache.c +538 -43
- data/ext/hyperion_http/parser.c +382 -51
- data/lib/hyperion/adapter/rack.rb +303 -4
- data/lib/hyperion/connection.rb +65 -4
- data/lib/hyperion/http2_handler.rb +348 -21
- data/lib/hyperion/metrics.rb +174 -38
- 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
|
@@ -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
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
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:
|
|
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
|
|
1180
|
-
# (`Rack::BodyProxy` clears state on close)
|
|
1181
|
-
#
|
|
1182
|
-
#
|
|
1183
|
-
# the
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
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
|
-
|
|
1205
|
-
|
|
1206
|
-
# (no END_STREAM
|
|
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
|
-
|
|
1209
|
-
|
|
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
|