async-safe 0.4.1 → 0.5.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: 72500ee1207a863df7f749896936bf9edce7e06f2f7d7a519329c0ada72951eb
4
- data.tar.gz: 584c0393294503ea01cf1e1858f88f3c2faf5e2cd6fdf1b2f632af0708e4d787
3
+ metadata.gz: a159f426b967f880b5935565fde44767b006695b75475e097f3e8d2e7239f8fe
4
+ data.tar.gz: a3549d80e371d6b42e6bacd9f6aa64f09479e8432e0648b8aa4b2a93c5e25f45
5
5
  SHA512:
6
- metadata.gz: 305ea4886f6b9fa0452de045244623806ed824ba24791ed0d2d1e6bf96a14f536f31fccbd54f099e5cc10ee1dc39067609be9b646daac413c47126a86f0306a2
7
- data.tar.gz: 78dd7ab4aab4893c2824788e03522905698d6f400557976677253fbcc53643b4a0dbaaa8a8157249415f9380a317ffddc126948bb484fa2b6c795513abf4fc2d
6
+ metadata.gz: 8b514ecda9b54080477fe913b9facaeba091523ba6d72dc2aa3a186c1c01fff74595057f64947fff3fcb570804166d273009518d3275861373cd5ccea21edc67
7
+ data.tar.gz: 4fe5eabb41e14c091176b1b30ee600db76143158db5a18bfa4b995d8e3a071b8d84e7857cb84a3443d70864cf36accc57ba514c857948fc18c9699bea88e443d
checksums.yaml.gz.sig CHANGED
Binary file
@@ -25,31 +25,103 @@ Async::Safe.enable!
25
25
 
26
26
  When a violation is detected, an `Async::Safe::ViolationError` will be raised immediately with details about the object, method, and execution contexts involved.
27
27
 
28
+ ## Concurrent Access Detection
29
+
30
+ `async-safe` detects **concurrent access** (data races) to objects across fibers. Objects can move freely between fibers - violations are only raised when two fibers try to access the same object simultaneously.
31
+
32
+ ~~~ ruby
33
+ Async::Safe.enable!
34
+
35
+ request = Request.new("http://example.com")
36
+ request.process # Main fiber
37
+
38
+ Fiber.new do
39
+ # No problem - sequential access is allowed
40
+ request.process # ✅ OK
41
+ end.resume
42
+ ~~~
43
+
44
+ However, actual concurrent access is detected:
45
+
46
+ ~~~ ruby
47
+ require 'async'
48
+
49
+ counter = Counter.new
50
+ counter.increment
51
+
52
+ Async do |task|
53
+ task.async do
54
+ counter.increment # Fiber A accessing
55
+ sleep 0.1 # ... method is still running
56
+ end
57
+
58
+ task.async do
59
+ sleep 0.05 # Wait for Fiber A to start
60
+ counter.increment # 💥 Concurrent access detected!
61
+ end
62
+ end
63
+ ~~~
64
+
65
+ This approach focuses on catching **actual bugs** (data races) while allowing objects to move naturally between fibers.
66
+
67
+ ### Guard-Based Concurrency
68
+
69
+ For objects with multiple independent operation types (like streams with separate read/write operations), `async_safe?` can return different guard symbols for different operations:
70
+
71
+ ~~~ ruby
72
+ class Stream
73
+ def self.async_safe?(method)
74
+ case method
75
+ when :read then :readable
76
+ when :write then :writable
77
+ else false
78
+ end
79
+ end
80
+
81
+ def read; end
82
+ def write(data); end
83
+ end
84
+ ~~~
85
+
86
+ This allows:
87
+ - ✅ Concurrent `read` and `write` (different guards: `:readable` and `:writable`)
88
+ - ❌ Concurrent `read` and `read` (same `:readable` guard)
89
+ - ❌ Concurrent `write` and `write` (same `:writable` guard)
90
+
91
+ Each guard can only be held by one fiber at a time, but different guards can be held concurrently.
92
+
28
93
  ### Single-Owner Model
29
94
 
30
95
  By default, all classes are assumed to be async-safe. To enable tracking for specific classes, mark them with `ASYNC_SAFE = false`:
31
96
 
32
97
  ~~~ ruby
33
98
  class MyBody
34
- ASYNC_SAFE = false # Enable tracking for this class
35
-
36
- def initialize(chunks)
37
- @chunks = chunks
38
- @index = 0
39
- end
40
-
41
- def read
42
- chunk = @chunks[@index]
43
- @index += 1
44
- chunk
45
- end
99
+ ASYNC_SAFE = false # Enable tracking for this class
100
+
101
+ def initialize(chunks)
102
+ @chunks = chunks
103
+ @index = 0
104
+ end
105
+
106
+ def read
107
+ chunk = @chunks[@index]
108
+ @index += 1
109
+ chunk
110
+ end
46
111
  end
