async-bus 0.1.1 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f0862030bd7ad9cf8ffe84a3e35e4ec52e3c3f3cc81b8192c5d59cc0bc435e3c
4
- data.tar.gz: d2f9a0398f4aa94a35fc3d2dc8aa1d4097777e9f85eeab2ca19405e30d902aa5
3
+ metadata.gz: f14db24c29a1d9ed24379631bcde98474533dd61397d6cf273bdeea5feabdb42
4
+ data.tar.gz: 2a1cf6b2528af9626010b95bfde0a4decf985518707c4469dd69f102ad000582
5
5
  SHA512:
6
- metadata.gz: f4f6d79a2da0ecd4cba25ad79a2be878728701c01113c6f65aa6f9980a863a721f8863841bcfb1c0206f21e25bd0d1b4747a97102b3fbc2b5658dd045c1bf58d
7
- data.tar.gz: ecc5632d806ceebbf5b61aebc85ae1b47118303cf9f03320ecc243b403ca7edb00cf60ee966a55cc7c247bd4969897ef10588a97940c55518603fd34afb7c6fc
6
+ metadata.gz: 23697b989abd95dc326f2ad370ca68c42488a5eb4ddc01efd87e6fd3b102d7221ff5dc872a13c8f400efcf39d7477d8d63980f9eb097a0a6e0456de28106ab18
7
+ data.tar.gz: bbf61748ccd9685a6708eacf24e32d48b76d9baef531c95a70975427b40afa0552ee05b4a3600900025342bd4ac98c39d1baec4db47482795134b64f55ab3bfc
checksums.yaml.gz.sig ADDED
Binary file
@@ -1,54 +1,92 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Copyright, 2020, by Samuel G. D. Williams. <http://www.codeotaku.com>
4
- #
5
- # Permission is hereby granted, free of charge, to any person obtaining a copy
6
- # of this software and associated documentation files (the "Software"), to deal
7
- # in the Software without restriction, including without limitation the rights
8
- # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- # copies of the Software, and to permit persons to whom the Software is
10
- # furnished to do so, subject to the following conditions:
11
- #
12
- # The above copyright notice and this permission notice shall be included in
13
- # all copies or substantial portions of the Software.
14
- #
15
- # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
- # THE SOFTWARE.
3
+ # Released under the MIT License.
4
+ # Copyright, 2021-2025, by Samuel Williams.
22
5
 
23
- require_relative 'protocol/connection'
24
- require 'async/queue'
6
+ require_relative "protocol/connection"
7
+ require "async/queue"
25
8
 
26
9
  module Async
27
10
  module Bus
11
+ # Represents a client that can connect to a server.
28
12
  class Client
29
- def initialize(endpoint = nil)
13
+ # Initialize a new client.
14
+ # @parameter endpoint [IO::Endpoint] The endpoint to connect to.
15
+ # @parameter options [Hash] Additional options for the connection.
16
+ def initialize(endpoint = nil, **options)
30
17
  @endpoint = endpoint || Protocol.local_endpoint
31
- @queue = Async::Queue.new
18
+ @options = options
32
19
  end
33
20
 
34
- def connect
35
- @endpoint.connect do |peer|
36
- connection = Protocol::Connection.client(peer)
37
-
38
- connection_task = Async do
39
- connection.run
40
- end
41
-
42
- return yield(connection)
21
+ # Create a new connection to the server.
22
+ #
23
+ # @returns [Protocol::Connection] The new connection.
24
+ protected def connect!
25
+ peer = @endpoint.connect
26
+ return Protocol::Connection.client(peer, **@options)
27
+ end
28
+
29
+ # Called when a connection is established.
30
+ # Override this method to perform setup when a connection is established.
31
+ #
32
+ # @parameter connection [Protocol::Connection] The established connection.
33
+ protected def connected!(connection)
34
+ # Do nothing by default.
35
+ end
36
+
37
+ # Connect to the server.
38
+ #
39
+ # @parameter persist [Boolean] Whether to keep the connection open indefiniely.
40
+ # @yields {|connection| ...} If a block is given, it will be called with the connection, and the connection will be closed afterwards.
41
+ # @returns [Protocol::Connection] The connection if no block is given.
42
+ def connect(parent: Task.current)
43
+ connection = connect!
44
+
45
+ connection_task = parent.async do
46
+ connection.run
47
+ end
48
+
49
+ connected!(connection)
50
+
51
+ return connection unless block_given?
52
+
53
+ begin
54
+ yield(connection, connection_task)
43
55
  ensure
44
56
  connection_task&.stop
