Skip to content

Commit

Permalink
Introduce PRIORITY_UPDATE frame and priority tracking per stream. (#25
Browse files Browse the repository at this point in the history
)
  • Loading branch information
ioquatix authored Dec 1, 2024
1 parent 4b680eb commit ea4b387
Show file tree
Hide file tree
Showing 12 changed files with 225 additions and 75 deletions.
1 change: 1 addition & 0 deletions fixtures/protocol/http2/a_frame.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# Released under the MIT License.
# Copyright, 2019-2024, by Samuel Williams.

require "socket"
require "protocol/http2/framer"

module Protocol
Expand Down
16 changes: 13 additions & 3 deletions lib/protocol/http2/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,25 @@ def send_connection_preface(settings = [])
@framer.write_connection_preface

# We don't support RFC7540 priorities:
settings = settings.to_a
settings << [Settings::NO_RFC7540_PRIORITIES, 1]
if settings.is_a?(Hash)
settings = settings.dup
else
settings = settings.to_h
end

unless settings.key?(Settings::NO_RFC7540_PRIORITIES)
settings = settings.dup
settings[Settings::NO_RFC7540_PRIORITIES] = 1
end

send_settings(settings)

yield if block_given?

read_frame do |frame|
raise ProtocolError, "First frame must be #{SettingsFrame}, but got #{frame.class}" unless frame.is_a? SettingsFrame
unless frame.is_a? SettingsFrame
raise ProtocolError, "First frame must be #{SettingsFrame}, but got #{frame.class}"
end
end
else
raise ProtocolError, "Cannot send connection preface in state #{@state}"
Expand Down
14 changes: 14 additions & 0 deletions lib/protocol/http2/connection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
require_relative "flow_controlled"

require "protocol/hpack"
require "protocol/http/header/priority"

module Protocol
module HTTP2
Expand Down Expand Up @@ -400,6 +401,19 @@ def receive_push_promise(frame)
raise ProtocolError, "Unable to receive push promise!"
end

def receive_priority_update(frame)
if frame.stream_id != 0
raise ProtocolError, "Invalid stream id: #{frame.stream_id}"
end

stream_id, value = frame.unpack

# Apparently you can set the priority of idle streams, but I'm not sure why that makes sense, so for now let's ignore it.
if stream = @streams[stream_id]
stream.priority = Protocol::HTTP::Header::Priority.new(value)
end
end

def client_stream_id?(id)
id.odd?
end
Expand Down
8 changes: 8 additions & 0 deletions lib/protocol/http2/framer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
require_relative "goaway_frame"
require_relative "window_update_frame"
require_relative "continuation_frame"
require_relative "priority_update_frame"

module Protocol
module HTTP2
Expand All @@ -29,6 +30,13 @@ module HTTP2
GoawayFrame,
WindowUpdateFrame,
ContinuationFrame,
nil,
nil,
nil,
nil,
nil,
nil,
PriorityUpdateFrame,
].freeze

# Default connection "fast-fail" preamble string as defined by the spec.
Expand Down
41 changes: 41 additions & 0 deletions lib/protocol/http2/priority_update_frame.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# frozen_string_literal: true

# Released under the MIT License.
# Copyright, 2019-2024, by Samuel Williams.

require_relative "frame"
require_relative "padded"
require_relative "continuation_frame"

module Protocol
module HTTP2
# The PRIORITY_UPDATE frame is used by clients to signal the initial priority of a response, or to reprioritize a response or push stream. It carries the stream ID of the response and the priority in ASCII text, using the same representation as the Priority header field value.
#
# +-+-------------+-----------------------------------------------+
# |R| Prioritized Stream ID (31) |
# +-+-----------------------------+-------------------------------+
# | Priority Field Value (*) ...
# +---------------------------------------------------------------+
#
class PriorityUpdateFrame < Frame
TYPE = 0x10
FORMAT = "N".freeze

def unpack
data = super

prioritized_stream_id = data.unpack1(FORMAT)

return prioritized_stream_id, data.byteslice(4, data.bytesize - 4)
end

