Skip to content

Commit 054553e

Browse files
Better ownership transfer.
1 parent d38758b commit 054553e

File tree

6 files changed

+167
-13
lines changed

6 files changed

+167
-13
lines changed

guides/getting-started/readme.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,38 @@ Fiber.schedule do
163163
end
164164
~~~
165165

166+
### Deep Transfer with Traversal
167+
168+
By default, `transfer` only transfers the object itself (shallow). For collections like `Array`, `Hash`, and `Set`, the gem automatically traverses and transfers contained objects:
169+
170+
~~~ ruby
171+
bodies = [Body.new, Body.new]
172+
173+
Async::Safe.transfer(bodies) # Transfers array AND all bodies inside
174+
~~~
175+
176+
Custom classes can define traversal behavior using `async_safe_traverse`:
177+
178+
~~~ ruby
179+
class Request
180+
async_safe!(false)
181+
attr_accessor :body, :headers
182+
183+
def self.async_safe_traverse(instance, &block)
184+
yield instance.body if instance.body
185+
yield instance.headers if instance.headers
186+
end
187+
end
188+
189+
request = Request.new
190+
request.body = Body.new
191+
request.headers = Headers.new
192+
193+
Async::Safe.transfer(request) # Transfers request, body, AND headers
194+
~~~
195+
196+
**Note:** Shareable objects (`async_safe? -> true`) are never traversed or transferred, as they can be safely shared across fibers.
197+
166198
## Integration with Tests
167199

168200
Add to your test helper (e.g., `config/sus.rb` or `spec/spec_helper.rb`):

lib/async/safe/builtins.rb

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,34 @@
88
# Note: Immutable values (nil, true, false, integers, symbols, etc.) are already
99
# handled by the frozen? check in the monitor and don't need to be listed here.
1010

11+
# Arrays contain references to other objects that may need transfer:
12+
class Array
13+
ASYNC_SAFE = false
14+
15+
def self.async_safe_traverse(instance, &block)
16+
instance.each(&block)
17+
end
18+
end
19+
20+
# Hashes contain keys and values that may need transfer:
21+
class Hash
22+
ASYNC_SAFE = false
23+
24+
def self.async_safe_traverse(instance, &block)
25+
instance.each_key(&block)
26+
instance.each_value(&block)
27+
end
28+
end
29+
30+
# Sets contain elements that may need transfer:
31+
class Set
32+
ASYNC_SAFE = false
33+
34+
def self.async_safe_traverse(instance, &block)
35+
instance.each(&block)
36+
end
37+
end
38+
1139
module Async
1240
module Safe
1341
# Automatically transfers ownership of objects when they are removed from a Thread::Queue.

lib/async/safe/class.rb

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,31 @@ def async_safe?(method = nil)
4545

4646
# Mark the class as async-safe or not.
4747
#
48-
# @parameter value [Boolean] Whether the class is async-safe.
49-
# @returns [Boolean] Whether the class is async-safe.
48+
# @parameter value [Boolean | Hash | Array] Configuration for async-safety.
49+
# @returns [Boolean | Hash | Array] The configured value.
5050
def async_safe!(value = true)
5151
self.const_set(:ASYNC_SAFE, value)
5252
end
53+
54+
# Define how to traverse this object's children during ownership transfer.
55+
#
56+
# This method is called by `Async::Safe.transfer` to recursively transfer
57+
# ownership of contained objects. By default, only the object itself is transferred.
58+
# Define this method to enable deep transfer for collection-like classes.
59+
#
60+
# @parameter instance [Object] The instance to traverse.
61+
# @parameter block [Proc] Block to call for each child object that should be transferred.
62+
#
63+
# ~~~ ruby
64+
# class MyContainer
65+
# async_safe!(false)
66+
#
67+
# def self.async_safe_traverse(instance, &block)
68+
# instance.children.each(&block)
69+
# end
70+
# end
71+
# ~~~
72+
def async_safe_traverse(instance, &block)
73+
# Default: no traversal (shallow transfer only)
74+
end
5375
end

lib/async/safe/monitor.rb

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,25 +87,63 @@ def disable!
8787

