connectrpc-async 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +542 -0
- data/lib/connectrpc/async/application.rb +173 -0
- data/lib/connectrpc/async/completion_join.rb +249 -0
- data/lib/connectrpc/async/connection.rb +711 -0
- data/lib/connectrpc/async/connection_manager.rb +358 -0
- data/lib/connectrpc/async/connection_policy.rb +60 -0
- data/lib/connectrpc/async/host.rb +171 -0
- data/lib/connectrpc/async/http1_connection.rb +144 -0
- data/lib/connectrpc/async/http1_exchange.rb +27 -0
- data/lib/connectrpc/async/http1_stream.rb +76 -0
- data/lib/connectrpc/async/http1_transport.rb +642 -0
- data/lib/connectrpc/async/ingress.rb +100 -0
- data/lib/connectrpc/async/internal.rb +270 -0
- data/lib/connectrpc/async/server_session.rb +457 -0
- data/lib/connectrpc/async/stream_exchange.rb +324 -0
- data/lib/connectrpc/async/tls.rb +124 -0
- data/lib/connectrpc/async/tls_configuration.rb +309 -0
- data/lib/connectrpc/async/transport.rb +488 -0
- data/lib/connectrpc/async/version.rb +7 -0
- data/lib/connectrpc/async.rb +39 -0
- data/sig/connectrpc-async.rbs +165 -0
- data/sig/manifest.yaml +4 -0
- metadata +202 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "protocol/http1/connection"
|
|
4
|
+
require_relative "http1_stream"
|
|
5
|
+
|
|
6
|
+
module ConnectRPC
|
|
7
|
+
module Async
|
|
8
|
+
module Internal
|
|
9
|
+
class HTTP1Connection < ::Protocol::HTTP1::Connection
|
|
10
|
+
STATUS = %r{\AHTTP/1\.1 ([0-9]{3}) [\x09\x20-\x7e\x80-\xff]*\z}n
|
|
11
|
+
|
|
12
|
+
attr_reader :response_version
|
|
13
|
+
|
|
14
|
+
def read_head(method)
|
|
15
|
+
raise IOError, "HTTP/1 connection is not open" unless open? || half_closed_local?
|
|
16
|
+
|
|
17
|
+
interim = 0
|
|
18
|
+
loop do
|
|
19
|
+
line = read_line
|
|
20
|
+
match = STATUS.match(line)
|
|
21
|
+
raise IOError, "Invalid HTTP/1.1 status line" unless match
|
|
22
|
+
|
|
23
|
+
@response_version = "HTTP/1.1"
|
|
24
|
+
status = match[1].to_i
|
|
25
|
+
headers = read_headers
|
|
26
|
+
leading = snapshot(headers)
|
|
27
|
+
validate_framing!(leading)
|
|
28
|
+
raise IOError, "HTTP upgrade is unsupported" if status == 101
|
|
29
|
+
|
|
30
|
+
# The pinned native parser compares the transfer token literally.
|
|
31
|
+
# Our validation already permits exactly one case-insensitive token.
|
|
32
|
+
headers["transfer-encoding"] = "chunked" if leading.key?("transfer-encoding")
|
|
33
|
+
@persistent &&= persistent?(@response_version, method, headers)
|
|
34
|
+
if status.between?(100, 199)
|
|
35
|
+
interim += 1
|
|
36
|
+
raise IOError, "Too many interim responses" if interim > 16
|
|
37
|
+
|
|
38
|
+
next
|
|
39
|
+
end
|
|
40
|
+
body = read_response_body(method, status, headers)
|
|
41
|
+
receive_end_stream! unless body
|
|
42
|
+
@count += 1
|
|
43
|
+
return [StreamHead.new(status: status, headers: leading), body]
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def viable? = idle? && persistent && stream.viable?
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def snapshot(headers)
|
|
52
|
+
result = headers.header.each_with_object({}) do |(name, value), result|
|
|
53
|
+
(result[name.downcase] ||= []) << value.dup.freeze
|
|
54
|
+
end
|
|
55
|
+
result.each_value(&:freeze)
|
|
56
|
+
result.freeze
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Validate the original fields before the native parser deletes or
|
|
60
|
+
# combines them. Only a single chunked transfer coding is supported.
|
|
61
|
+
def validate_framing!(headers)
|
|
62
|
+
lengths = headers["content-length"]
|
|
63
|
+
coding = headers["transfer-encoding"]
|
|
64
|
+
if lengths && (lengths.size != 1 || !/\A[0-9]+\z/.match?(lengths.first))
|
|
65
|
+
raise IOError, "Invalid HTTP Content-Length"
|
|
66
|
+
end
|
|
67
|
+
return unless coding && (lengths || coding.size != 1 || !coding.first.strip.casecmp?("chunked"))
|
|
68
|
+
|
|
69
|
+
raise IOError, "Unsupported HTTP transfer framing"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
class HTTP1Output < OutputBody
|
|
74
|
+
def initialize(dispatched)
|
|
75
|
+
super()
|
|
76
|
+
@dispatched = dispatched
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def ready? = false
|
|
80
|
+
|
|
81
|
+
def read
|
|
82
|
+
@dispatched.resolve(nil)
|
|
83
|
+
super
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# One resource per reserved slot, including setup and destructive joins.
|
|
88
|
+
class HTTP1Resource
|
|
89
|
+
attr_accessor :raw, :socket, :connection, :setup
|
|
90
|
+
|
|
91
|
+
def finished? = !!@finished
|
|
92
|
+
def finish! = @finished = true
|
|
93
|
+
|
|
94
|
+
def abort
|
|
95
|
+
pending_tls = @socket.is_a?(OpenSSL::SSL::SSLSocket) && @connection.nil?
|
|
96
|
+
return if @raw.nil? || @raw.closed?
|
|
97
|
+
|
|
98
|
+
# Ruby 4 marks raw closed before waiting for blocked fibers, before
|
|
99
|
+
# closing the descriptor. Transfer descriptor ownership first so an
|
|
100
|
+
# interruption cannot strand it behind raw.closed?. Retain the same
|
|
101
|
+
# descriptor until every native waiter and lifecycle child has joined.
|
|
102
|
+
# This handle has no readers/writers and is never used for transport IO.
|
|
103
|
+
@raw_close_io ||= ::IO.for_fd(@raw.fileno)
|
|
104
|
+
@raw.autoclose = false
|
|
105
|
+
# Ruby 4 raw close can wait on SSL setup. Shutdown wakes it first.
|
|
106
|
+
@raw.shutdown(Socket::SHUT_RDWR)
|
|
107
|
+
rescue IOError, SystemCallError
|
|
108
|
+
nil
|
|
109
|
+
ensure
|
|
110
|
+
begin
|
|
111
|
+
Internal.raw_close(@raw)
|
|
112
|
+
ensure
|
|
113
|
+
# Ruby 4 raw close of pending SSL setup can leave its abandoned
|
|
114
|
+
# EOF in OpenSSL's thread-local queue, including on interruption.
|
|
115
|
+
# Drain only this destructive setup boundary before sibling TLS.
|
|
116
|
+
# The final post-join abort also drains a late native waiter EOF.
|
|
117
|
+
OpenSSL.errors if pending_tls
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Only the final owner calls this, after all accessing tasks have joined.
|
|
122
|
+
def close
|
|
123
|
+
interruption = nil
|
|
124
|
+
[@connection, @socket, @raw_close_io].each do |owned|
|
|
125
|
+
next unless owned
|
|
126
|
+
next if owned.equal?(@raw_close_io) && owned.closed?
|
|
127
|
+
|
|
128
|
+
begin
|
|
129
|
+
owned.close
|
|
130
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- complete every close stage before propagating the first interruption
|
|
131
|
+
interruption ||= e
|
|
132
|
+
# Native wrappers can be interrupted before completing their own
|
|
133
|
+
# cleanup even when raw.closed? is true. Retry interruptions, but
|
|
134
|
+
# let ordinary wrapper failures proceed to the remaining stages.
|
|
135
|
+
retry if !e.is_a?(StandardError) && !owned.equal?(@raw_close_io)
|
|
136
|
+
retry if owned.equal?(@raw_close_io) && !owned.closed?
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
raise interruption if interruption
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConnectRPC
|
|
4
|
+
module Async
|
|
5
|
+
module Internal
|
|
6
|
+
class HTTP1Exchange
|
|
7
|
+
def initialize(transport, operation)
|
|
8
|
+
@transport = transport
|
|
9
|
+
@operation = operation
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def response_head(timeout: nil) = invoke(:response_head, timeout: timeout)
|
|
13
|
+
def read(length, timeout: nil) = invoke(:read, length, timeout: timeout)
|
|
14
|
+
def write(bytes) = invoke(:write, bytes)
|
|
15
|
+
def close_send = invoke(:close_send)
|
|
16
|
+
def finish(timeout: nil) = invoke(:finish, timeout: timeout)
|
|
17
|
+
def abort = invoke(:abort)
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def invoke(method, *, **)
|
|
22
|
+
@transport.send(method, @operation, *, **)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/stream/buffered"
|
|
4
|
+
|
|
5
|
+
module ConnectRPC
|
|
6
|
+
module Async
|
|
7
|
+
module Internal
|
|
8
|
+
# Unlike IO::Stream's coupled read path, an H1 response must remain
|
|
9
|
+
# readable while the request writer holds its output mutex.
|
|
10
|
+
class HTTP1Stream < ::IO::Stream::Buffered
|
|
11
|
+
def initialize(io)
|
|
12
|
+
super(io, minimum_read_size: 16_384, maximum_read_size: 16_384)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def readpartial(length, buffer = nil)
|
|
16
|
+
super([length, 16_384].min, buffer)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def close_read
|
|
20
|
+
# Destructive retirement closes raw IO before unfinished body cleanup.
|
|
21
|
+
super unless closed?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def viable?
|
|
25
|
+
return false if closed? || @finished || !@read_buffer.empty?
|
|
26
|
+
|
|
27
|
+
result = sysread_nonblock(1, @input_buffer)
|
|
28
|
+
result == :wait_readable
|
|
29
|
+
rescue IOError, SystemCallError, OpenSSL::SSL::SSLError
|
|
30
|
+
false
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# Keep pinned buffer/EOF behavior without acquiring the writer's mutex.
|
|
36
|
+
def fill_read_buffer(size = @minimum_read_size) # rubocop:disable Naming/PredicateMethod -- Pinned upstream override.
|
|
37
|
+
size = [size, @maximum_read_size].min
|
|
38
|
+
if @read_buffer.empty?
|
|
39
|
+
return true if sysread(size, @read_buffer)
|
|
40
|
+
elsif (chunk = sysread(size, @input_buffer))
|
|
41
|
+
@read_buffer << chunk
|
|
42
|
+
return true
|
|
43
|
+
end
|
|
44
|
+
@finished = true
|
|
45
|
+
false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Buffered#sysread in io-stream 0.14 swallows SSL errors other than
|
|
49
|
+
# unexpected EOF. Preserve those native failures rather than returning
|
|
50
|
+
# a clean close-delimited HTTP EOF.
|
|
51
|
+
def sysread(size, buffer)
|
|
52
|
+
loop do
|
|
53
|
+
result = @io.read_nonblock(size, buffer, exception: false)
|
|
54
|
+
case result
|
|
55
|
+
when :wait_readable
|
|
56
|
+
@io.wait_readable(@io.timeout) or raise ::IO::TimeoutError, "read timeout"
|
|
57
|
+
when :wait_writable
|
|
58
|
+
@io.wait_writable(@io.timeout) or raise ::IO::TimeoutError, "write timeout"
|
|
59
|
+
else
|
|
60
|
+
return result
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
rescue OpenSSL::SSL::SSLError => e
|
|
64
|
+
raise ::IO::Stream::ConnectionResetError, "Connection reset by peer!" if
|
|
65
|
+
e.message.include?("unexpected eof while reading")
|
|
66
|
+
|
|
67
|
+
raise
|
|
68
|
+
rescue Errno::ECONNRESET
|
|
69
|
+
raise ::IO::Stream::ConnectionResetError, "Connection reset by peer!"
|
|
70
|
+
rescue Errno::EBADF
|
|
71
|
+
raise IOError, "stream closed"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|