57
+ connection&.close
45
58
  end
46
59
  end
47
60
 
48
- protected
49
-
50
- def handle(connection, message)
51
- # No default implementation.
61
+ # Run the client in a loop, reconnecting if necessary.
62
+ #
63
+ # Automatically reconnects when the connection fails, with random backoff.
64
+ # This is useful for long-running clients that need to maintain a persistent connection.
65
+ #
66
+ # @parameter parent [Async::Task] The parent task to run under.
67
+ def run
68
+ Sync do |task|
69
+ loop do
70
+ connection = connect!
71
+
72
+ connected_task = task.async do
73
+ connected!(connection)
74
+
75
+ yield(connection) if block_given?
76
+ end
77
+
78
+ connection.run
79
+ rescue => error
80
+ Console.error(self, "Connection failed:", exception: error)
81
+ sleep(rand)
82
+ ensure
83
+ # Ensure any tasks that were created during connection are stopped:
84
+ connected_task&.stop
85
+
86
+ # Close the connection itself:
87
+ connection&.close
88
+ end
89
+ end
52
90
  end
53
91
  end
54
92
  end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2025, by Samuel Williams.
5
+
6
+ module Async
7
+ module Bus
8
+ # Base class for controller objects designed to be proxied over Async::Bus.
9
+ #
10
+ # Controllers provide an explicit API for remote operations, avoiding the
11
+ # confusion that comes from proxying generic objects like Array or Hash.
12
+ #
13
+ # @example Array Controller
14
+ # class ArrayController < Async::Bus::Controller
15
+ # def initialize(array)
16
+ # @array = array
17
+ # end
18
+ #
19
+ # def append(*values)
20
+ # @array.concat(values)
21
+ # self # Return self for chaining
22
+ # end
23
+ #
24
+ # def get(index)
25
+ # @array[index] # Returns value
26
+ # end
27
+ #
28
+ # def size
29
+ # @array.size
30
+ # end
31
+ # end
32
+ #
33
+ # @example Server Setup
34
+ # server.accept do |connection|
35
+ # array = []
36
+ # controller = ArrayController.new(array)
37
+ # connection.bind(:items, controller)
38
+ # end
39
+ #
40
+ # @example Client Usage
41
+ # client.connect do |connection|
42
+ # items = connection[:items] # Returns proxy to controller
43
+ # items.append(1, 2, 3) # Remote call
44
+ # expect(items.size).to be == 3
45
+ # end
46
+ #
47
+ # Controllers are automatically proxied when serialized if registered
48
+ # as a reference type in the Wrapper:
49
+ #
50
+ # Wrapper.new(connection, reference_types: [Async::Bus::Controller])
51
+ #
52
+ # This allows controller methods to return other controllers and have
53
+ # them automatically proxied.
54
+ class Controller
55
+ end
56
+ end
57
+ end
58
+
@@ -1,69 +1,112 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Copyright, 2021, by Samuel G. D. Williams. <http://www.codeotaku.com>
4
- #
5
- # Permission is hereby granted, free of charge, to any person obtaining a copy
6
- # of this software and associated documentation files (the "Software"), to deal
7
- # in the Software without restriction, including without limitation the rights
8
- # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- # copies of the Software, and to permit persons to whom the Software is
10
- # furnished to do so, subject to the following conditions:
11
- #
12
- # The above copyright notice and this permission notice shall be included in
13
- # all copies or substantial portions of the Software.
14
- #
15
- # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
- # THE SOFTWARE.
3
+ # Released under the MIT License.
4
+ # Copyright, 2021-2025, by Samuel Williams.
22
5
 
23
- require 'async'
24
- require 'async/io/unix_endpoint'
6
+ require "async"
7
+ require "io/endpoint/unix_endpoint"
25
8
 
26
- require_relative 'wrapper'
27
- require_relative 'transaction'
28
- require_relative 'proxy'
9
+ require_relative "wrapper"
10
+ require_relative "transaction"
11
+ require_relative "proxy"
12
+ require_relative "response"
29
13
 
30
14
  module Async
31
15
  module Bus
16
+ # @namespace
32
17
  module Protocol
18
+ # Create a local Unix domain socket endpoint.
19
+ # @parameter path [String] The path to the socket file.
20
+ # @returns [IO::Endpoint::Unix] The Unix endpoint.
33
21
  def self.local_endpoint(path = "bus.ipc")
34
- Async::IO::Endpoint.unix(path)
22
+ ::IO::Endpoint.unix(path)
35
23
  end
36
24
 
25
+ # Represents a connection between client and server for message passing.
37
26
  class Connection