8888
# Explicitly transfer ownership of objects to the current fiber.
8989
#
90+
# Also recursively transfers ownership of any tracked instance variables and
91+
# objects contained in collections (Array, Hash, Set).
92+
#
9093
# @parameter objects [Array(Object)] The objects to transfer.
9194
def transfer(*objects)
95+
current = Fiber.current
96+
visited = Set.new
97+
98+
# Traverse object graph (outside mutex to avoid deadlock):
99+
objects.each do |object|
100+
traverse_objects(object, visited)
101+
end
102+
103+
# Transfer all visited objects (convert to array to avoid triggering TracePoint in sync block):
104+
objects_to_transfer = visited.to_a
105+
92106
@mutex.synchronize do
93-
current = Fiber.current
94-
95-
objects.each do |object|
96-
@owners[object] = current
107+
objects_to_transfer.each do |object|
108+
@owners[object] = current if @owners.key?(object)
97109
end
98110
end
99111
end
100112

113+
private
114+
115+
# Traverse the object graph and collect all reachable objects.
116+
#
117+
# @parameter object [Object] The object to traverse.
118+
# @parameter visited [Set] Set of visited objects (object references, not IDs).
119+
def traverse_objects(object, visited)
120+
# Avoid circular references:
121+
return if visited.include?(object)
122+
123+
# Skip objects that don't need traversing:
124+
return if object.frozen? or object.is_a?(Module)
125+
126+
# Skip async-safe (shareable) objects - they're not owned:
127+
klass = object.class
128+
return if klass.async_safe?(nil)
129+
130+
# Mark as visited:
131+
visited << object
132+
133+
# Recurse through custom traversal:
134+
klass.async_safe_traverse(object) do |element|
135+
traverse_objects(element, visited)
136+
end
137+
end
138+
101139
# Check if the current access is allowed or constitutes a violation.
102140
#
103141
# @parameter trace_point [TracePoint] The trace point containing access information.
104142
def check_access(trace_point)
105143
object = trace_point.self
106144

107145
# Skip tracking class/module methods:
108-
return if object.is_a?(Class) || object.is_a?(Module)
146+
return if object.is_a?(Module)
109147

110148
# Skip frozen objects:
111149
return if object.frozen?

releases.md

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,24 @@
11
# Releases
22

3+
## Unreleased
4+
5+
- Improved `Async::Safe.transfer` to recursively transfer ownership of tracked instance variables.
6+
37
## v0.3.2
48

59
- Better error message.
610

711
## v0.3.0
812

9-
- Inverted default model: classes are async-safe by default, use `ASYNC_SAFE = false` to enable tracking.
10-
- Added flexible `ASYNC_SAFE` constant support: boolean, hash, or array configurations.
11-
- Added `Class#async_safe!` method for marking classes.
12-
- Added `Class#async_safe?(method)` method for querying safety.
13-
- Removed logger feature: always raises `ViolationError` exceptions.
14-
- Removed `Async::Safe::Concurrent` module: use `async_safe!` instead.
13+
- Inverted default model: classes are async-safe by default, use `ASYNC_SAFE = false` to enable tracking.
14+
- Added flexible `ASYNC_SAFE` constant support: boolean, hash, or array configurations.
15+
- Added `Class#async_safe!` method for marking classes.
16+
- Added `Class#async_safe?(method)` method for querying safety.
17+
- Added `Class.async_safe_traverse` for custom deep transfer traversal (opt-in).
18+
- Improved `Async::Safe.transfer` to use shallow transfer by default with opt-in deep traversal.
19+
- Mark built-in collections (`Array`, `Hash`, `Set`) as single-owner with deep traversal support.
20+
- Removed logger feature: always raises `ViolationError` exceptions.
21+
- Removed `Async::Safe::Concurrent` module: use `async_safe!` instead.
1522

1623
## v0.2.0
1724

test/async/safe.rb

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,33 @@ def push(item)
7373
end.not.to raise_exception
7474
end
7575

76+
it "recursively transfers owned instance variables" do
77+
inner_class = Class.new do
78+
async_safe!(false)
79+
def read; "data"; end
80+
end
81+
82+
outer_class = Class.new do
83+
async_safe!(false)
84+
attr_reader :inner
85+
def initialize(inner); @inner = inner; end
86+
end
87+
88+
inner = inner_class.new
89+
outer = outer_class.new(inner)
90+
91+
# Both owned by main fiber:
92+
inner.read
93+
outer.inner
94+
95+
Fiber.new do
96+
Async::Safe.transfer(outer)
97+
98+
# Both outer and inner should be transferred:
99+
outer.inner.read
100+
end.resume
101+
end
102+
76103
it "allows access after ownership transfer" do
77104
body = body_class.new(["a", "b"])
78105
body.read # Main fiber owns it

0 commit comments

Comments
 (0)