ruby-utcp 1.1.5 → 1.1.6

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.
@@ -2,27 +2,45 @@
2
2
 
3
3
  module UTCP
4
4
  class WebRTCPeer
5
- def initialize(template)
6
- gem "webrtc-ruby", ">= 1.0.0"
7
- require "webrtc"
8
- WebRTC.init
5
+ def initialize(template, connection: nil)
9
6
  @template = template
10
7
  @mutex = Mutex.new
11
8
  @condition = ConditionVariable.new
12
- @responses = {}
9
+ @pending = {}
10
+ @io_mutex = Mutex.new
11
+ @closed = false
13
12
  @candidates = []
14
- configuration = { disable_auto_negotiation: true }
13
+ configuration = { disable_auto_negotiation: true, max_message_size: template.max_response_bytes }
15
14
  configuration[:ice_servers] = template.ice_servers unless template.ice_servers.empty?
16
- @connection = WebRTC::RTCPeerConnection.new(configuration)
15
+ unless connection
16
+ gem "webrtc-ruby", ">= 1.0.0"
17
+ require "webrtc"
18
+ require_relative "webrtc_native_cleanup"
19
+ WebRTC.init
20
+ @native_cleanup = WebRTCNativeCleanup.new
21
+ connection = WebRTC::RTCPeerConnection.new(configuration)
22
+ end
23
+ @connection = connection
17
24
  install_candidate_handler
18
25
  @channel = @connection.create_data_channel(template.data_channel_name)
19
26
  install_channel_handlers
20
27
  rescue LoadError => error
28
+ close
21
29
  raise MissingDependencyError,
22
30
  "WebRTC requires the optional 'webrtc-ruby' gem and libdatachannel: #{error.message}"
31
+ rescue StandardError
32
+ close
33
+ raise
23
34
  end
24
35
 
25
36
  def connect
37
+ @io_mutex.synchronize do
38
+ @mutex.synchronize { assert_open! }
39
+ connect_peer
40
+ end
41
+ end
42
+
43
+ def connect_peer
26
44
  offer = @connection.create_offer.await
27
45
  # webrtc-ruby creates and installs the local offer in one native operation.
28
46
  wait_for_ice_gathering
@@ -34,38 +52,82 @@ module UTCP
34
52
  rescue StandardError
35
53
  nil
36
54
  end
37
- @candidates.each { |candidate| post_candidate(candidate) }
55
+ @mutex.synchronize { @candidates.dup }.each { |candidate| post_candidate(candidate) }
38
56
  wait_for_channel
39
57
  response
40
58
  end
41
59
 
42
60
  def request(payload, timeout: @template.timeout)
43
61
  identifier = payload.fetch("id")
44
- @channel.send_text(JSON.generate(payload))
45
62
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
63
+ state = { deadline: deadline }
46
64
  @mutex.synchronize do
47
- until @responses.key?(identifier)
65
+ assert_open!
66
+ raise ValidationError, "Duplicate WebRTC request id" if @pending.key?(identifier)
67
+ if @pending.length >= @template.max_pending_requests
68
+ raise ToolCallError, "WebRTC exceeds max_pending_requests"
69
+ end
70
+ @pending[identifier] = state
71
+ end
72
+ @io_mutex.synchronize do
73
+ @mutex.synchronize { assert_open! }
74
+ @channel.send_text(JSON.generate(payload))
75
+ end
76
+ @mutex.synchronize do
77
+ loop do
78
+ assert_open!
79
+ return state[:response] if state.key?(:response)
48
80
  remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
49
81
  raise TimeoutError, "WebRTC response timed out" unless remaining.positive?
50
82
  @condition.wait(@mutex, remaining)
51
83
  end
52
- @responses.delete(identifier)
53
84
  end
85
+ ensure
86
+ @mutex.synchronize { @pending.delete(identifier) if @pending[identifier].equal?(state) }
54
87
  end
55
88
 
56
89
  def close
57
- @channel.close if @channel
58
- @channel.destroy if @channel&.respond_to?(:destroy)
59
- @connection.close if @connection
60
- rescue StandardError
90
+ @mutex.synchronize do
91
+ return nil if @closed
92
+ @closed = true
93
+ @pending.clear
94
+ @candidates.clear
95
+ @condition.broadcast
96
+ end
97
+ @io_mutex.synchronize do
98
+ if @native_cleanup
99
+ @native_cleanup.close(@channel, @connection)
100
+ else
101
+ begin
102
+ @channel.close if @channel
103
+ @channel.destroy if @channel&.respond_to?(:destroy)
104
+ ensure
105
+ @connection.close if @connection
106
+ end
107
+ end
108
+ end
61
109
  nil