47
112
 
48
113
  body = MyBody.new(["a", "b", "c"])
49
- body.read # OK - accessed from main fiber
114
+ body.read # Main fiber
50
115
 
51
116
  Fiber.schedule do
52
- body.read # 💥 Raises Async::Safe::ViolationError!
117
+ body.read # OK - sequential access is allowed
118
+ end
119
+
120
+ # But concurrent access is detected:
121
+ require 'async'
122
+ Async do |task|
123
+ task.async { body.read } # Two fibers accessing
124
+ task.async { body.read } # at the same time → ViolationError!
53
125
  end
54
126
  ~~~
55
127
 
@@ -59,26 +131,26 @@ Mark entire classes as safe for concurrent access:
59
131
 
60
132
  ~~~ ruby
61
133
  class MyQueue
62
- async_safe!
63
-
64
- def initialize
65
- @queue = Thread::Queue.new
66
- end
67
-
68
- def push(item)
69
- @queue.push(item)
70
- end
71
-
72
- def pop
73
- @queue.pop
74
- end
134
+ async_safe!
135
+
136
+ def initialize
137
+ @queue = Thread::Queue.new
138
+ end
139
+
140
+ def push(item)
141
+ @queue.push(item)
142
+ end
143
+
144
+ def pop
145
+ @queue.pop
146
+ end
75
147
  end
76
148
 
77
149
  queue = MyQueue.new
78
150
  queue.push("item")
79
151
 
80
152
  Fiber.schedule do
81
- queue.push("another") # ✅ OK - class is marked async-safe
153
+ queue.push("another") # ✅ OK - class is marked async-safe
82
154
  end
83
155
  ~~~
84
156
 
@@ -86,9 +158,9 @@ Alternatively, you can manually set the constant:
86
158
 
87
159
  ~~~ ruby
88
160
  class MyQueue
89
- ASYNC_SAFE = true
90
-
91
- # ... implementation
161
+ ASYNC_SAFE = true
162
+
163
+ # ... implementation
92
164
  end
93
165
  ~~~
94
166
 
@@ -96,12 +168,12 @@ Or use a hash for per-method configuration:
96
168
 
97
169
  ~~~ ruby
98
170
  class MixedClass
99
- ASYNC_SAFE = {
100
- read: true, # This method is async-safe
101
- write: false # This method is NOT async-safe
102
- }.freeze
103
-
104
- # ... implementation
171
+ ASYNC_SAFE = {
172
+ read: true, # This method is async-safe
173
+ write: false # This method is NOT async-safe
174
+ }.freeze
175
+
176
+ # ... implementation
105
177
  end
106
178
  ~~~
107
179
 
@@ -111,30 +183,30 @@ Use a hash to specify which methods are async-safe:
111
183
 
112
184
  ~~~ ruby
113
185
  class MixedSafety
114
- ASYNC_SAFE = {
115
- safe_read: true, # This method is async-safe
116
- increment: false # This method is NOT async-safe
117
- }.freeze
118
-
119
- def initialize(data)
120
- @data = data
121
- @count = 0
122
- end
123
-
124
- def safe_read
125
- @data # Async-safe method
126
- end
127
-
128
- def increment
129
- @count += 1 # Not async-safe - will be tracked
130
- end
186
+ ASYNC_SAFE = {
187
+ safe_read: true, # This method is async-safe
188
+ increment: false # This method is NOT async-safe
189
+ }.freeze
190
+
191
+ def initialize(data)
192
+ @data = data
193
+ @count = 0
194
+ end
195
+
196
+ def safe_read
197
+ @data # Async-safe method
198
+ end
199
+
200
+ def increment
201
+ @count += 1 # Not async-safe - will be tracked
202
+ end
131
203
  end
132
204
 
133
205
  obj = MixedSafety.new("data")
134
206
 
135
207
  Fiber.schedule do
136
- obj.safe_read # ✅ OK - method is marked async-safe
137
- obj.increment # 💥 Raises Async::Safe::ViolationError!
208
+ obj.safe_read # ✅ OK - method is marked async-safe
209
+ obj.increment # 💥 Raises Async::Safe::ViolationError!
138
210
  end
139
211
  ~~~
140
212
 
@@ -142,58 +214,13 @@ Or use an array to list async-safe methods:
142
214
 
143
215
  ~~~ ruby
144
216
  class MyClass
