async-bus 0.1.1 → 0.2.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: bcce5759c8ff0eab16a54c2cf8dd48c054dac88bec2705124cc70589563b9fbb
4
+ data.tar.gz: 63b96c8cc50edf9f357df6ea992cb4f6ec63aa89f5349c9e0d8ff85c97dafc91
5
5
  SHA512:
6
- metadata.gz: f4f6d79a2da0ecd4cba25ad79a2be878728701c01113c6f65aa6f9980a863a721f8863841bcfb1c0206f21e25bd0d1b4747a97102b3fbc2b5658dd045c1bf58d
7
- data.tar.gz: ecc5632d806ceebbf5b61aebc85ae1b47118303cf9f03320ecc243b403ca7edb00cf60ee966a55cc7c247bd4969897ef10588a97940c55518603fd34afb7c6fc
6
+ metadata.gz: a87a86e44c67ca9e4ca76234e0f844c2bb92d14eaffd52f16104f1b14038d266e3725abc584fe779088256c2cdf34e0bfeb4e29aecddeaa71f2d2835f2ed692a
7
+ data.tar.gz: c28eb52cc46f0ae4d3dcbd1c3e823174e273dde9e9dd1678d672b2f0d3ba5704fe495c511c07355be0a1215d0ade1e0824ca36e8f27d20a11f42f00711b65290
checksums.yaml.gz.sig ADDED
Binary file
@@ -1,54 +1,86 @@
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
28
11
  class Client
29
- def initialize(endpoint = nil)
12
+ def initialize(endpoint = nil, **options)
30
13
  @endpoint = endpoint || Protocol.local_endpoint
31
- @queue = Async::Queue.new
14
+ @options = options
32
15
  end
33
16
 
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)
17
+ # Create a new connection to the server.
18
+ #
19
+ # @returns [Protocol::Connection] The new connection.
20
+ protected def connect!
21
+ peer = @endpoint.connect
22
+ return Protocol::Connection.client(peer, **@options)
23
+ end
24
+
25
+ # Called when a connection is established.
26
+ # Override this method to perform setup when a connection is established.
27
+ #
28
+ # @parameter connection [Protocol::Connection] The established connection.
29
+ protected def connected!(connection)
30
+ # Do nothing by default.
31
+ end
32
+
33
+ # Connect to the server.
34
+ #
35
+ # @parameter persist [Boolean] Whether to keep the connection open indefiniely.
36
+ # @yields {|connection| ...} If a block is given, it will be called with the connection, and the connection will be closed afterwards.
37
+ # @returns [Protocol::Connection] The connection if no block is given.
38
+ def connect(parent: Task.current)
39
+ connection = connect!
40
+
41
+ connection_task = parent.async do
42
+ connection.run
43
+ end
44
+
45
+ connected!(connection)
46
+
47
+ return connection unless block_given?
48
+
49
+ begin
50
+ yield(connection, connection_task)
43
51
  ensure
44
52
  connection_task&.stop
53
+ connection&.close
45
54
  end
46
55
  end
47
56
 
48
- protected
49
-
50
- def handle(connection, message)
51
- # No default implementation.
57
+ # Run the client in a loop, reconnecting if necessary.
58
+ #
59
+ # Automatically reconnects when the connection fails, with random backoff.
60
+ # This is useful for long-running clients that need to maintain a persistent connection.
61
+ #
62
+ # @parameter parent [Async::Task] The parent task to run under.
63
+ def run(parent: Task.current)
64
+ parent.async(annotation: "Bus Client", transient: true) do |task|
65
+ loop do
66
+ connection = connect!
67
+
68
+ connected_task = task.async do
69
+ connected!(connection)
70
+ end
71
+
72
+ connection.run
73
+ rescue => error
74
+ Console.error(self, "Connection failed:", exception: error)
75
+ sleep(rand)
76
+ ensure
77
+ # Ensure any tasks that were created during connection are stopped:
78
+ connected_task&.stop
79
+
80
+ # Close the connection itself:
81
+ connection&.close
82
+ end
83
+ end
52
84
  end
53
85
  end
54
86
  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,61 +1,72 @@
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
32
16
  module Protocol