def pack(prioritized_stream_id, data, **options)
super([prioritized_stream_id].pack(FORMAT) + data, **options)
end

def apply(connection)
connection.receive_priority_update(self)
end
end
end
end
12 changes: 10 additions & 2 deletions lib/protocol/http2/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,16 @@ def read_connection_preface(settings = [])
@framer.read_connection_preface

# We don't support RFC7540 priorities:
settings = settings.to_a
settings << [Settings::NO_RFC7540_PRIORITIES, 1]
if settings.is_a?(Hash)
settings = settings.dup
else
settings = settings.to_h
end

unless settings.key?(Settings::NO_RFC7540_PRIORITIES)
settings = settings.dup
settings[Settings::NO_RFC7540_PRIORITIES] = 1
end

send_settings(settings)

Expand Down
70 changes: 26 additions & 44 deletions lib/protocol/http2/settings_frame.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,22 @@ class Settings
:maximum_header_list_size=,
nil,
:enable_connect_protocol=,
:no_rfc7540_priorities=,
]

def initialize
# These limits are taken from the RFC:
# https://tools.ietf.org/html/rfc7540#section-6.5.2
@header_table_size = 4096
@enable_push = 1
@maximum_concurrent_streams = 0xFFFFFFFF
@initial_window_size = 0xFFFF # 2**16 - 1
@maximum_frame_size = 0x4000 # 2**14
@maximum_header_list_size = 0xFFFFFFFF
@enable_connect_protocol = 0
@no_rfc7540_priorities = 0
end

# Allows the sender to inform the remote endpoint of the maximum size of the header compression table used to decode header blocks, in octets.
attr_accessor :header_table_size

Expand Down Expand Up @@ -91,16 +105,18 @@ def enable_connect_protocol?
@enable_connect_protocol == 1
end

def initialize
# These limits are taken from the RFC:
# https://tools.ietf.org/html/rfc7540#section-6.5.2
@header_table_size = 4096
@enable_push = 1
@maximum_concurrent_streams = 0xFFFFFFFF
@initial_window_size = 0xFFFF # 2**16 - 1
@maximum_frame_size = 0x4000 # 2**14
@maximum_header_list_size = 0xFFFFFFFF
@enable_connect_protocol = 0
attr :no_rfc7540_priorities

def no_rfc7540_priorities= value
if value == 0 or value == 1
@no_rfc7540_priorities = value
else
raise ProtocolError, "Invalid value for no_rfc7540_priorities: #{value}"
end
end

def no_rfc7540_priorities?
@no_rfc7540_priorities == 1
end

def update(changes)
Expand All @@ -110,40 +126,6 @@ def update(changes)
end
end
end

def difference(other)
changes = []

if @header_table_size != other.header_table_size
changes << [HEADER_TABLE_SIZE, @header_table_size]
end

if @enable_push != other.enable_push
changes << [ENABLE_PUSH, @enable_push]
end

if @maximum_concurrent_streams != other.maximum_concurrent_streams
changes << [MAXIMUM_CONCURRENT_STREAMS, @maximum_concurrent_streams]
end

if @initial_window_size != other.initial_window_size
changes << [INITIAL_WINDOW_SIZE, @initial_window_size]
end

if @maximum_frame_size != other.maximum_frame_size
changes << [MAXIMUM_FRAME_SIZE, @maximum_frame_size]
end

if @maximum_header_list_size != other.maximum_header_list_size
changes << [MAXIMUM_HEADER_LIST_SIZE, @maximum_header_list_size]
end

if @enable_connect_protocol != other.enable_connect_protocol
changes << [ENABLE_CONNECT_PROTOCOL, @enable_connect_protocol]
end

return changes
end
end

class PendingSettings
Expand Down
5 changes: 5 additions & 0 deletions lib/protocol/http2/stream.rb
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ def initialize(connection, id, state = :idle)

@local_window = Window.new(@connection.local_settings.initial_window_size)
@remote_window = Window.new(@connection.remote_settings.initial_window_size)