145
- ASYNC_SAFE = [:read, :inspect].freeze
146
-
147
- # read and inspect are async-safe
148
- # all other methods will be tracked
217
+ ASYNC_SAFE = [:read, :inspect].freeze
218
+
219
+ # read and inspect are async-safe
220
+ # all other methods will be tracked
149
221
  end
150
222
  ~~~
151
223
 
152
- ### Transferring Ownership
153
-
154
- Explicitly transfer ownership between fibers:
155
-
156
- ~~~ ruby
157
- request = create_request
158
- process_in_main_fiber(request)
159
-
160
- Fiber.schedule do
161
- Async::Safe.transfer(request) # Transfer ownership
162
- process_in_worker_fiber(request) # ✅ OK now
163
- end
164
- ~~~
165
-
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
185
- yield 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
224
 
198
225
  ## Integration with Tests
199
226
 
@@ -222,37 +249,37 @@ When deciding whether to mark a class or method with `ASYNC_SAFE = false`, consi
222
249
  **Immutable objects:**
223
250
  ~~~ ruby
224
251
  class ImmutableUser
225
- def initialize(name, email)
226
- @name = name.freeze
227
- @email = email.freeze
228
- freeze # Entire object is frozen
229
- end
230
-
231
- attr_reader :name, :email
252
+ def initialize(name, email)
253
+ @name = name.freeze
254
+ @email = email.freeze
255
+ freeze # Entire object is frozen
256
+ end
257
+
258
+ attr_reader :name, :email
232
259
  end
233
260
  ~~~
234
261
 
235
262
  **Pure functions (no state modification):**
236
263
  ~~~ ruby
237
264
  class Calculator
238
- def add(a, b)
239
- a + b # No instance state, pure computation
240
- end
265
+ def add(a, b)
266
+ a + b # No instance state, pure computation
267
+ end
241
268
  end
242
269
  ~~~
243
270
 
244
271
  **Thread-safe synchronization:**
245
272
  ~~~ ruby
246
273
  class SafeQueue
247
- ASYNC_SAFE = true # Explicitly marked
248
-
249
- def initialize
250
- @queue = Thread::Queue.new # Thread-safe internally
251
- end
252
-
253
- def push(item)
254
- @queue.push(item) # Delegates to thread-safe queue
255
- end
274
+ ASYNC_SAFE = true # Explicitly marked
275
+
276
+ def initialize
277
+ @queue = Thread::Queue.new # Thread-safe internally
278
+ end
279
+
280
+ def push(item)
281
+ @queue.push(item) # Delegates to thread-safe queue
282
+ end
256
283
  end
257
284
  ~~~
258
285
 
@@ -261,44 +288,44 @@ end
261
288
  **Mutable instance state:**
262
289
  ~~~ ruby
263
290
  class Counter
264
- ASYNC_SAFE = false # Enable tracking
265
-
266
- def initialize
267
- @count = 0
268
- end
269
-
270
- def increment
271
- @count += 1 # Reads and writes @count (race condition!)
272
- end
291
+ ASYNC_SAFE = false # Enable tracking
292
+
293
+ def initialize
294
+ @count = 0
295
+ end
296
+
297
+ def increment
298
+ @count += 1 # Reads and writes @count (race condition!)
299
+ end
273
300
  end
274
301
  ~~~
275
302
 
276
303
  **Stateful iteration:**
277
304
  ~~~ ruby
278
305
  class Reader
279
- ASYNC_SAFE = false # Enable tracking
280
-
281
- def initialize(data)
282
- @data = data
283
- @index = 0
284
- end
285
-
286
- def read
287
- value = @data[@index]
288
- @index += 1 # Mutates state
289
- value
290
- end
306
+ ASYNC_SAFE = false # Enable tracking
307
+
308
+ def initialize(data)
309
+ @data = data
310
+ @index = 0
311
+ end
312
+
313
+ def read
314
+ value = @data[@index]
315
+ @index += 1 # Mutates state
316
+ value
317
+ end
291
318
  end
292
319
  ~~~
293
320
 
294
321
  **Lazy initialization:**
295
322
  ~~~ ruby
296
323
  class DataLoader
297
- ASYNC_SAFE = false # Enable tracking
298
-
299
- def data
300
- @data ||= load_data # Not atomic! (race condition)
301
- end
324
+ ASYNC_SAFE = false # Enable tracking
325
+
326
+ def data
327
+ @data ||= load_data # Not atomic! (race condition)
328
+ end
302
329
  end
303
330
  ~~~
304
331
 
@@ -308,23 +335,23 @@ Use hash or array configuration for classes with both safe and unsafe methods:
308
335
 
