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.
@@ -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