38
- def self.client(peer)
39
- self.new(peer, 1)
27
+ # Create a client-side connection.
28
+ # @parameter peer [IO] The peer connection.
29
+ # @parameter options [Hash] Additional options for the connection.
30
+ # @returns [Connection] A new client connection.
31
+ def self.client(peer, **options)
32
+ self.new(peer, 1, **options)
40
33
  end
41
34
 
42
- def self.server(peer)
43
- self.new(peer, 2)
35
+ # Create a server-side connection.
36
+ # @parameter peer [IO] The peer connection.
37
+ # @parameter options [Hash] Additional options for the connection.
38
+ # @returns [Connection] A new server connection.
39
+ def self.server(peer, **options)
40
+ self.new(peer, 2, **options)
44
41
  end
45
42
 
46
- def initialize(peer, id)
43
+ # Initialize a new connection.
44
+ # @parameter peer [IO] The peer connection.
45
+ # @parameter id [Integer] The initial transaction ID.
46
+ # @parameter wrapper [Class] The wrapper class for serialization.
47
+ # @parameter timeout [Float] The timeout for transactions.
48
+ def initialize(peer, id, wrapper: Wrapper, timeout: nil)
47
49
  @peer = peer
50
+ @id = id
48
51
 
49
- @wrapper = Wrapper.new(self)
52
+ @wrapper = wrapper.new(self)
50
53
  @unpacker = @wrapper.unpacker(peer)
51
54
  @packer = @wrapper.packer(peer)
52
55
 
56
+ @timeout = timeout
57
+
53
58
  @transactions = {}
54
- @id = id
55
59
 
56
60
  @objects = {}
57
61
  @proxies = ::ObjectSpace::WeakMap.new
58
- @finalized = Thread::Queue.new
62
+ @finalized = ::Thread::Queue.new
63
+ end
64
+
65
+ # @attribute [Float] The timeout for transactions.
66
+ attr_accessor :timeout
67
+
68
+ # Flush the packer buffer.
69
+ def flush
70
+ @packer.flush
71
+ end
72
+
73
+ # Write a message to the connection.
74
+ # @parameter message [Object] The message to write.
75
+ def write(message)
76
+ # $stderr.puts "Writing: #{message.inspect}"
77
+ @packer.write(message)
78
+ @packer.flush
79
+ end
80
+
81
+ # Close the connection and clean up resources.
82
+ def close
83
+ @transactions.each do |id, transaction|
84
+ transaction.close
85
+ end
86
+
87
+ @peer.close
88
+ end
89
+
90
+ # Return a string representation of the connection.
91
+ # @returns [String] A string describing the connection.
92
+ def inspect
93
+ "#<#{self.class} #{@objects.size} objects>"
59
94
  end
60
95
 
96
+ # @attribute [Hash] The bound objects.
61
97
  attr :objects
98
+
99
+ # @attribute [ObjectSpace::WeakMap] The proxy cache.
62
100
  attr :proxies
63
101
 
102
+ # @attribute [MessagePack::Unpacker] The message unpacker.
64
103
  attr :unpacker
104
+
105
+ # @attribute [MessagePack::Packer] The message packer.
65
106
  attr :packer
66
107
 
108
+ # Get the next transaction ID.
109
+ # @returns [Integer] The next transaction ID.
67
110
  def next_id
68
111
  id = @id
69
112
  @id += 2
@@ -71,80 +114,218 @@ module Async
71
114
  return id
72
115
  end
73
116
 
117
+ # @attribute [Hash] Active transactions.
74
118
  attr :transactions
75
119
 