309
336
  ~~~ ruby
310
337
  class MixedClass
311
- ASYNC_SAFE = {
312
- read_config: true, # Safe: only reads frozen data
313
- update_state: false # Unsafe: modifies mutable state
314
- }.freeze
315
-
316
- def initialize
317
- @config = {setting: "value"}.freeze
318
- @state = {count: 0}
319
- end
320
-
321
- def read_config
322
- @config[:setting] # Safe: frozen hash
323
- end
324
-
325
- def update_state
326
- @state[:count] += 1 # Unsafe: mutates state
327
- end
338
+ ASYNC_SAFE = {
339
+ read_config: true, # Safe: only reads frozen data
340
+ update_state: false # Unsafe: modifies mutable state
341
+ }.freeze
342
+
343
+ def initialize
344
+ @config = {setting: "value"}.freeze
345
+ @state = {count: 0}
346
+ end
347
+
348
+ def read_config
349
+ @config[:setting] # Safe: frozen hash
350
+ end
351
+
352
+ def update_state
353
+ @state[:count] += 1 # Unsafe: mutates state
354
+ end
328
355
  end
329
356
  ~~~
330
357
 
@@ -351,24 +378,3 @@ If you're unsure whether a class is thread-safe:
351
378
  2. **Run your tests** with monitoring enabled.
352
379
  3. **If no violations occur** after thorough testing, it's likely safe.
353
380
  4. **Review the code** for the patterns above.
354
-
355
- ## How It Works
356
-
357
- 1. **Default Assumption**: All objects follow a single-owner model (not thread-safe).
358
- 2. **TracePoint Monitoring**: Tracks which fiber/thread first accesses each object.
359
- 3. **Violation Detection**: Raises an exception when a different fiber/thread accesses the same object.
360
- 4. **Explicit Safety**: Objects/methods can be marked as thread-safe to allow concurrent access.
361
- 5. **Zero Overhead**: Monitoring is only active when explicitly enabled.
362
-
363
- ## Use Cases
364
-
365
- - **Detecting concurrency bugs** in development and testing.
366
- - **Validating thread safety assumptions** in async/fiber-based code.
367
- - **Finding race conditions** before they cause production issues.
368
- - **Educational tool** for learning about thread safety in Ruby.
369
-
370
- ## Performance
371
-
372
- - **Zero overhead when disabled** - TracePoint is not activated.
373
- - **Minimal overhead when enabled** - suitable for development/test environments.
374
- - **Not recommended for production** - use only in development/testing.
@@ -50,27 +50,4 @@ class Class
50
50
  def async_safe!(value = true)
51
51
  self.const_set(:ASYNC_SAFE, value)
52
52
  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
- # attr_reader :children
67
- #
68
- # def self.async_safe_traverse(instance, &block)
69
- # instance.children.each(&block)
70
- # end
71
- # end
72
- # ~~~
73
- def async_safe_traverse(instance, &block)
74
- # Default: no traversal (shallow transfer only)
75
- end
76
53
  end
@@ -3,81 +3,42 @@
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2025, by Samuel Williams.
5
5
 
6
- require "set"
7
- require "weakref"
8
-
9
- # Fiber-local variable to track when we're in a transfer operation:
10
- Fiber.attr_accessor :async_safe_transfer
6
+ require_relative "violation_error"
11
7
 
12
8
  module Async
13
9
  module Safe
14
- # Raised when an object is accessed from a different fiber than the one that owns it.
15
- class ViolationError < StandardError
16
- # Initialize a new violation error.
17
- #
18
- # @parameter message [String | Nil] Optional custom message.
19
- # @parameter target [Object] The object that was accessed.
20
- # @parameter method [Symbol] The method that was called.
21
- # @parameter owner [Fiber] The fiber that owns the object.
22
- # @parameter current [Fiber] The fiber that attempted to access the object.
23
- def initialize(message = nil, target:, method:, owner:, current:)
24
- @target = target
25
- @method = method
26
- @owner = owner
27
- @current = current
28
-
29
- super(message || build_message)
30
- end
31
-
32
- attr_reader :object_class, :method, :owner, :current
33
-
34
- # Convert the violation error to a JSON-serializable hash.
35
- #
36
- # @returns [Hash] A hash representation of the violation.
37
- def as_json
38
- {
39
- object_class: @object_class,
40
- method: @method,
41
- owner: {
42
- name: @owner.inspect,
43
- backtrace: @owner.backtrace,
44
- },
45
- current: {
46
- name: @current.inspect,
47
- backtrace: @current.backtrace,
48
- },
49
- }
50
- end
51
-
52
- private def build_message
53
- "Thread safety violation detected!\n\tObject: #{@target.inspect}##{@method}\n\tOwner: #{@owner.inspect}\n\tAccessed by: #{@current.inspect}"
54
- end
55
- end
56
-
57
- # The core monitoring implementation using TracePoint.
10
+ # Monitors for concurrent access to objects across fibers.
58
11
  #
59
- # This class tracks object ownership across fibers, detecting when an object
60
- # is accessed from a different fiber than the one that originally created or
61
- # accessed it.
12
+ # This monitor detects when multiple fibers try to execute methods on the same
13
+ # object simultaneously (actual data races). Sequential access across fibers is
14
+ # allowed - objects can be passed between fibers freely.
62
15
  #
63
- # The monitor uses a TracePoint on `:call` events to track all method calls,
64
- # and maintains a registry of which fiber "owns" each object. Uses weak references
65
- # to avoid preventing garbage collection of tracked objects.
16
+ # Uses TracePoint to track in-flight method calls and detect concurrent access.
66
17
  class Monitor
67
18
  ASYNC_SAFE = true
68
19
 
69
- # Initialize a new monitor instance.
20
+ # Initialize a new concurrency monitor.
70
21
  def initialize
71
- @owners = ObjectSpace::WeakMap.new
22
+ @guards = ObjectSpace::WeakMap.new # Tracks {object => fiber} or {object => {guard => fiber}}
72
23
  @mutex = Thread::Mutex.new
73
24
  @trace_point = nil
74
25
  end
75
26
 
76
- attr :owners
27
+ attr :guards
77
28
 
78
29
  # Enable the monitor by activating the TracePoint.
79
30
  def enable!
80
- @trace_point ||= TracePoint.trace(:call, &method(:check_access))
31
+ return if @trace_point
32
+
33
+ @trace_point = TracePoint.new(:call, :return) do |tp|
34
+ if tp.event == :call
35
+ check_call(tp)
36
+ else
37
+ check_return(tp)
38
+ end
39
+ end
40
+
41
+ @trace_point.enable
81
42
  end
82
43
 
83
44
  # Disable the monitor by deactivating the TracePoint.
@@ -88,67 +49,94 @@ module Async
88
49
  end
89
50
  end
90
51
 
91
- # Explicitly transfer ownership of objects to the current fiber.
52
+ # Transfer has no effect in concurrency monitoring.
92
53
  #
93
- # Also recursively transfers ownership of any tracked instance variables and
94
- # objects contained in collections (Array, Hash, Set).
54
+ # Objects can move freely between fibers. This method exists for
55
+ # backward compatibility but does nothing.
95
56
  #
96
- # @parameter objects [Array(Object)] The objects to transfer.
57
+ # @parameter objects [Array(Object)] The objects to transfer (ignored).
97
58
  def transfer(*objects)
98
- current = Fiber.current
99
- visited = Set.new
100
-
101
- # Disable tracking during traversal to avoid deadlock:
102
- current.async_safe_transfer = true
103
-
104
- begin
105
- # Traverse object graph:
106
- objects.each do |object|
107
- traverse_objects(object, visited)
108
- end
109
-
110
- # Transfer all visited objects:
111
- @mutex.synchronize do
112
- visited.each do |object|
113
- @owners[object] = current if @owners.key?(object)
114
- end
115
- end
116
- ensure
117
- current.async_safe_transfer = false
118
- end
59
+ # No-op - objects move freely between fibers
119
60
  end
120
61
 
121
- # Traverse the object graph and collect all reachable objects.
62
+ # Check method call for concurrent access violations.
122
63
  #
123
- # @parameter object [Object] The object to traverse.
124
- # @parameter visited [Set] Set of visited objects (object references, not IDs).
125
- private def traverse_objects(object, visited)
126
- # Avoid circular references:
127
- return if visited.include?(object)
64
+ # @parameter trace_point [TracePoint] The trace point containing call information.
65
+ private def check_call(trace_point)
66
+ object = trace_point.self
67
+
68
+ # Skip tracking class/module methods:
69
+ return if object.is_a?(Module)
70
+
71
+ # Skip frozen objects:
72
+ return if object.frozen?
128
73
 
129
- # Skip objects that don't need traversing:
130
- return if object.frozen? or object.is_a?(Module)
74
+ method = trace_point.method_id
131
75
 
132
- # Skip async-safe (shareable) objects - they're not owned:
76
+ # Check the object's actual class:
133
77
  klass = object.class
134
- return if klass.async_safe?(nil)
135
78
 
