Skip to content

Commit

Permalink
Stateful connection management. (#39)
Browse files Browse the repository at this point in the history
  • Loading branch information
ioquatix authored Sep 18, 2024
1 parent e88da02 commit 88b8e55
Show file tree
Hide file tree
Showing 2 changed files with 188 additions and 22 deletions.
74 changes: 52 additions & 22 deletions lib/protocol/http1/connection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,26 @@ def initialize(stream, persistent: true, state: :idle)
# State transition methods use a trailing "!".
attr_accessor :state

def idle?
@state == :idle
end

def open?
@state == :open
end

def half_closed_local?
@state == :half_closed_local
end

def half_closed_remote?
@state == :half_closed_remote
end

def closed?
@state == :closed
end

# The number of requests processed.
attr :count

Expand Down Expand Up @@ -185,7 +205,9 @@ def write_request(authority, method, path, version, headers)
end

def write_response(version, status, headers, reason = Reason::DESCRIPTIONS[status])
raise ProtocolError, "Cannot write response in #{@state}!" unless @state == :open
unless @state == :open or @state == :half_closed_remote
raise ProtocolError, "Cannot write response in #{@state}!"
end

# Safari WebSockets break if no reason is given:
@stream.write("#{version} #{status} #{reason}\r\n")
Expand All @@ -194,7 +216,9 @@ def write_response(version, status, headers, reason = Reason::DESCRIPTIONS[statu
end

def write_interim_response(version, status, headers, reason = Reason::DESCRIPTIONS[status])
raise ProtocolError, "Cannot write interim response!" unless @state == :open
unless @state == :open or @state == :half_closed_remote
raise ProtocolError, "Cannot write interim response in #{@state}!"
end

@stream.write("#{version} #{status} #{reason}\r\n")

Expand Down Expand Up @@ -268,6 +292,10 @@ def read_request

body = read_request_body(method, headers)

unless body
self.receive_end_stream!
end

@count += 1

return headers.delete(HOST), method, path, version, headers, body
Expand All @@ -281,18 +309,30 @@ def read_response_line
return version, status, reason
end

private def interim_status?(status)
status != 101 and status >= 100 and status < 200
end

def read_response(method)
raise ProtocolError, "Cannot read response in #{@state}!" unless @state == :open
unless @state == :open or @state == :half_closed_local
raise ProtocolError, "Cannot read response in #{@state}!"
end

version, status, reason = read_response_line

headers = read_headers

@persistent = persistent?(version, method, headers)

body = read_response_body(method, status, headers)

@count += 1
unless interim_status?(status)
body = read_response_body(method, status, headers)

unless body
self.receive_end_stream!
end

@count += 1
end

return version, status, reason, headers, body
end
Expand Down Expand Up @@ -450,26 +490,16 @@ def write_body_and_close(body, head)
@stream.close_write
end

def half_closed_local!
raise ProtocolError, "Cannot close local in #{@state}!" unless @state == :open

@state = :half_closed_local
end

def half_closed_remote!
raise ProtocolError, "Cannot close remote in #{@state}!" unless @state == :open

@state = :half_closed_remote
end

def idle!
@state = :idle
end

def closed!
raise ProtocolError, "Cannot close in #{@state}!" unless @state == :half_closed_local or @state == :half_closed_remote
unless @state == :half_closed_local or @state == :half_closed_remote
raise ProtocolError, "Cannot close in #{@state}!"
end

if self.persistent?
if @persistent
self.idle!
else
@state = :closed
Expand All @@ -478,7 +508,7 @@ def closed!

def send_end_stream!
if @state == :open
self.half_closed_local!
@state = :half_closed_local
elsif @state == :half_closed_remote
self.closed!
else
Expand Down Expand Up @@ -521,7 +551,7 @@ def write_body(version, body, head = false, trailer = nil)

def receive_end_stream!
if @state == :open
self.half_closed_remote!
@state = :half_closed_remote
elsif @state == :half_closed_local
self.closed!
else
Expand Down
136 changes: 136 additions & 0 deletions test/protocol/http1/connection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

require "protocol/http1/connection"
require "protocol/http/body/buffered"
require "protocol/http/body/writable"

require "connection_context"

Expand Down Expand Up @@ -547,4 +548,139 @@
end.to raise_exception(Protocol::HTTP1::BadHeader)
end
end

it "enters half-closed (local) state after writing response body" do
expect(client).to be(:idle?)
client.write_request("localhost", "GET", "/", "HTTP/1.1", {})
expect(client).to be(:open?)
body = Protocol::HTTP::Body::Buffered.new(["Hello World"])
client.write_body("HTTP/1.1", body)
expect(client).to be(:half_closed_local?)

expect(server).to be(:idle?)
request = server.read_request
server.write_response("HTTP/1.1", 200, {}, nil)
server.write_body("HTTP/1.1", nil)
expect(server).to be(:half_closed_local?)
end

it "returns back to idle state" do
expect(client).to be(:idle?)
client.write_request("localhost", "GET", "/", "HTTP/1.1", {})
expect(client).to be(:open?)
client.write_body("HTTP/1.1", nil)
expect(client).to be(:half_closed_local?)

expect(server).to be(:idle?)
request = server.read_request
expect(request).to be == ["localhost", "GET", "/", "HTTP/1.1", {}, nil]
expect(server).to be(:half_closed_remote?)

server.write_response("HTTP/1.1", 200, {}, [])
server.write_body("HTTP/1.1", nil)
expect(server).to be(:idle?)

response = client.read_response("GET")
expect(client).to be(:idle?)
end

it "transitions to the closed state when using connection: close response body" do
expect(client).to be(:idle?)
client.write_request("localhost", "GET", "/", "HTTP/1.0", {})
expect(client).to be(:open?)

client.write_body("HTTP/1.0", nil)
expect(client).to be(:half_closed_local?)

expect(server).to be(:idle?)
request = server.read_request
expect(server).to be(:half_closed_remote?)

server.write_response("HTTP/1.0", 200, {}, [])

# Length is unknown, and HTTP/1.0 does not support chunked encoding, so this will close the connection:
body = Protocol::HTTP::Body::Writable.new
body.write "Hello World"
body.close_write

server.write_body("HTTP/1.0", body)
expect(server).not.to be(:persistent)
expect(server).to be(:closed?)

response = client.read_response("GET")
body = response.last
expect(body.join).to be == "Hello World"
expect(client).to be(:closed?)
end

it "can't write a request in the closed state" do
client.state = :closed

expect do
client.write_request("localhost", "GET", "/", "HTTP/1.0", {})
end.to raise_exception(Protocol::HTTP1::ProtocolError)
end

it "can't read a response in the closed state" do
client.state = :closed

expect do
client.read_response("GET")
end.to raise_exception(Protocol::HTTP1::ProtocolError)
end

it "can't write a response in the closed state" do
server.state = :closed

expect do
server.write_response("HTTP/1.0", 200, {}, nil)
end.to raise_exception(Protocol::HTTP1::ProtocolError)
end

it "can't read a request in the closed state" do
server.state = :closed

expect do
server.read_request
end.to raise_exception(Protocol::HTTP1::ProtocolError)
end

it "can't enter the closed state from the idle state" do
expect do
client.closed!
end.to raise_exception(Protocol::HTTP1::ProtocolError)
end

it "can't write response body without writing response" do
expect do
server.write_body("HTTP/1.0", nil)
end.to raise_exception(Protocol::HTTP1::ProtocolError)
end

it "can't write request body without writing request" do
expect do
client.write_body("HTTP/1.0", nil)
end.to raise_exception(Protocol::HTTP1::ProtocolError)
end

it "can't read request body without reading request" do
# Fake empty chunked encoded body:
client.stream.write("0\r\n\r\n")

body = server.read_request_body("POST", {"transfer-encoding" => ["chunked"]})

expect(body).to be_a(Protocol::HTTP1::Body::Chunked)

expect do
body.join
end.to raise_exception(Protocol::HTTP1::ProtocolError)
end

it "can't write interim response in the closed state" do
server.state = :closed

expect do
server.write_interim_response("HTTP/1.0", 100, {})
end.to raise_exception(Protocol::HTTP1::ProtocolError)
end
end

0 comments on commit 88b8e55

Please sign in to comment.