120
+ Explicit = Struct.new(:object) do
121
+ def temporary?
122
+ false
123
+ end
124
+ end
125
+
126
+ Implicit = Struct.new(:object) do
127
+ def temporary?
128
+ true
129
+ end
130
+ end
131
+
132
+ # Explicitly bind an object to a name, such that it could be accessed remotely.
133
+ #
134
+ # This is the same as {bind} but due to the semantics of the `[]=` operator, it does not return a proxy instance.
135
+ #
136
+ # Explicitly bound objects are not garbage collected until the connection is closed.
137
+ #
138
+ # @parameter name [String] The name to bind the object to.
139
+ # @parameter object [Object] The object to bind to the given name.
140
+ def []=(name, object)
141
+ @objects[name] = Explicit.new(object)
142
+ end
143
+
144
+ # Generate a proxy for a remotely bound object.
145
+ #
146
+ # **This always returns a proxy, even if the object is bound locally.**
147
+ # The object bus is not shared between client and server, so `[]` always
148
+ # returns a proxy to the remote instance.
149
+ #
150
+ # @parameter name [String] The name of the bound object.
151
+ # @returns [Proxy] A proxy instance for the bound object.
152
+ def [](name)
153
+ return proxy_for(name)
154
+ end
155
+
156
+ # Explicitly bind an object to a name, such that it could be accessed remotely.
157
+ #
158
+ # This method is identical to {[]=} but also returns a {Proxy} instance for the bound object which can be passed by reference.
159
+ #
160
+ # Explicitly bound objects are not garbage collected until the connection is closed.
161
+ #
162
+ # @example Binding an object to a name and accessing it remotely.
163
+ # array_proxy = connection.bind(:items, [1, 2, 3])
164
+ # connection[:remote].register(array_proxy)
165
+ #
166
+ # @parameter name [String] The name to bind the object to.
167
+ # @parameter object [Object] The object to bind to the given name.
168
+ # @returns [Proxy] A proxy instance for the bound object.
169
+ def bind(name, object)
170
+ # Bind the object into the local object store (explicitly bound, not temporary):
171
+ @objects[name] = Explicit.new(object)
172
+
173
+ # Always return a proxy for passing by reference, even for locally bound objects:
174
+ return proxy_for(name)
175
+ end
176
+
177
+ # Implicitly bind an object with a temporary name, such that it could be accessed remotely.
178
+ #
179
+ # Implicitly bound objects are garbage collected when the remote end no longer references them.
180
+ #
181
+ # This method is simliar to {bind} but is designed to be used to generate temporary proxies for objects that are not explicitly bound.
182
+ #
183
+ # @parameter object [Object] The object to bind to a temporary name.
184
+ # @returns [Proxy] A proxy instance for the bound object.
76
185
  def proxy(object)
77
- name = next_id.to_s(16).freeze
186
+ name = object.__id__
78
187
 
79
- bind(name, object)
188
+ # Bind the object into the local object store (temporary):
189
+ @objects[name] ||= Implicit.new(object)
80
190
 
81
- return name
191
+ # Always return a proxy for passing by reference:
192
+ return proxy_for(name)
82
193
  end
83
194
 
84
- def bind(name, object)
85
- @objects[name] = object
195
+ # Implicitly bind an object with a temporary name, such that it could be accessed remotely.
196
+ #
197
+ # Implicitly bound objects are garbage collected when the remote end no longer references them.
198
+ #
199
+ # This method is similar to {proxy} but is designed to be used to generate temporary names for objects that are not explicitly bound during serialization.
200
+ #
201
+ # @parameter object [Object] The object to bind to a temporary name.
202
+ # @returns [String] The name of the bound object.
203
+ def proxy_name(object)
204
+ name = object.__id__
205
+
206
+ # Bind the object into the local object store (temporary):
207
+ @objects[name] ||= Implicit.new(object)
208
+
209
+ # Return the name:
210
+ return name
86
211
  end
87
212
 
88
- private def finalize(name)
89
- proc{@finalized << name}
213
+ # Get an object or proxy for a bound object, handling reverse lookup.
214
+ #
215
+ # If the object is bound locally and the proxy is for this connection, returns the actual object.
216
+ # If the object is bound remotely, or the proxy is from a different connection, returns a proxy.
217
+ # This is used when deserializing proxies to handle round-trip scenarios and avoid name collisions.
218
+ #
219
+ # @parameter name [String] The name of the bound object.
220
+ # @parameter local [Boolean] Whether the proxy is for this connection (from serialization). Defaults to true.
221
+ # @returns [Object | Proxy] The object if bound locally and proxy is for this connection, or a proxy otherwise.
222
+ def proxy_object(name)
223
+ # If the proxy is for this connection and the object is bound locally, return the actual object:
224
+ if entry = @objects[name]
225
+ # This handles round-trip scenarios correctly.
226
+ return entry.object
227
+ end
228
+
229
+ # Otherwise, create a proxy for the remote object:
230
+ return proxy_for(name)
90
231
  end
91
232
 
92
- def [](name)
233
+ # Get or create a proxy for a named object.
234
+ #
235
+ # @parameter name [String] The name of the object.
236
+ # @returns [Proxy] A proxy instance for the named object.
237
+ private def proxy_for(name)
93
238
  unless proxy = @proxies[name]
94
239
  proxy = Proxy.new(self, name)
95
240
  @proxies[name] = proxy
96
241
 
97
- ObjectSpace.define_finalizer(proxy, finalize(name))
242
+ ::ObjectSpace.define_finalizer(proxy, finalize(name))
98
243
  end