33
17
  def self.local_endpoint(path = "bus.ipc")
34
- Async::IO::Endpoint.unix(path)
18
+ ::IO::Endpoint.unix(path)
35
19
  end
36
20
 
37
21
  class Connection
38
- def self.client(peer)
39
- self.new(peer, 1)
22
+ def self.client(peer, **options)
23
+ self.new(peer, 1, **options)
40
24
  end
41
25
 
42
- def self.server(peer)
43
- self.new(peer, 2)
26
+ def self.server(peer, **options)
27
+ self.new(peer, 2, **options)
44
28
  end
45
29
 
46
- def initialize(peer, id)
30
+ def initialize(peer, id, wrapper: Wrapper, timeout: nil)
47
31
  @peer = peer
32
+ @id = id
48
33
 
49
- @wrapper = Wrapper.new(self)
34
+ @wrapper = wrapper.new(self)
50
35
  @unpacker = @wrapper.unpacker(peer)
51
36
  @packer = @wrapper.packer(peer)
52
37
 
38
+ @timeout = timeout
39
+
53
40
  @transactions = {}
54
- @id = id
55
41
 
56
42
  @objects = {}
57
43
  @proxies = ::ObjectSpace::WeakMap.new
58
- @finalized = Thread::Queue.new
44
+ @finalized = ::Thread::Queue.new
45
+ end
46
+
47
+ # @attribute [Float] The timeout for transactions.
48
+ attr_accessor :timeout
49
+
50
+ def flush
51
+ @packer.flush
52
+ end
53
+
54
+ def write(message)
55
+ # $stderr.puts "Writing: #{message.inspect}"
56
+ @packer.write(message)
57
+ @packer.flush
58
+ end
59
+
60
+ def close
61
+ @transactions.each do |id, transaction|
62
+ transaction.close
63
+ end
64
+
65
+ @peer.close
66
+ end
67
+
68
+ def inspect
69
+ "#<#{self.class} #{@objects.size} objects>"
59
70
  end
60
71
 
61
72
  attr :objects
@@ -73,20 +84,68 @@ module Async
73
84
 
74
85
  attr :transactions
75
86
 
87
+ Explicit = Struct.new(:object) do
88
+ def temporary?
89
+ false
90
+ end
91
+ end
92
+
93
+ Implicit = Struct.new(:object) do
94
+ def temporary?
95
+ true
96
+ end
97
+ end
98
+
99
+ # Bind a local object to a name, such that it could be accessed remotely.
100
+ #
101
+ # @returns [Proxy] A proxy instance for the bound object.
102
+ def bind(name, object)
103
+ # Bind the object into the local object store (explicitly bound, not temporary):
104
+ @objects[name] = Explicit.new(object)
105
+
106
+ # Return the proxy instance for the bound object:
107
+ return self[name]
108
+ end
109
+
110
+ # Generate a proxy name for an object and bind it.
111
+ #
112
+ # @returns [Proxy] A proxy instance for the bound object.
76
113
  def proxy(object)
77
- name = next_id.to_s(16).freeze
114
+ name = "<#{object.class}@#{next_id.to_s(16)}>".freeze
78
115
 
79
- bind(name, object)
116
+ # Bind the object into the local object store (temporary):
117
+ @objects[name] = Implicit.new(object)
80
118
 
119
+ # This constructs the Proxy instance:
120
+ return self[name]
121
+ end
122
+
123
+ # Generate a proxy name for an object and bind it, returning just the name.
124
+ # Used for serialization when you need the name string, not a Proxy instance.
125
+ #
126
+ # @returns [String] The name of the bound object.
127
+ def proxy_name(object)
128
+ name = "<#{object.class}@#{next_id.to_s(16)}>".freeze
129
+
130
+ # Bind the object into the local object store (temporary):
131
+ @objects[name] = Implicit.new(object)
132
+
133
+ # Return the name:
81
134
  return name
82
135
  end
83
136
 
84
- def bind(name, object)
85
- @objects[name] = object
137
+ def object(name)
138
+ @objects[name]&.object
86
139
  end
87
140
 
88
141
  private def finalize(name)
89
- proc{@finalized << name}
142
+ proc do
143
+ @finalized.push(name) rescue nil
144
+ end
145
+ end
146
+
147
+ def []=(name, object)
148
+ @objects[name] = Explicit.new(object)
90
149
  end
91
150
 
92
151
  def [](name)
@@ -94,57 +153,74 @@ module Async
94
153
  proxy = Proxy.new(self, name)
95
154
  @proxies[name] = proxy
96
155
 
97
- ObjectSpace.define_finalizer(proxy, finalize(name))
156
+ ::ObjectSpace.define_finalizer(proxy, finalize(name))
98
157
  end
99
158
 
100
159
  return proxy
101
160
  end
102
161
 
103
- def invoke(name, arguments, options = {}, &block)
104
-
105
- id = self.next_id
106
-
107
- transaction = Transaction.new(self, id)
162
+ def transaction!(id = self.next_id)
163
+ transaction = Transaction.new(self, id, timeout: @timeout)
108
164
  @transactions[id] = transaction
109
165
 
166
+ return transaction
167
+ end
168
+
169
+ def invoke(name, arguments, options = {}, &block)
170
+ transaction = self.transaction!
171
+
110
172
  transaction.invoke(name, arguments, options, &block)
173
+ ensure
174
+ transaction&.close
175
+ end
176
+
177
+ def send_release(name)
178
+ self.write(Release.new(name))
111
179
  end
112
180
 
113
- def run
114
- finalizer_task = Async do
181
+ def run(parent: Task.current)
182
+ finalizer_task = parent.async do
115
183
  while name = @finalized.pop
116
- @packer.write([:release, name])
184
+ self.send_release(name)
117
185
  end
118
186
  end
119
187
 
120
188
  @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
189
+ case message
190
+ when Invoke
191
+ # If the object is not found, send an error response and skip the transaction:
192
+ if object = @objects[message.name]&.object
193
+ transaction = self.transaction!(message.id)
194
+
195
+ parent.async(annotation: "Invoke #{message.name}") do
196
+ # $stderr.puts "-> Accepting: #{message.name} #{message.arguments.inspect} #{message.options.inspect}"
197
+ transaction.accept(object, message.arguments, message.options, message.block_given)
198
+ ensure
199
+ # $stderr.puts "<- Accepted: #{message.name}"
200
+ # This will also delete the transaction from @transactions:
201
+ transaction.close
202
+ end
203
+ else
204
+ self.write(Error.new(message.id, NameError.new("Object not found: #{message.name}")))
205
+ end
206
+ when Response
207
+ if transaction = @transactions[message.id]
208
+ transaction.push(message)
209
+ else
210
+ # Stale message - transaction already closed (e.g. timeout) or never existed (ignore silently).
211
+ end
212
+ when Release
213
+ name = message.name
214
+ if @objects[name]&.temporary?
215
+ # Only delete temporary objects, not explicitly bound ones:
216
+ @objects.delete(name)
141
217
  end
142
218
  else
143
- raise "Out of order message: #{message}"
219
+ Console.error(self, "Unexpected message:", message)
144
220
  end
145
221
  end
146
222
  ensure
147
- finalizer_task.stop
223
+ finalizer_task&.stop
148
224
 
149
225
  @transactions.each do |id, transaction|
150
226
  transaction.close
@@ -153,15 +229,7 @@ module Async
153
229
  @transactions.clear
154
230
  @proxies = ::ObjectSpace::WeakMap.new
155
231
  end
156
-
157
- def close
158
- @transactions.each do |id, transaction|
159
- transaction.close
160
- end
161
-
162
- @peer.close
163
- end
164
232
  end
165
233
  end
166
234
  end