62
110
  end
63
111
 
64
112
  private
65
113
 
114
+ private :connect_peer
115
+
116
+ def assert_open!
117
+ raise ToolCallError, "WebRTC peer closed" if @closed
118
+ raise ToolCallError, @failure if @failure
119
+ end
120
+
121
+ def fail_peer(message)
122
+ @mutex.synchronize do
123
+ @failure ||= message unless @closed
124
+ @condition.broadcast
125
+ end
126
+ end
127
+
66
128
  def install_candidate_handler
67
129
  @connection.on_ice_candidate do |candidate|
68
- @mutex.synchronize { @candidates << candidate } if candidate
130
+ @mutex.synchronize { @candidates << candidate unless @closed } if candidate
69
131
  end
70
132
  end
71
133
 
@@ -73,15 +135,24 @@ module UTCP
73
135
  @channel_open = false
74
136
  @channel.on_open do
75
137
  @mutex.synchronize do
76
- @channel_open = true
138
+ @channel_open = true unless @closed
77
139
  @condition.broadcast
78
140
  end
79
141
  end
142
+ @channel.on_close { fail_peer("WebRTC channel closed") } if @channel.respond_to?(:on_close)
80
143
  @channel.on_message do |message|
144
+ if message.data.bytesize > @template.max_response_bytes
145
+ fail_peer("WebRTC response exceeds max_response_bytes")
146
+ next
147
+ end
81
148
  envelope = JSON.parse(message.data)
149
+ next unless envelope.is_a?(Hash)
82
150
  identifier = envelope["id"]
83
151
  @mutex.synchronize do
84
- @responses[identifier] = envelope.key?("result") ? envelope["result"] : envelope
152
+ state = @pending[identifier]
153
+ next unless state && !@closed && !state.key?(:response)
154
+ next if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= state[:deadline]
155
+ state[:response] = envelope.key?("result") ? envelope["result"] : envelope
85
156
  @condition.broadcast
86
157
  end
87
158
  rescue JSON::ParserError
@@ -93,9 +164,9 @@ module UTCP
93
164
  return unless @connection.respond_to?(:on_ice_gathering_state_change)
94
165
 
95
166
  complete = @connection.ice_gathering_state == :complete
96
- @connection.on_ice_gathering_state_change do
167
+ @connection.on_ice_gathering_state_change do |state|
97
168
  @mutex.synchronize do
98
- complete = @connection.ice_gathering_state == :complete
169
+ complete = state == :complete
99
170
  @condition.broadcast if complete
100
171
  end
101
172
  end
@@ -112,7 +183,9 @@ module UTCP
112
183
  def wait_for_flag(timeout)
113
184
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
114
185
  @mutex.synchronize do
115
- until yield
186
+ loop do
187
+ assert_open!
188
+ return if yield
116
189
  remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
117
190
  raise TimeoutError, "WebRTC connection timed out" unless remaining.positive?
118
191
  @condition.wait(@mutex, remaining)
@@ -138,7 +211,14 @@ module UTCP
138
211
  http.verify_mode = OpenSSL::SSL::VERIFY_PEER if http.use_ssl?
139
212
  http.open_timeout = [@template.timeout, 10].min
140
213
  http.read_timeout = @template.timeout
141
- response = http.start { |connection| connection.request(request) }
214
+ http.write_timeout = @template.timeout if http.respond_to?(:write_timeout=)
215
+ response = Timeout.timeout(@template.timeout, TimeoutError, "WebRTC signaling timed out") do
216
+ http.start do |connection|
217
+ connection.request(request) do |incoming|
218
+ incoming.body = LimitedHTTPResponse.new(incoming, @template.max_response_bytes).body
219
+ end
220
+ end
221
+ end
142
222
  unless response.code.to_i.between?(200, 299)
143
223
  raise ToolCallError.new("WebRTC signaling failed with status #{response.code}",
144
224
  status: response.code.to_i, response_body: response.body)
@@ -159,6 +239,7 @@ module UTCP
159
239
  def register_manual(client, template)
160
240
  assert_webrtc_template!(template)
