Class: Hyperion::Http2Handler::RequestStream

Inherits:
Protocol::HTTP2::Stream
  • Object
show all
Defined in:
lib/hyperion/http2_handler.rb

Overview

Per-stream subclass that captures decoded request pseudo-headers, regular headers, and any DATA frame body bytes for later dispatch. Also exposes a ‘window_available` notification fan-out so the response-writer fiber can sleep until WINDOW_UPDATE arrives.

Constant Summary collapse

VALID_REQUEST_PSEUDO_HEADERS =

RFC 7540 §8.1.2.1 — the only pseudo-headers a server MUST accept on a request. Anything else (notably ‘:status`, which is response-only, or an unknown `:foo`) is a malformed request that we reject with PROTOCOL_ERROR.

%w[:method :path :scheme :authority].freeze
FORBIDDEN_HEADERS =

RFC 7540 §8.1.2.2 — these connection-specific headers MUST NOT appear in HTTP/2 requests; their semantics are folded into HTTP/2 framing.

%w[connection transfer-encoding keep-alive upgrade proxy-connection].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initializeRequestStream

Returns a new instance of RequestStream.



275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
# File 'lib/hyperion/http2_handler.rb', line 275

def initialize(*)
  super
  @request_headers = []
  # 2.12-F — gRPC carries opaque protobuf bytes
  # ([1-byte compressed flag][4-byte length-prefix][message bytes]) in the
  # request body. The default UTF-8 encoding on a `+''` literal would
  # break valid_encoding? on byte sequences that don't form UTF-8
  # codepoints, leading to a Rack app reading `body.string` and getting
  # a String that misreports its bytesize / corrupts when string-
  # interpolated. ASCII_8BIT (binary) preserves bytes verbatim and is
  # the encoding gRPC Ruby clients expect. Same change is applied to
  # the HTTP/1.1 path as a separate concern; see Connection.
  @request_body = String.new(encoding: Encoding::ASCII_8BIT)
  @request_body_bytes = 0
  @request_complete = false
  @window_available = ::Async::Notification.new
  @protocol_error_reason = nil
  @declared_content_length = nil
end

Instance Attribute Details

#protocol_error_reasonObject (readonly)

Returns the value of attribute protocol_error_reason.



273
274
275
# File 'lib/hyperion/http2_handler.rb', line 273

def protocol_error_reason
  @protocol_error_reason
end

#request_bodyObject (readonly)

Returns the value of attribute request_body.



273
274
275
# File 'lib/hyperion/http2_handler.rb', line 273

def request_body
  @request_body
end

#request_completeObject (readonly)

Returns the value of attribute request_complete.



273
274
275
# File 'lib/hyperion/http2_handler.rb', line 273

def request_complete
  @request_complete
end

#request_headersObject (readonly)

Returns the value of attribute request_headers.



273
274
275
# File 'lib/hyperion/http2_handler.rb', line 273

def request_headers
  @request_headers
end

Instance Method Details

#process_data(frame) ⇒ Object



323
324
325
326
327
328
329
330
331
332
333
334
335
336
# File 'lib/hyperion/http2_handler.rb', line 323

def process_data(frame)
  data = super
  # rubocop:disable Rails/Present
  if data && !data.empty?
    @request_body << data
    @request_body_bytes += data.bytesize
  end
  # rubocop:enable Rails/Present
  if frame.end_stream?
    validate_body_length! unless protocol_error?
    @request_complete = true
  end
  data
end

#process_headers(frame) ⇒ Object



302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
# File 'lib/hyperion/http2_handler.rb', line 302

def process_headers(frame)
  decoded = super
  # First HEADERS frame on a stream carries the request header block;
  # any later HEADERS frame is trailers (§8.1) and we deliberately do
  # not re-validate (re-running the validator would see the original
  # request pseudo-headers plus the new trailer block and falsely flag
  # them as misordered).
  first_block = @request_headers.empty?
  # decoded is an Array of [name, value] pairs (HPACK output).
  decoded.each { |pair| @request_headers << pair }
  # Run RFC 7540 §8.1.2 validation as soon as we have a complete header
  # block. We do it here (not at end_stream) so the dispatcher sees the
  # error flag before it spawns a fiber for the request.
  validate_request_headers! if first_block && !protocol_error?
  if frame.end_stream?
    validate_body_length! unless protocol_error?
    @request_complete = true
  end
  decoded