@priority = nil
end

# The connection this stream belongs to.
Expand All @@ -90,6 +92,9 @@ def initialize(connection, id, state = :idle)
attr :local_window
attr :remote_window

# @attribute [Protocol::HTTP::Header::Priority | Nil] the priority of the stream.
attr_accessor :priority

def maximum_frame_size
@connection.available_frame_size
end
Expand Down
23 changes: 22 additions & 1 deletion test/protocol/http2/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@

client_settings_frame = framer.read_frame
expect(client_settings_frame).to be_a Protocol::HTTP2::SettingsFrame
expect(client_settings_frame.unpack).to be == settings
expect(client_settings_frame.unpack).to be == settings + [[Protocol::HTTP2::Settings::NO_RFC7540_PRIORITIES, 1]]

# Fake (empty) server settings:
server_settings_frame = Protocol::HTTP2::SettingsFrame.new
Expand All @@ -52,6 +52,27 @@
expect(client.local_settings.header_table_size).to be == 1024
end

it "should fail if the server does not reply with settings frame" do
data_frame = Protocol::HTTP2::DataFrame.new
data_frame.pack("Hello, World!")

expect do
client.send_connection_preface(settings) do
framer.write_frame(data_frame)
end
end.to raise_exception(Protocol::HTTP2::ProtocolError, message: be =~ /First frame must be Protocol::HTTP2::SettingsFrame/)
end

it "should send connection preface with no RFC7540 priorities" do
server_settings_frame = client.send_connection_preface({}) do
client_settings_frame = server.read_connection_preface({})

expect(client_settings_frame.unpack).to be == [[Protocol::HTTP2::Settings::NO_RFC7540_PRIORITIES, 1]]
end

expect(server_settings_frame.unpack).to be == [[Protocol::HTTP2::Settings::NO_RFC7540_PRIORITIES, 1]]
end

it "can generate a stream id" do
id = client.next_stream_id
expect(id).to be == 1
Expand Down
65 changes: 65 additions & 0 deletions test/protocol/http2/priority_update_frame.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# frozen_string_literal: true
# Released under the MIT License.
# Copyright, 2019-2024, by Samuel Williams.

require "protocol/http2/priority_update_frame"

require "protocol/http2/a_frame"
require "protocol/http2/connection_context"

describe Protocol::HTTP2::PriorityUpdateFrame do
let(:frame) {subject.new}

it_behaves_like Protocol::HTTP2::AFrame do
before do
frame.pack 1, "u=1, i"
end

it "applies to the connection" do
expect(frame).to be(:connection?)
end
end

with "client/server connection" do
include_context Protocol::HTTP2::ConnectionContext

def before
client.open!
server.open!

super
end

it "fails with protocol error if stream id is not zero" do
# This isn't a valid for the frame stream_id:
frame.stream_id = 1

# This is a valid stream payload:
frame.pack stream.id, "u=1, i"

expect do
frame.apply(server)
end.to raise_exception(Protocol::HTTP2::ProtocolError)
end

let(:stream) {client.create_stream}

it "updates the priority of a stream" do
stream.send_headers [["content-type", "text/plain"]]
server.read_frame

expect(server).to receive(:receive_priority_update)
expect(stream.priority).to be_nil

frame.pack stream.id, "u=1, i"
client.write_frame(frame)

inform server.read_frame

server_stream = server.streams[stream.id]
expect(server_stream).not.to be_nil

expect(server_stream.priority).to be == ["u=1", "i"]
end
end
end
2 changes: 1 addition & 1 deletion test/protocol/http2/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
# The server immediately sends its own settings frame...
frame = framer.read_frame
expect(frame).to be_a Protocol::HTTP2::SettingsFrame
expect(frame.unpack).to be == server_settings
expect(frame.unpack).to be == server_settings + [[Protocol::HTTP2::Settings::NO_RFC7540_PRIORITIES, 1]]

# And then it acknowledges the client settings:
frame = framer.read_frame
Expand Down
Loading

0 comments on commit ea4b387

Please sign in to comment.