99
244
 
100
245
  return proxy
101
246
  end
102
247
 
103
- def invoke(name, arguments, options = {}, &block)
104
-
105
- id = self.next_id
106
-
107
- transaction = Transaction.new(self, id)
248
+ private def finalize(name)
249
+ proc do
250
+ @finalized.push(name) rescue nil
251
+ end
252
+ end
253
+
254
+ # Create a new transaction.
255
+ # @parameter id [Integer] The transaction ID.
256
+ # @returns [Transaction] A new transaction.
257
+ def transaction!(id = self.next_id)
258
+ transaction = Transaction.new(self, id, timeout: @timeout)
108
259
  @transactions[id] = transaction
109
260
 
261
+ return transaction
262
+ end
263
+
264
+ # Invoke a remote procedure.
265
+ # @parameter name [Symbol] The name of the remote object.
266
+ # @parameter arguments [Array] The arguments to pass.
267
+ # @parameter options [Hash] The keyword arguments to pass.
268
+ # @yields {|*args| ...} Optional block for yielding operations.
269
+ # @returns [Object] The result of the invocation.
270
+ def invoke(name, arguments, options = {}, &block)
271
+ transaction = self.transaction!
272
+
110
273
  transaction.invoke(name, arguments, options, &block)
274
+ ensure
275
+ transaction&.close
111
276
  end
112
277
 
113
- def run
114
- finalizer_task = Async do
278
+ # Send a release message for a named object.
279
+ # @parameter name [Symbol] The name of the object to release.
280
+ def send_release(name)
281
+ self.write(Release.new(name))
282
+ end
283
+
284
+ # Run the connection message loop.
285
+ # @parameter parent [Async::Task] The parent task to run under.
286
+ def run(parent: Task.current)
287
+ finalizer_task = parent.async do
115
288
  while name = @finalized.pop
116
- @packer.write([:release, name])
289
+ self.send_release(name)
117
290
  end
118
291
  end
119
292
 
120
293
  @unpacker.each do |message|
121
- id = message.shift
122
-
123
- if id == :release
124
- name = message.shift
125
- @objects.delete(name) if name.is_a?(String)
126
- elsif transaction = @transactions[id]
127
- transaction.received.enqueue(message)
128
- elsif message.first == :invoke
129
- message.shift
130
-
131
- transaction = Transaction.new(self, id)
132
- @transactions[id] = transaction
133
-
134
- name = message.shift
135
- object = @objects[name]
136
-
137
- Async do
138
- transaction.accept(object, *message)
139
- ensure
140
- transaction.close
294
+ case message
295
+ when Invoke
296
+ # If the object is not found, send an error response and skip the transaction:
297
+ if object = @objects[message.name]&.object
298
+ transaction = self.transaction!(message.id)
299
+
300
+ parent.async(annotation: "Invoke #{message.name}") do
301
+ # $stderr.puts "-> Accepting: #{message.name} #{message.arguments.inspect} #{message.options.inspect}"
302
+ transaction.accept(object, message.arguments, message.options, message.block_given)
303
+ ensure
304
+ # $stderr.puts "<- Accepted: #{message.name}"
305
+ # This will also delete the transaction from @transactions:
306
+ transaction.close
307
+ end
308
+ else
309
+ self.write(Error.new(message.id, NameError.new("Object not found: #{message.name}")))
310
+ end
311
+ when Response
312
+ if transaction = @transactions[message.id]
313
+ transaction.push(message)
314
+ else
315
+ # Stale message - transaction already closed (e.g. timeout) or never existed (ignore silently).
316
+ end
317
+ when Release
318
+ name = message.name
319
+ if @objects[name]&.temporary?
320
+ # Only delete temporary objects, not explicitly bound ones:
321
+ @objects.delete(name)
141
322
  end
142
323
  else
143
- raise "Out of order message: #{message}"
324
+ Console.error(self, "Unexpected message:", message)
144
325
  end
145
326
  end
146
327
  ensure
147
- finalizer_task.stop
328
+ finalizer_task&.stop
148
329
 
149
330
  @transactions.each do |id, transaction|
150
331
  transaction.close
@@ -153,15 +334,7 @@ module Async
153
334
  @transactions.clear
154
335
  @proxies = ::ObjectSpace::WeakMap.new
155
336
  end
156
-
157
- def close
158
- @transactions.each do |id, transaction|
159
- transaction.close
160
- end
161
-
162
- @peer.close
163
- end
164
337
  end
165
338
  end
166
339
  end
167
- end
340
+ end