end

#protocol_error?Boolean

Used by the dispatch loop to decide whether to invoke the app or send RST_STREAM PROTOCOL_ERROR. Set by ‘validate_request_headers!` and `validate_body_length!`.

Returns:

  • (Boolean)


298
299
300
# File 'lib/hyperion/http2_handler.rb', line 298

def protocol_error?
  !@protocol_error_reason.nil?
end

#validate_body_length!Object

RFC 7540 §8.1.2.6 — if ‘content-length` was advertised, the actual number of DATA bytes received (across all DATA frames) MUST match.



400
401
402
403
404
405
406
407
# File 'lib/hyperion/http2_handler.rb', line 400

def validate_body_length!
  return if @declared_content_length.nil?
  return if @declared_content_length == @request_body_bytes

  fail_validation!(
    "content-length mismatch: declared #{@declared_content_length}, received #{@request_body_bytes}"
  )
end

#validate_request_headers!Object

RFC 7540 §8.1.2 — request header validation. Sets ‘@protocol_error_reason` on the first violation we hit; the dispatch loop turns that into RST_STREAM PROTOCOL_ERROR.



341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
# File 'lib/hyperion/http2_handler.rb', line 341

def validate_request_headers!
  seen_regular = false
  pseudo_counts = Hash.new(0)
  @request_headers.each do |pair|
    name, value = pair
    name = name.to_s
    if name.start_with?(':')
      # §8.1.2.1: pseudo-headers MUST precede regular headers.
      return fail_validation!('pseudo-header after regular header') if seen_regular
      # §8.1.2.1: only the four request pseudo-headers are valid; in
      # particular, `:status` is response-only.
      unless VALID_REQUEST_PSEUDO_HEADERS.include?(name)
        return fail_validation!("invalid request pseudo-header: #{name}")
      end

      pseudo_counts[name] += 1
    else
      seen_regular = true
      # §8.1.2: header names must be lowercase in HTTP/2.
      return fail_validation!('uppercase header name') if /[A-Z]/.match?(name)
      # §8.1.2.2: connection-specific headers are forbidden.
      return fail_validation!("forbidden connection-specific header: #{name}") if FORBIDDEN_HEADERS.include?(name)
      # §8.1.2.2: TE may only carry the value `trailers`.
      if name == 'te' && value.to_s.downcase.strip != 'trailers'
        return fail_validation!('TE header with non-trailers value')
      end

      # Track declared content-length for later body-byte cross-check.
      @declared_content_length = value.to_s.to_i if name == 'content-length'
    end
  end

  # §8.1.2.3: every pseudo-header may appear at most once.
  pseudo_counts.each do |name, count|
    return fail_validation!("duplicated pseudo-header: #{name}") if count > 1
  end

  method = pseudo_value(':method')
  # CONNECT (§8.3) has its own rules; everything else MUST carry
  # :method, :scheme and a non-empty :path.
  if method == 'CONNECT'
    return fail_validation!('CONNECT with :scheme') if pseudo_value(':scheme')
    return fail_validation!('CONNECT with :path') if pseudo_value(':path')
    return fail_validation!('CONNECT without :authority') unless pseudo_value(':authority')
  else
    return fail_validation!('missing :method') if method.nil? || method.empty?

    scheme = pseudo_value(':scheme')
    return fail_validation!('missing :scheme') if scheme.nil? || scheme.empty?

    path = pseudo_value(':path')
    return fail_validation!('missing or empty :path') if path.nil? || path.empty?
  end

  nil
end

#wait_for_windowObject

Block the calling fiber until the remote window grows. Cheap no-op signal each time ‘window_updated` fires; the caller re-checks available_frame_size in a loop.



421
422
423
# File 'lib/hyperion/http2_handler.rb', line 421

def wait_for_window
  @window_available.wait
end

#window_updated(size) ⇒ Object

Called by protocol-http2 whenever the remote peer’s flow-control window opens up — either via a stream-level WINDOW_UPDATE or via the connection-level fan-out in ‘Connection#consume_window`. We poke the notification so any fiber waiting in `wait_for_window` resumes.



413
414
415
416
# File 'lib/hyperion/http2_handler.rb', line 413

def window_updated(size)
  @window_available.signal
  super
end