Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce PRIORITY_UPDATE frame and priority tracking per stream. #25

Merged
merged 3 commits into from
Dec 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading