Skip to content

Commit 478e027

Browse files
100% test coverage.
1 parent 801e72a commit 478e027

File tree

4 files changed

+295
-14
lines changed

4 files changed

+295
-14
lines changed

lib/async/bus/protocol/transaction.rb

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -90,22 +90,27 @@ def invoke(name, arguments, options, &block)
9090

9191
self.write(Invoke.new(@id, name, arguments, options, block_given?))
9292

93-
while response = self.read
94-
case response
95-
when Return
96-
return response.result
97-
when Yield
98-
begin
99-
result = yield(*response.result)
100-
self.write(Next.new(@id, result))
101-
rescue => error
102-
self.write(Error.new(@id, error))
103-
end
104-
when Error
105-
raise(response.result)
93+
while response = self.read
94+
case response
95+
when Return
96+
return response.result
97+
when Yield
98+
begin
99+
result = yield(*response.result)
100+
self.write(Next.new(@id, result))
101+
rescue => error
102+
self.write(Error.new(@id, error))
106103
end
104+
when Error
105+
raise(response.result)
106+
when Throw
107+
# Re-throw the tag and value that was thrown on the server side
108+
# Throw.result contains [tag, value] array
109+
tag, value = response.result
110+
throw(tag, value)
107111
end
108112
end
113+
end
109114

110115
# Accept a remote procedure invocation.
111116
# @parameter object [Object] The object to invoke the method on.
@@ -134,7 +139,9 @@ def accept(object, arguments, options, block_given)
134139

135140
self.write(Return.new(@id, result))
136141
rescue UncaughtThrowError => error
137-
self.write(Throw.new(@id, error.tag))
142+
# UncaughtThrowError has both tag and value attributes
143+
# Store both in the Throw message: result is tag, we'll add value handling
144+
self.write(Throw.new(@id, [error.tag, error.value]))
138145
rescue => error
139146
self.write(Error.new(@id, error))
140147
end

test/async/bus/protocol/connection.rb

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,5 +213,160 @@ def get_temporary_object
213213
end
214214
end
215215
end
216+
217+
with "#[]=" do
218+
it "can bind objects explicitly" do
219+
server_connection = nil
220+
221+
start_server do |connection|
222+
server_connection = connection
223+
object = Object.new
224+
connection[:test] = object
225+
end
226+
227+
client.connect do |connection|
228+
# Connect to trigger server's accept block
229+
expect(server_connection.objects[:test]).to be_a(Async::Bus::Protocol::Connection::Explicit)
230+
expect(server_connection.objects[:test].object).to be_a(Object)
231+
expect(server_connection.objects[:test]).not.to be(:temporary?)
232+
end
233+
end
234+
end
235+
236+
with "#run" do
237+
it "handles unexpected messages" do
238+
error_logged = false
239+
error_args = nil
240+
241+
# Intercept Console.error to verify it's called
242+
original_error = Console.method(:error)
243+
Console.define_singleton_method(:error) do |*args|
244+
error_logged = true
245+
error_args = args
246+
original_error.call(*args)
247+
end
248+
249+
begin
250+
# Create a connection directly with a mock peer
251+
peer = StringIO.new
252+
connection = Async::Bus::Protocol::Connection.server(peer)
253+
connection.bind(:test, Object.new)
254+
255+
# Create an unexpected message object
256+
unexpected_message = Object.new
257+
258+
# Mock the unpacker to yield the unexpected message
259+
original_unpacker = connection.instance_variable_get(:@unpacker)
260+
mock_enumerator = Enumerator.new do |yielder|
261+
yielder.yield(unexpected_message)
262+
# Raise to stop the loop
263+
raise IOError, "End of stream"
264+
end
265+
266+
connection.instance_variable_set(:@unpacker, mock_enumerator)
267+
268+
# Run the connection in a task
269+
task = Async do
270+
begin
271+
connection.run
272+
rescue IOError
273+
# Expected when enumerator raises
274+
end
275+
end
276+
277+
# Wait for it to process
278+
sleep(0.05)
279+
280+
# Stop the task
281+
task.stop
282+
283+
expect(error_logged).to be_truthy
284+
expect(error_args).not.to be_nil
285+
ensure
286+
# Restore original Console.error
287+
Console.define_singleton_method(:error, original_error)
288+
end
289+
end
290+
291+
it "closes pending transactions in ensure block when run loop exits" do
292+
transactions_closed_count = 0
293+
294+
# Create a connection directly to test the ensure block
295+
peer = StringIO.new
296+
connection = Async::Bus::Protocol::Connection.server(peer)
297+
298+
# Create some transactions manually
299+
transaction1 = connection.transaction!
300+
transaction2 = connection.transaction!
301+
302+
# Mock transaction.close to track calls
303+
[transaction1, transaction2].each do |transaction|
304+
original_close = transaction.method(:close)
305+
transaction.define_singleton_method(:close) do
306+
transactions_closed_count += 1
307+
original_close.call
308+
end
309+
end
310+
311+
# Verify transactions exist
312+
expect(connection.transactions.size).to be == 2
313+
314+
# Mock the unpacker to immediately raise (simulating connection close)
315+
# This will trigger the ensure block
316+
mock_enumerator = Enumerator.new do |yielder|
317+
raise IOError, "Connection closed"
318+
end
319+
320+
connection.instance_variable_set(:@unpacker, mock_enumerator)
321+
322+
# Run the connection - it will immediately hit the error and run ensure block
323+
task = Async do
324+
begin
325+
connection.run
326+
rescue IOError
327+
# Expected
328+
end
329+
end
330+
331+
# Wait for it to process
332+
sleep(0.05)
333+
334+
# Stop the task
335+
task.stop
336+
337+
# Verify transactions were closed in ensure block
338+
expect(transactions_closed_count).to be == 2
339+
expect(connection.transactions).to be(:empty?)
340+
end
341+
342+
it "closes pending transactions on connection close" do
343+
start_server do |connection|
344+
service = Object.new
345+
def service.slow_method
346+
sleep(0.1)
347+
:result
348+
end
349+
350+
connection.bind(:service, service)
351+
end
352+
353+
client.connect do |connection|
354+
# Start a transaction
355+
transaction = connection.transaction!
356+
357+
# Start an async call that will take time
358+
Async do
359+
connection[:service].slow_method
360+
end
361+
362+
# Close the connection immediately
363+
connection.close
364+
365+
# Verify transaction was closed
366+
expect(transaction.connection).to be_nil
367+
expect(transaction.received).to be(:closed?)
368+
end
369+
end
370+
end
216371
end
217372

test/async/bus/protocol/proxy.rb

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2025, by Samuel Williams.
5+
6+
require "async/bus/a_server"
7+
8+
describe Async::Bus::Protocol::Proxy do
9+
include Async::Bus::AServer
10+
11+
with "operators" do
12+
it "can use logical negation operator" do
13+
start_server do |connection|
14+
service = Object.new
15+
def service.!
16+
false
17+
end
18+
19+
connection.bind(:service, service)
20+
end
21+
22+
client.connect do |connection|
23+
proxy = connection[:service]
24+
result = !proxy
25+
26+
expect(result).to be == false
27+
end
28+
end
29+
end
30+
31+
with "#respond_to_missing?" do
32+
it "can check if remote object responds to method" do
33+
start_server do |connection|
34+
service = Object.new
35+
def service.test_method
36+
:result
37+
end
38+
39+
connection.bind(:service, service)
40+
end
41+
42+
client.connect do |connection|
43+
proxy = connection[:service]
44+
45+
# Call respond_to_missing? using instance_eval since Proxy inherits from BasicObject
46+
# which doesn't have send/__send__/method
47+
# (respond_to? is defined on Proxy, so Ruby won't call respond_to_missing? automatically)
48+
result1 = proxy.instance_eval { respond_to_missing?(:test_method, false) }
49+
result2 = proxy.instance_eval { respond_to_missing?(:nonexistent_method, false) }
50+
51+
expect(result1).to be == true
52+
expect(result2).to be == false
53+
end
54+
end
55+
end
56+
end
57+

test/async/bus/protocol/transaction.rb

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,68 @@ def service.error_method
174174
expect(write_count).to be > 0
175175
end
176176
end
177+
178+
it "handles Close message in yield block to break iteration" do
179+
start_server do |connection|
180+
service = Object.new
181+
def service.yielding_method
182+
yield 1
183+
yield 2
184+
yield 3
185+
:done
186+
end
187+
188+
connection.bind(:service, service)
189+
end
190+
191+
client.connect do |connection|
192+
results = []
193+
194+
# Create a transaction and manually handle yields to send Close
195+
transaction = connection.transaction!
196+
transaction.invoke(:service, [:yielding_method], {}) do |*yield_args|
197+
value = yield_args.first
198+
results << value
199+
200+
# After first yield, send Close to break the loop
201+
if results.size == 1
202+
connection.write(Async::Bus::Protocol::Close.new(transaction.id, nil))
203+
end
204+
205+
:ack
206+
end
207+
208+
# Close should break the loop, so we should only get one value
209+
expect(results.size).to be == 1
210+
expect(results.first).to be == 1
211+
end
212+
end
213+
214+
it "handles Throw message from server" do
215+
start_server do |connection|
216+
service = Object.new
217+
def service.throw_method
218+
throw :some_tag
219+
end
220+
221+
connection.bind(:service, service)
222+
end
223+
224+
client.connect do |connection|
225+
# The server should catch the UncaughtThrowError and send a Throw message
226+
# The client should handle it by re-throwing
227+
# Note: UncaughtThrowError only preserves the tag, not the value
228+
thrown = false
229+
result = catch(:some_tag) do
230+
connection[:service].throw_method
231+
thrown = true
232+
end
233+
234+
# catch returns nil when throw happens without a value
235+
expect(thrown).to be == false
236+
expect(result).to be_nil
237+
end
238+
end
177239
end
178240
end
179241

0 commit comments

Comments
 (0)