167
- end
235
+ 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
+ require "msgpack"
7
+ require_relative "proxy"
8
+
9
+ module Async
10
+ module Bus
11
+ module Protocol
12
+ # Represents a method invocation.
13
+ class Invoke
14
+ def initialize(id, name, arguments, options, block_given)
15
+ @id = id
16
+ @name = name
17
+ @arguments = arguments
18
+ @options = options
19
+ @block_given = block_given
20
+ end
21
+
22
+ attr :id
23
+ attr :name
24
+ attr :arguments
25
+ attr :options
26
+ attr :block_given
27
+
28
+ def pack(packer)
29
+ packer.write(@id)
30
+ packer.write(@name)
31
+
32
+ packer.write(@arguments.size)
33
+ @arguments.each do |argument|
34
+ packer.write(argument)
35
+ end
36
+
37
+ packer.write(@options.size)
38
+ @options.each do |key, value|
39
+ packer.write(key)
40
+ packer.write(value)
41
+ end
42
+
43
+ packer.write(@block_given)
44
+ end
45
+
46
+ def self.unpack(unpacker)
47
+ id = unpacker.read
48
+ name = unpacker.read
49
+ arguments = Array.new(unpacker.read){unpacker.read}
50
+ options = Array.new(unpacker.read){[unpacker.read, unpacker.read]}.to_h
51
+ block_given = unpacker.read
52
+
53
+ return self.new(id, name, arguments, options, block_given)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -1,32 +1,28 @@
1
- # Copyright, 2018, by Samuel G. D. Williams. <http://www.codeotaku.com>
2
- #
3
- # Permission is hereby granted, free of charge, to any person obtaining a copy
4
- # of this software and associated documentation files (the "Software"), to deal
5
- # in the Software without restriction, including without limitation the rights
6
- # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
- # copies of the Software, and to permit persons to whom the Software is
8
- # furnished to do so, subject to the following conditions:
9
- #
10
- # The above copyright notice and this permission notice shall be included in
11
- # all copies or substantial portions of the Software.
12
- #
13
- # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
- # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
- # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
- # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
- # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
- # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
- # THE SOFTWARE.
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2021-2025, by Samuel Williams.
20
5
 
21
6
  module Async
22
7
  module Bus
23
8
  module Protocol
9
+ # A proxy object that forwards method calls to a remote object.
10
+ #
11
+ # We must be extremely careful not to invoke any methods on the proxy object that would recursively call the proxy object.
24
12
  class Proxy < BasicObject
13
+ # Create a new proxy object.
14
+ #
15
+ # @parameter connection [Connection] The connection to the remote object.
16
+ # @parameter name [Symbol] The name (address) of the remote object.
25
17
  def initialize(connection, name)
26
18
  @connection = connection
27
19
  @name = name
28
20
  end
29
21
 
22
+ def __name__
23
+ @name
24
+ end
25
+
30
26
  def !
31
27
  @connection.invoke(@name, [:!])
32
28
  end
@@ -39,26 +35,6 @@ module Async
39
35
  @connection.invoke(@name, [:!=, object])
40
36
  end
41
37
 
42
- def eql?(other)
43
- self.equal?(other)
44
- end
45
-
46
- def methods(all = true)
47
- @connection.invoke(@name, [:methods, all]) | super
48
- end
49
-
50
- def protected_methods(all = true)
51
- @connection.invoke(@name, [:protected_methods, all]) | super
52
- end
53
-
54
- def public_methods(all = true)
55
- @connection.invoke(@name, [:public_methods, all]) | super
56
- end
57
-
58
- def inspect
59
- "[Proxy (#{@name}) #{method_missing(:inspect)}]"
60
- end
61
-
62
38
  def method_missing(*arguments, **options, &block)
63
39
  @connection.invoke(@name, arguments, options, &block)
64
40
  end
@@ -68,7 +44,11 @@ module Async
68
44
  end
69
45
 
70
46
  def respond_to_missing?(name, include_all = false)
71
- @connection.invoke(@name, [:respond_to?, name, include_all]) || super
47
+ @connection.invoke(@name, [:respond_to?, name, include_all])
48
+ end
49
+
50
+ def inspect
51
+ "#<proxy #{@name}: #{@connection.invoke(@name, [:inspect])}>"
72
52
  end
73
53
  end
74
54
  end
@@ -0,0 +1,29 @@
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
+ module Protocol
9
+ # Represents a named object that has been released (no longer available).
10
+ class Release
11
+ def initialize(name)
12
+ @name = name
13
+ end
14
+
15
+ attr :name
16
+
17
+ def pack(packer)
18
+ packer.write(@name)
19
+ end
20
+
21
+ def self.unpack(unpacker)
22
+ name = unpacker.read
23
+
24
+ return self.new(name)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end