136
- # Mark as visited:
137
- visited << object
79
+ # Check if the class or method is marked as async-safe:
80
+ # Returns: true (skip), false (simple tracking), or Symbol (guard-based tracking)
81
+ safe = klass.async_safe?(method)
82
+ return if safe == true
83
+
84
+ current = Fiber.current
138
85
 
139
- # Recurse through custom traversal:
140
- klass.async_safe_traverse(object) do |element|
141
- traverse_objects(element, visited)
86
+ @mutex.synchronize do
87
+ if safe == false
88
+ # Simple tracking (single guard)
89
+ if fiber = @guards[object]
90
+ if fiber != current && !fiber.is_a?(Hash)
91
+ # Concurrent access detected!
92
+ raise ViolationError.new(
93
+ "Concurrent access detected!",
94
+ target: object,
95
+ method: method,
96
+ owner: fiber,
97
+ current: current,
98
+ )
99
+ end
100
+ else
101
+ # Acquire the guard
102
+ @guards[object] = current
103
+ end
104
+ else
105
+ # Multi-guard tracking
106
+ guard = safe
107
+
108
+ # Get or create the guards hash for this object
109
+ entry = @guards[object]
110
+ if entry.nil? || !entry.is_a?(Hash)
111
+ guards = @guards[object] = {}
112
+ else
113
+ guards = entry
114
+ end
115
+
116
+ # Check if another fiber currently holds this guard
117
+ if fiber = guards[guard]
118
+ if fiber != current
119
+ # Concurrent access detected within the same guard!
120
+ raise ViolationError.new(
121
+ "Concurrent access detected (guard: #{guard})!",
122
+ target: object,
123
+ method: method,
124
+ owner: fiber,
125
+ current: current,
126
+ )
127
+ end
128
+ else
129
+ # Acquire this guard
130
+ guards[guard] = current
131
+ end
132
+ end
142
133
  end
143
134
  end
144
135
 
145
- # Check if the current access is allowed or constitutes a violation.
136
+ # Check method return to release guard.
146
137
  #
147
- # @parameter trace_point [TracePoint] The trace point containing access information.
148
- def check_access(trace_point)
149
- # Skip if we're in a transfer operation:
150
- return if Fiber.current.async_safe_transfer
151
-
138
+ # @parameter trace_point [TracePoint] The trace point containing return information.
139
+ private def check_return(trace_point)
152
140
  object = trace_point.self
153
141
 
154
142
  # Skip tracking class/module methods:
@@ -158,33 +146,32 @@ module Async
158
146
  return if object.frozen?
159
147
 
160
148
  method = trace_point.method_id
161
- klass = trace_point.defined_class
162
149
 
163
150
  # Check the object's actual class:
164
151
  klass = object.class
165
152
 
166
153
  # Check if the class or method is marked as async-safe:
167
- if klass.async_safe?(method)
168
- return
169
- end
154
+ safe = klass.async_safe?(method)
155
+ return if safe == true
170
156
 
171
- # Track ownership:
172
157
  current = Fiber.current
173
158
 
174
159
  @mutex.synchronize do
175
- if owner = @owners[object]
176
- # Violation if accessed from different fiber:
177
- if owner != current
178
- raise ViolationError.new(
179
- target: object,
180
- method: method,
181
- owner: owner,
182
- current: current,
183
- )
184
- end
160
+ entry = @guards[object]
161
+
162
+ if safe == false
163
+ # Simple tracking (single guard)
164
+ # Release if this fiber holds it
165
+ @guards.delete(object) if entry == current
185
166
  else
186
- # First access - record owner:
187
- @owners[object] = current
167
+ # Multi-guard tracking
168
+ guard = safe
169
+
170
+ if entry.is_a?(Hash)
171
+ entry.delete(guard) if entry[guard] == current
172
+ # Clean up empty guards hash
173
+ @guards.delete(object) if entry.empty?
174
+ end
188
175
  end
189
176
  end
190
177
  end
@@ -5,7 +5,7 @@
5
5
 
6
6
  module Async
7
7
  module Safe
8
- VERSION = "0.4.1"
8
+ VERSION = "0.5.0"
9
9
  end
10
10
  end
11
11
 
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2025, by Samuel Williams.
5
+
6
+ module Async
7
+ module Safe
8
+ # Raised when an object is accessed from a different fiber than the one that owns it.
9
+ class ViolationError < StandardError
10
+ # Initialize a new violation error.
11
+ #
12
+ # @parameter message [String | Nil] Optional custom message.
13
+ # @parameter target [Object] The object that was accessed.
14
+ # @parameter method [Symbol] The method that was called.
15
+ # @parameter owner [Fiber] The fiber that owns the object.
16
+ # @parameter current [Fiber] The fiber that attempted to access the object.
17
+ def initialize(message = nil, target:, method:, owner:, current:)
18
+ @target = target
19
+ @method = method
20
+ @owner = owner
21
+ @current = current
22
+
23
+ super(message || build_message)
24
+ end
25
+
26
+ attr_reader :object_class, :method, :owner, :current
27
+
28
+ # Convert the violation error to a JSON-serializable hash.
29
+ #
30
+ # @returns [Hash] A hash representation of the violation.
31
+ def as_json
32
+ {
33
+ object_class: @object_class,
34
+ method: @method,
35
+ owner: {
36
+ name: @owner.inspect,
37
+ backtrace: @owner.backtrace,
38
+ },
39
+ current: {
40
+ name: @current.inspect,
41
+ backtrace: @current.backtrace,
42
+ },
43
+ }
44
+ end
45
+
46
+ private def build_message
47
+ "Thread safety violation detected!\n\tObject: #{@target.inspect}##{@method}\n\tOwner: #{@owner.inspect}\n\tAccessed by: #{@current.inspect}"
48
+ end
49
+ end
50
+ end
51
+ end
52
+
data/lib/async/safe.rb CHANGED
@@ -6,7 +6,6 @@
6
6
  require_relative "safe/version"
7
7
  require_relative "safe/class"
8
8
  require_relative "safe/monitor"
9
- require_relative "safe/builtins"
10
9
 
11
10
  # @namespace
12
11
  module Async
@@ -24,8 +23,11 @@ module Async
24
23
 
25
24
  # Enable thread safety monitoring.
26
25
  #
27
- # This activates a TracePoint that tracks object access across fibers and threads.
28
- # There is no performance overhead when monitoring is disabled.
26
+ # This activates a TracePoint that detects concurrent access to objects across
27
+ # fibers and threads. There is no performance overhead when monitoring is disabled.
28
+ #
29
+ # Objects can move freely between fibers - only actual concurrent access (data races)
30
+ # is detected and reported.
29
31
  def enable!
30
32
  @monitor ||= Monitor.new
31
33
  @monitor.enable!
@@ -37,23 +39,14 @@ module Async
37
39
  @monitor = nil
38
40
  end
39
41
 
40
- # Explicitly transfer ownership of objects to the current fiber.
41
- #
42
- # This allows an object to be safely passed between fibers.
42
+ # Transfer has no effect in concurrency monitoring mode.
43
43
  #
44
- # @parameter objects [Array(Object)] The objects to transfer ownership of.
44
+ # Objects can move freely between fibers. This method is kept for
45
+ # backward compatibility but does nothing.
45
46
  #
46
- # ~~~ ruby
47
- # request = Request.new(...)
48
- #
49
- # Fiber.schedule do
50
- # # Transfer ownership of the request to this fiber:
51
- # Async::Safe.transfer(request)
52
- # process(request)
53
- # end
54
- # ~~~
47
+ # @parameter objects [Array(Object)] The objects to transfer (ignored).
55
48
  def transfer(*objects)
56
- @monitor&.transfer(*objects)
49
+ # No-op - objects can move freely between fibers
57
50
  end
58
51
  end
59
52
  end
data/readme.md CHANGED
@@ -8,9 +8,9 @@ This gem provides a TracePoint-based ownership tracking system that detects when
8
8
 
9
9
  ## Motivation
10
10
 
11
- Ruby's fiber-based concurrency (via `async`) requires careful attention to object ownership. This gem helps you catch violations of the single-owner model in your test suite, preventing concurrency bugs from reaching production.
11
+ Ruby's fiber-based concurrency (via `async`) can lead to data races when objects are accessed concurrently. This gem helps you catch these concurrency bugs in your test suite by detecting when multiple fibers access the same object simultaneously.
12
12
 
13
- Enable it in your tests to get immediate feedback when objects are incorrectly shared across fibers.
13
+ Enable it in your tests to get immediate feedback when invalid concurrent access occurs.
14
14
 
15
15
  ## Usage
16
16
 