161
241
  response = peer_for(client, template).connect
242
+ ResponseByteBudget.new(template.max_response_bytes, "WebRTC discovery").consume_value(response)
162
243
  payload = if response.is_a?(Hash) && response.key?("tools") && !response.key?("utcp_version")
163
244
  {
164
245
  "utcp_version" => VERSION,
@@ -187,7 +268,7 @@ module UTCP
187
268
  def call_tool(client, tool_name, tool_args, template)
188
269
  assert_webrtc_template!(template)
189
270
  identifier = SecureRandom.uuid
190
- peer_for(client, template).request(
271
+ response = peer_for(client, template).request(
191
272
  {
192
273
  "id" => identifier,
193
274
  "tool" => tool_name.to_s.split(".").last,
@@ -195,6 +276,7 @@ module UTCP
195
276
  },
196
277
  timeout: template.timeout
197
278
  )
279
+ ResponseByteBudget.new(template.max_response_bytes, "WebRTC").consume_value(response)
198
280
  rescue Error
199
281
  raise
200
282
  rescue StandardError => error
@@ -214,7 +296,8 @@ module UTCP
214
296
  end
215
297
 
216
298
  def peer_key(client, template)
217
- [client, template.name, template.signaling_server, template.peer_id, template.data_channel_name]
299
+ [client, template.name, template.signaling_server, template.peer_id, template.data_channel_name,
300
+ template.max_response_bytes, template.max_pending_requests]
218
301
  end
219
302
  end
220
303
  WebrtcCommunicationProtocol = WebRTCProtocol
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UTCP
4
+ # Stock webrtc-ruby 1.0 binds these waiting destructors without releasing the
5
+ # GVL. Keep the compatibility binding local to UTCP-owned objects: callbacks
6
+ # must be able to finish while native destruction waits for them.
7
+ class WebRTCNativeCleanup
8
+ MUTEX = Mutex.new
9
+
10
+ def self.bindings
11
+ MUTEX.synchronize do
12
+ @bindings ||= Module.new do
13
+ extend ::FFI::Library
14
+ ffi_lib WebRTC::FFI::LIB_PATH
15
+ attach_function :destroy_channel, :webrtc_data_channel_destroy, [:pointer], :void, blocking: true
16
+ attach_function :destroy_peer, :webrtc_peer_connection_destroy, [:pointer], :void, blocking: true
17
+ end
18
+ end
19
+ end
20
+
21
+ def initialize
22
+ @bindings = self.class.bindings
23
+ end
24
+
25
+ def close(channel, connection)
26
+ # WebRTCPeer serializes this transfer with all native I/O and elects one
27
+ # closer. Detach handles before callbacks can observe or reuse them.
28
+ channel_pointer = channel.instance_variable_get(:@ptr) if channel
29
+ peer_pointer = connection.ptr if connection
30
+ channel.instance_variable_set(:@ptr, nil) if channel
31
+ connection.instance_variable_set(:@ptr, nil) if connection
32
+ begin
33
+ @bindings.destroy_channel(channel_pointer) if channel_pointer && !channel_pointer.null?
34
+ ensure
35
+ @bindings.destroy_peer(peer_pointer) if peer_pointer && !peer_pointer.null?
36
+ end
37
+ end
38
+ end
39
+ end
@@ -28,10 +28,12 @@ module UTCP
28
28
  GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
29
29
  MAX_HEADER_SIZE = 65_536
30
30
  MAX_MESSAGE_SIZE = 16 * 1024 * 1024
31
+ attr_accessor :max_response_bytes
31
32
 
32
33
  def initialize(url, headers = {}, protocol = nil, timeout = 30)
33
34
  @uri = WebSocketURLSecurity.validate!(url)
34
35
  @timeout = Float(timeout)
36
+ @max_response_bytes = MAX_MESSAGE_SIZE
35
37
  @read_buffer = +"".b
36
38
  @closed = false
37
39
  open_socket
@@ -57,7 +59,7 @@ module UTCP
57
59
  message = +"".b
58
60
  message_opcode = nil
59
61
  loop do
60
- fin, opcode, payload = read_frame
62
+ fin, opcode, payload = read_frame(@max_response_bytes - message.bytesize)
61
63
  case opcode
62
64
  when 0x0
63
65
  raise ToolCallError, "unexpected WebSocket continuation frame" unless message_opcode
@@ -78,9 +80,12 @@ module UTCP
78
80
  else
79
81
  raise ToolCallError, "unsupported WebSocket opcode #{opcode}"
80
82
  end
81
- raise ToolCallError, "WebSocket message exceeds #{MAX_MESSAGE_SIZE} bytes" if message.bytesize > MAX_MESSAGE_SIZE
83
+ raise ToolCallError, "WebSocket message exceeds max_response_bytes" if message.bytesize > @max_response_bytes
82
84
  return [message_opcode, message] if fin
83
85
  end
86
+ rescue Error
87
+ close
88
+ raise
84
89
  end
85
90
 
86
91
  def close
@@ -198,7 +203,7 @@ module UTCP
198
203
  @socket.write(header + mask + masked)
199
204
  end
200
205
 
201
- def read_frame
206
+ def read_frame(remaining = @max_response_bytes)
202
207
  head = read_exact(2)
203
208
  first, second = head.unpack("CC")
204
209
  fin = (first & 0x80) != 0
@@ -207,7 +212,8 @@ module UTCP
207
212
  length = second & 0x7F
208
213
  length = read_exact(2).unpack1("n") if length == 126
209
214
  length = read_exact(8).unpack1("Q>") if length == 127
210
- raise ToolCallError, "WebSocket frame exceeds #{MAX_MESSAGE_SIZE} bytes" if length > MAX_MESSAGE_SIZE
215
+ maximum = opcode >= 8 ? 125 : remaining
216
+ raise ToolCallError, "WebSocket frame exceeds max_response_bytes" if length > maximum
211
217
 
212
218
  mask = masked ? read_exact(4) : nil
213
219
  payload = read_exact(length)
@@ -220,7 +226,7 @@ module UTCP
220
226
  def read_exact(length)
221
227
  while @read_buffer.bytesize < length
222
228
  wait_readable
223
- @read_buffer << @socket.readpartial([4096, length - @read_buffer.bytesize].max)
229
+ @read_buffer << @socket.readpartial([4096, length - @read_buffer.bytesize].min)
224
230
  end
225
231
  @read_buffer.slice!(0, length)
226
232
  rescue EOFError
@@ -255,8 +261,10 @@ module UTCP
255
261
  assert_websocket_template!(template)
256
262
  entry, transient = connection_for(client, template, {})
257
263
  payload = entry.mutex.synchronize do
264
+ configure_response_limit(entry.connection, template)
258
265
  entry.connection.send_text(JSON.generate("type" => "utcp"))
259
266
  _opcode, bytes = entry.connection.read_message
267
+ ResponseByteBudget.new(template.max_response_bytes, "WebSocket").consume(bytes)
260
268
  bytes
261
269
  end
262
270
  success(template, manual_from_payload(template, payload, source: "WebSocket discovery response"))
@@ -283,11 +291,13 @@ module UTCP
283
291
  args = Utils.stringify_keys(tool_args || {})
284
292
  entry, transient, message_args = connection_for(client, template, args, include_arguments: true)
285
293
  result = entry.mutex.synchronize do
294
+ configure_response_limit(entry.connection, template)
286
295
  message = format_message(template, message_args)
287
296
  entry.connection.send_text(message)
288
297
  frame = entry.connection.read_message
289
298
  raise ToolCallError, "WebSocket closed without a response" unless frame
290
299
 
300
+ ResponseByteBudget.new(template.max_response_bytes, "WebSocket").consume(frame[1])
291
301
  decode_message(frame[1], template.response_format, frame[0])
292
302
  end
293
303
  result
@@ -301,6 +311,10 @@ module UTCP
301
311
 
302
312
  private
303
313
 
314
+ def configure_response_limit(connection, template)
315
+ connection.max_response_bytes = template.max_response_bytes if connection.respond_to?(:max_response_bytes=)
316
+ end
317
+
304
318
  def assert_websocket_template!(template)
305
319
  return if template.is_a?(WebSocketCallTemplate)
306
320
 
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module UTCP
6
+ # Ruby extension for network transports; CLI, file and text are excluded.
7
+ module ResponseLimits
8
+ DEFAULT_MAX_RESPONSE_BYTES = 100 * 1024 * 1024
9
+ DEFAULT_MAX_EVENT_BYTES = 1024 * 1024
10
+ DEFAULT_MAX_RESPONSE_ITEMS = 10_000
11
+
12
+ attr_accessor :max_response_bytes
13
+
14
+ def initialize(max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES, **options)
15
+ super(**options)
16
+ @max_response_bytes = positive_limit(max_response_bytes, "max_response_bytes")
17
+ end
18
+
19
+ def to_h
20
+ super.merge("max_response_bytes" => max_response_bytes)
21
+ end
22
+
23
+ private
24
+
25
+ def positive_limit(value, name)
26
+ result = Integer(value)
27
+ raise ValidationError.new("must be greater than zero", path: name) unless result.positive?
28
+ result
29
+ end
30
+ end
31
+
32
+ module HTTPResponseLimits
33
+ include ResponseLimits
34
+ attr_accessor :max_event_bytes, :max_response_items, :total_timeout
35
+
36
+ def initialize(max_event_bytes: DEFAULT_MAX_EVENT_BYTES,
37
+ max_response_items: DEFAULT_MAX_RESPONSE_ITEMS, total_timeout: nil, **options)
38
+ super(**options)
39
+ @max_event_bytes = positive_limit(max_event_bytes, "max_event_bytes")
40
+ @max_response_items = positive_limit(max_response_items, "max_response_items")
41
+ @total_timeout = total_timeout.nil? ? nil : Float(total_timeout)
42
+ if @total_timeout && (!@total_timeout.finite? || !@total_timeout.positive?)
43
+ raise ValidationError.new("must be finite and greater than zero", path: "total_timeout")
44
+ end
45
+ end
46
+
47
+ def to_h
48
+ super.merge("max_event_bytes" => max_event_bytes,
49
+ "max_response_items" => max_response_items).tap do |value|
50
+ value["total_timeout"] = total_timeout if total_timeout
51
+ end
52
+ end
53
+
54
+ end
55
+
56
+ class ResponseByteBudget
57
+ attr_reader :remaining
58
+
59
+ def initialize(maximum, context = "Transport")
60
+ @remaining = maximum
61
+ @context = context
62
+ end
63
+
64
+ def consume(bytes)
65
+ @remaining -= bytes.to_s.bytesize
66
+ raise ToolCallError, "#{@context} response exceeds max_response_bytes" if @remaining.negative?
67
+ bytes
68
+ end
69
+
70
+ def consume_value(value)
71
+ consume(value.is_a?(String) ? value : JSON.generate(value))
72
+ value
73
+ end
74
+ end
75
+
76
+ # Count decoded bytes before a parser or an accumulating caller receives them.
77
+ class LimitedHTTPResponse
78
+ def initialize(response, maximum)
79
+ @response = response
80
+ @maximum = maximum
81
+ @bytes = 0
82
+ end
83
+
84
+ def code
85
+ @response.code
86
+ end
87
+
88
+ def [](name)
89
+ @response[name]
90
+ end
91
+
92
+ def read_body
93
+ consume = lambda do |chunk|
94
+ @bytes += chunk.to_s.bytesize
95
+ raise ToolCallError, "HTTP response exceeds max_response_bytes (#{@maximum})" if @bytes > @maximum
96
+ yield chunk
97
+ end
98
+ if @response.respond_to?(:read_body)
99
+ @response.read_body { |chunk| consume.call(chunk) }
100
+ else
101
+ consume.call(@response.body) unless @response.body.nil?
102
+ end
103
+ end
104
+
105
+ def body
106
+ result = nil
107
+ read_body do |chunk|
108
+ result ||= +"".b
109
+ result << chunk.to_s.b
110
+ end
111
+ result
112
+ end
113
+ end
114
+ end
data/lib/utcp/version.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module UTCP
4
- VERSION = "1.1.5"
4
+ VERSION = "1.1.6"
5
5
  end
6
6
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby-utcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.5
4
+ version: 1.1.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - ruby-utcp contributors
@@ -183,9 +183,11 @@ files:
183
183
  - lib/utcp/protocols/text.rb
184
184
  - lib/utcp/protocols/udp.rb
185
185
  - lib/utcp/protocols/webrtc.rb
186
+ - lib/utcp/protocols/webrtc_native_cleanup.rb
186
187
  - lib/utcp/protocols/websocket.rb
187
188
  - lib/utcp/registry.rb
188
189
  - lib/utcp/repository.rb
190
+ - lib/utcp/response_limits.rb
189
191
  - lib/utcp/serializer.rb
190
192
  - lib/utcp/utils.rb
191
193
  - lib/utcp/variables.rb