@@ -22,6 +22,11 @@ Please see the [project documentation](https://socketry.github.io/async-safe/) f
22
22
 
23
23
  Please see the [project releases](https://socketry.github.io/async-safe/releases/index) for all releases.
24
24
 
25
+ ### v0.5.0
26
+
27
+ - More conservative tracking of objects using call/return for ownership transfer.
28
+ - Introduced guard concept for splitting ownership within a single object, e.g. independently concurrent readable and writable parts of an object.
29
+
25
30
  ### v0.4.0
26
31
 
27
32
  - Improved `Async::Safe.transfer` to recursively transfer ownership of tracked instance variables.
data/releases.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Releases
2
2
 
3
+ ## v0.5.0
4
+
5
+ - More conservative tracking of objects using call/return for ownership transfer.
6
+ - Introduced guard concept for splitting ownership within a single object, e.g. independently concurrent readable and writable parts of an object.
7
+
3
8
  ## v0.4.0
4
9
 
5
10
  - Improved `Async::Safe.transfer` to recursively transfer ownership of tracked instance variables.
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: async-safe
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
@@ -45,10 +45,10 @@ files:
45
45
  - context/getting-started.md
46
46
  - context/index.yaml
47
47
  - lib/async/safe.rb
48
- - lib/async/safe/builtins.rb
49
48
  - lib/async/safe/class.rb
50
49
  - lib/async/safe/monitor.rb
51
50
  - lib/async/safe/version.rb
51
+ - lib/async/safe/violation_error.rb
52
52
  - license.md
53
53
  - readme.md
54
54
  - releases.md
metadata.gz.sig CHANGED
Binary file
@@ -1,97 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Released under the MIT License.
4
- # Copyright, 2025, by Samuel Williams.
5
-
6
- # Mark Ruby's built-in thread-safe classes as async-safe
7
- #
8
- # Note: Immutable values (nil, true, false, integers, symbols, etc.) are already
9
- # handled by the frozen? check in the monitor and don't need to be listed here.
10
-
11
- # Arrays contain references to other objects that may need transfer:
12
- class Array
13
- ASYNC_SAFE = false
14
-
15
- # Traverse array elements during ownership transfer.
16
- #
17
- # @parameter instance [Array] The array instance to traverse.
18
- # @parameter block [Proc] Block to call for each element.
19
- def self.async_safe_traverse(instance, &block)
20
- instance.each(&block)
21
- end
22
- end
23
-
24
- # Hashes contain keys and values that may need transfer:
25
- class Hash
26
- ASYNC_SAFE = false
27
-
28
- # Traverse hash keys and values during ownership transfer.
29
- #
30
- # @parameter instance [Hash] The hash instance to traverse.
31
- # @parameter block [Proc] Block to call for each key and value.
32
- def self.async_safe_traverse(instance, &block)
33
- instance.each_key(&block)
34
- instance.each_value(&block)
35
- end
36
- end
37
-
38
- # Sets contain elements that may need transfer:
39
- class Set
40
- ASYNC_SAFE = false
41
-
42
- # Traverse set elements during ownership transfer.
43
- #
44
- # @parameter instance [Set] The set instance to traverse.
45
- # @parameter block [Proc] Block to call for each element.
46
- def self.async_safe_traverse(instance, &block)
47
- instance.each(&block)
48
- end
49
- end
50
-
51
- module Async
52
- module Safe
53
- # Automatically transfers ownership of objects when they are removed from a Thread::Queue.
54
- #
55
- # When included in Thread::Queue or Thread::SizedQueue, this module wraps pop/deq/shift
56
- # methods to automatically transfer ownership of the dequeued object to the fiber that
57
- # dequeues it.
58
- module TransferableThreadQueue
59
- # Pop an object from the queue and transfer ownership to the current fiber.
60
- #
61
- # @parameter arguments [Array] Arguments passed to the original pop method.
62
- # @returns [Object] The dequeued object with transferred ownership.
63
- def pop(...)
64
- object = super(...)
65
- Async::Safe.transfer(object)
66
- object
67
- end
68
-
69
- # Dequeue an object from the queue and transfer ownership to the current fiber.
70
- #
71
- # Alias for {#pop}.
72
- #
73
- # @parameter arguments [Array] Arguments passed to the original deq method.
74
- # @returns [Object] The dequeued object with transferred ownership.
75
- def deq(...)
76
- object = super(...)
77
- Async::Safe.transfer(object)
78
- object
79
- end
80
-
81
- # Shift an object from the queue and transfer ownership to the current fiber.
82
- #
83
- # Alias for {#pop}.
84
- #
85
- # @parameter arguments [Array] Arguments passed to the original shift method.
86
- # @returns [Object] The dequeued object with transferred ownership.
87
- def shift(...)
88
- object = super(...)
89
- Async::Safe.transfer(object)
90
- object
91
- end
92
- end
93
- end
94
- end
95
-
96
- Thread::Queue.prepend(Async::Safe::TransferableThreadQueue)
97
- Thread::SizedQueue.prepend(Async::Safe::TransferableThreadQueue)