async-safe 0.2.0 → 0.3.1
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 +4 -4
- checksums.yaml.gz.sig +0 -0
- data/context/getting-started.md +173 -7
- data/lib/async/safe/builtins.rb +21 -13
- data/lib/async/safe/class.rb +30 -5
- data/lib/async/safe/monitor.rb +7 -9
- data/lib/async/safe/version.rb +1 -1
- data/lib/async/safe.rb +5 -46
- data/readme.md +10 -0
- data/releases.md +10 -0
- data.tar.gz.sig +0 -0
- metadata +1 -1
- metadata.gz.sig +0 -0
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: a165b5a9b594d4eec52f9a7c80ee3cc020db7925ac7630ddc342a552a3a20112
|
4
|
+
data.tar.gz: c9cedd555a12b366f00bf4fa43906501bd6596a8abd3db2531b457d3083bd093
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 5816b73d736aa8570fbdf3eed10ba2424e0dc138ac823bd37577cda99b42ac5c120556ac34b60cb645d08206a0214bd5b9a36bd8b25fae829791c522c2586ccf
|
7
|
+
data.tar.gz: d5c2d51e17041f290616eb45a4be80acd5e33b6a6b4e59a801462a3d7bfaccb453633c8e921a2d615136ac784b20fa92436fa73cf664ba4bcf6eaa807ab6ec7e
|
checksums.yaml.gz.sig
CHANGED
Binary file
|
data/context/getting-started.md
CHANGED
@@ -27,10 +27,12 @@ When a violation is detected, an `Async::Safe::ViolationError` will be raised im
|
|
27
27
|
|
28
28
|
### Single-Owner Model
|
29
29
|
|
30
|
-
By default, all
|
30
|
+
By default, all classes are assumed to be async-safe. To enable tracking for specific classes, mark them with `ASYNC_SAFE = false`:
|
31
31
|
|
32
32
|
~~~ ruby
|
33
33
|
class MyBody
|
34
|
+
ASYNC_SAFE = false # Enable tracking for this class
|
35
|
+
|
34
36
|
def initialize(chunks)
|
35
37
|
@chunks = chunks
|
36
38
|
@index = 0
|
@@ -90,15 +92,29 @@ class MyQueue
|
|
90
92
|
end
|
91
93
|
~~~
|
92
94
|
|
93
|
-
|
95
|
+
Or use a hash for per-method configuration:
|
94
96
|
|
95
|
-
|
97
|
+
~~~ ruby
|
98
|
+
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
|
105
|
+
end
|
106
|
+
~~~
|
107
|
+
|
108
|
+
### Marking Methods with Hash
|
109
|
+
|
110
|
+
Use a hash to specify which methods are async-safe:
|
96
111
|
|
97
112
|
~~~ ruby
|
98
113
|
class MixedSafety
|
99
|
-
|
100
|
-
|
101
|
-
|
114
|
+
ASYNC_SAFE = {
|
115
|
+
safe_read: true, # This method is async-safe
|
116
|
+
increment: false # This method is NOT async-safe
|
117
|
+
}.freeze
|
102
118
|
|
103
119
|
def initialize(data)
|
104
120
|
@data = data
|
@@ -110,7 +126,7 @@ class MixedSafety
|
|
110
126
|
end
|
111
127
|
|
112
128
|
def increment
|
113
|
-
@count += 1 # Not async-safe
|
129
|
+
@count += 1 # Not async-safe - will be tracked
|
114
130
|
end
|
115
131
|
end
|
116
132
|
|
@@ -122,6 +138,17 @@ Fiber.schedule do
|
|
122
138
|
end
|
123
139
|
~~~
|
124
140
|
|
141
|
+
Or use an array to list async-safe methods:
|
142
|
+
|
143
|
+
~~~ ruby
|
144
|
+
class MyClass
|
145
|
+
ASYNC_SAFE = [:read, :inspect].freeze
|
146
|
+
|
147
|
+
# read and inspect are async-safe
|
148
|
+
# all other methods will be tracked
|
149
|
+
end
|
150
|
+
~~~
|
151
|
+
|
125
152
|
### Transferring Ownership
|
126
153
|
|
127
154
|
Explicitly transfer ownership between fibers:
|
@@ -154,6 +181,145 @@ $ bundle exec sus
|
|
154
181
|
|
155
182
|
Any thread safety violations will cause your tests to fail immediately with a clear error message showing which object was accessed incorrectly and from which fibers.
|
156
183
|
|
184
|
+
## Determining Async Safety
|
185
|
+
|
186
|
+
When deciding whether to mark a class or method with `ASYNC_SAFE = false`, consider these factors:
|
187
|
+
|
188
|
+
### Async-Safe Patterns
|
189
|
+
|
190
|
+
**Immutable objects:**
|
191
|
+
~~~ ruby
|
192
|
+
class ImmutableUser
|
193
|
+
def initialize(name, email)
|
194
|
+
@name = name.freeze
|
195
|
+
@email = email.freeze
|
196
|
+
freeze # Entire object is frozen
|
197
|
+
end
|
198
|
+
|
199
|
+
attr_reader :name, :email
|
200
|
+
end
|
201
|
+
~~~
|
202
|
+
|
203
|
+
**Pure functions (no state modification):**
|
204
|
+
~~~ ruby
|
205
|
+
class Calculator
|
206
|
+
def add(a, b)
|
207
|
+
a + b # No instance state, pure computation
|
208
|
+
end
|
209
|
+
end
|
210
|
+
~~~
|
211
|
+
|
212
|
+
**Thread-safe synchronization:**
|
213
|
+
~~~ ruby
|
214
|
+
class SafeQueue
|
215
|
+
ASYNC_SAFE = true # Explicitly marked
|
216
|
+
|
217
|
+
def initialize
|
218
|
+
@queue = Thread::Queue.new # Thread-safe internally
|
219
|
+
end
|
220
|
+
|
221
|
+
def push(item)
|
222
|
+
@queue.push(item) # Delegates to thread-safe queue
|
223
|
+
end
|
224
|
+
end
|
225
|
+
~~~
|
226
|
+
|
227
|
+
### Unsafe (Single-Owner) Patterns
|
228
|
+
|
229
|
+
**Mutable instance state:**
|
230
|
+
~~~ ruby
|
231
|
+
class Counter
|
232
|
+
ASYNC_SAFE = false # Enable tracking
|
233
|
+
|
234
|
+
def initialize
|
235
|
+
@count = 0
|
236
|
+
end
|
237
|
+
|
238
|
+
def increment
|
239
|
+
@count += 1 # Reads and writes @count (race condition!)
|
240
|
+
end
|
241
|
+
end
|
242
|
+
~~~
|
243
|
+
|
244
|
+
**Stateful iteration:**
|
245
|
+
~~~ ruby
|
246
|
+
class Reader
|
247
|
+
ASYNC_SAFE = false # Enable tracking
|
248
|
+
|
249
|
+
def initialize(data)
|
250
|
+
@data = data
|
251
|
+
@index = 0
|
252
|
+
end
|
253
|
+
|
254
|
+
def read
|
255
|
+
value = @data[@index]
|
256
|
+
@index += 1 # Mutates state
|
257
|
+
value
|
258
|
+
end
|
259
|
+
end
|
260
|
+
~~~
|
261
|
+
|
262
|
+
**Lazy initialization:**
|
263
|
+
~~~ ruby
|
264
|
+
class DataLoader
|
265
|
+
ASYNC_SAFE = false # Enable tracking
|
266
|
+
|
267
|
+
def data
|
268
|
+
@data ||= load_data # Not atomic! (race condition)
|
269
|
+
end
|
270
|
+
end
|
271
|
+
~~~
|
272
|
+
|
273
|
+
### Mixed Safety
|
274
|
+
|
275
|
+
Use hash or array configuration for classes with both safe and unsafe methods:
|
276
|
+
|
277
|
+
~~~ ruby
|
278
|
+
class MixedClass
|
279
|
+
ASYNC_SAFE = {
|
280
|
+
read_config: true, # Safe: only reads frozen data
|
281
|
+
update_state: false # Unsafe: modifies mutable state
|
282
|
+
}.freeze
|
283
|
+
|
284
|
+
def initialize
|
285
|
+
@config = {setting: "value"}.freeze
|
286
|
+
@state = {count: 0}
|
287
|
+
end
|
288
|
+
|
289
|
+
def read_config
|
290
|
+
@config[:setting] # Safe: frozen hash
|
291
|
+
end
|
292
|
+
|
293
|
+
def update_state
|
294
|
+
@state[:count] += 1 # Unsafe: mutates state
|
295
|
+
end
|
296
|
+
end
|
297
|
+
~~~
|
298
|
+
|
299
|
+
### Quick Checklist
|
300
|
+
|
301
|
+
Mark a method as unsafe (`ASYNC_SAFE = false`) if it:
|
302
|
+
- ❌ Modifies instance variables.
|
303
|
+
- ❌ Uses `||=` for lazy initialization.
|
304
|
+
- ❌ Iterates with mutable state (like `@index`).
|
305
|
+
- ❌ Reads then writes shared state.
|
306
|
+
- ❌ Accesses mutable collections without synchronization.
|
307
|
+
|
308
|
+
A method is likely safe if it:
|
309
|
+
- ✅ Only reads from frozen/immutable data.
|
310
|
+
- ✅ Has no instance state.
|
311
|
+
- ✅ Uses only local variables.
|
312
|
+
- ✅ Delegates to thread-safe primitives `Thread::Queue`, `Mutex`, etc.
|
313
|
+
- ✅ The object itself is frozen.
|
314
|
+
|
315
|
+
### When in Doubt
|
316
|
+
|
317
|
+
If you're unsure whether a class is thread-safe:
|
318
|
+
1. **Mark it as unsafe** (`ASYNC_SAFE = false`) - let the monitoring catch any issues.
|
319
|
+
2. **Run your tests** with monitoring enabled.
|
320
|
+
3. **If no violations occur** after thorough testing, it's likely safe.
|
321
|
+
4. **Review the code** for the patterns above.
|
322
|
+
|
157
323
|
## How It Works
|
158
324
|
|
159
325
|
1. **Default Assumption**: All objects follow a single-owner model (not thread-safe).
|
data/lib/async/safe/builtins.rb
CHANGED
@@ -8,34 +8,42 @@
|
|
8
8
|
# Note: Immutable values (nil, true, false, integers, symbols, etc.) are already
|
9
9
|
# handled by the frozen? check in the monitor and don't need to be listed here.
|
10
10
|
|
11
|
-
# Thread synchronization primitives:
|
12
|
-
Thread::ASYNC_SAFE = true
|
13
|
-
Thread::Queue::ASYNC_SAFE = true
|
14
|
-
Thread::SizedQueue::ASYNC_SAFE = true
|
15
|
-
Thread::Mutex::ASYNC_SAFE = true
|
16
|
-
Thread::ConditionVariable::ASYNC_SAFE = true
|
17
|
-
|
18
|
-
# Fibers are async-safe:
|
19
|
-
Fiber::ASYNC_SAFE = true
|
20
|
-
|
21
|
-
# ObjectSpace::WeakMap is async-safe:
|
22
|
-
ObjectSpace::WeakMap::ASYNC_SAFE = true
|
23
|
-
|
24
11
|
module Async
|
25
12
|
module Safe
|
13
|
+
# Automatically transfers ownership of objects when they are removed from a Thread::Queue.
|
14
|
+
#
|
15
|
+
# When included in Thread::Queue or Thread::SizedQueue, this module wraps pop/deq/shift
|
16
|
+
# methods to automatically transfer ownership of the dequeued object to the fiber that
|
17
|
+
# dequeues it.
|
26
18
|
module TransferableThreadQueue
|
19
|
+
# Pop an object from the queue and transfer ownership to the current fiber.
|
20
|
+
#
|
21
|
+
# @parameter arguments [Array] Arguments passed to the original pop method.
|
22
|
+
# @returns [Object] The dequeued object with transferred ownership.
|
27
23
|
def pop(...)
|
28
24
|
object = super(...)
|
29
25
|
Async::Safe.transfer(object)
|
30
26
|
object
|
31
27
|
end
|
32
28
|
|
29
|
+
# Dequeue an object from the queue and transfer ownership to the current fiber.
|
30
|
+
#
|
31
|
+
# Alias for {#pop}.
|
32
|
+
#
|
33
|
+
# @parameter arguments [Array] Arguments passed to the original deq method.
|
34
|
+
# @returns [Object] The dequeued object with transferred ownership.
|
33
35
|
def deq(...)
|
34
36
|
object = super(...)
|
35
37
|
Async::Safe.transfer(object)
|
36
38
|
object
|
37
39
|
end
|
38
40
|
|
41
|
+
# Shift an object from the queue and transfer ownership to the current fiber.
|
42
|
+
#
|
43
|
+
# Alias for {#pop}.
|
44
|
+
#
|
45
|
+
# @parameter arguments [Array] Arguments passed to the original shift method.
|
46
|
+
# @returns [Object] The dequeued object with transferred ownership.
|
39
47
|
def shift(...)
|
40
48
|
object = super(...)
|
41
49
|
Async::Safe.transfer(object)
|
data/lib/async/safe/class.rb
CHANGED
@@ -7,15 +7,40 @@
|
|
7
7
|
class Class
|
8
8
|
# Check if this class or a specific method is async-safe.
|
9
9
|
#
|
10
|
+
# The `ASYNC_SAFE` constant can be:
|
11
|
+
# - `true` - entire class is async-safe.
|
12
|
+
# - `false` - entire class is NOT async-safe (single-owner).
|
13
|
+
# - `{method_name: true/false}` - per-method configuration.
|
14
|
+
# - `[method_name1, method_name2]` - per-method configuration.
|
15
|
+
#
|
10
16
|
# @parameter method [Symbol | Nil] The method name to check, or nil to check if the entire class is async-safe.
|
11
|
-
# @returns [Boolean] Whether the class or method is async-safe.
|
17
|
+
# @returns [Boolean] Whether the class or method is async-safe. Defaults to true if not specified.
|
12
18
|
def async_safe?(method = nil)
|
13
|
-
|
14
|
-
|
15
|
-
|
19
|
+
if const_defined?(:ASYNC_SAFE)
|
20
|
+
async_safe = const_get(:ASYNC_SAFE)
|
21
|
+
|
22
|
+
case async_safe
|
23
|
+
when Hash
|
24
|
+
if method
|
25
|
+
async_safe = async_safe.fetch(method, false)
|
26
|
+
else
|
27
|
+
# In general, some methods may not be safe:
|
28
|
+
async_safe = false
|
29
|
+
end
|
30
|
+
when Array
|
31
|
+
if method
|
32
|
+
async_safe = async_safe.include?(method)
|
33
|
+
else
|
34
|
+
# In general, some methods may not be safe:
|
35
|
+
async_safe = false
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
return async_safe
|
16
40
|
end
|
17
41
|
|
18
|
-
|
42
|
+
# Default to true:
|
43
|
+
return true
|
19
44
|
end
|
20
45
|
|
21
46
|
# Mark the class as async-safe or not.
|
data/lib/async/safe/monitor.rb
CHANGED
@@ -64,13 +64,10 @@ module Async
|
|
64
64
|
ASYNC_SAFE = true
|
65
65
|
|
66
66
|
# Initialize a new monitor instance.
|
67
|
-
|
68
|
-
# @parameter logger [Object | Nil] Optional logger to use for violations instead of raising exceptions.
|
69
|
-
def initialize(logger: nil)
|
67
|
+
def initialize
|
70
68
|
@owners = ObjectSpace::WeakMap.new
|
71
69
|
@mutex = Thread::Mutex.new
|
72
70
|
@trace_point = nil
|
73
|
-
@logger = logger
|
74
71
|
end
|
75
72
|
|
76
73
|
attr :owners
|
@@ -131,11 +128,12 @@ module Async
|
|
131
128
|
if owner = @owners[object]
|
132
129
|
# Violation if accessed from different fiber:
|
133
130
|
if owner != current
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
131
|
+
raise ViolationError.new(
|
132
|
+
target: object,
|
133
|
+
method: method,
|
134
|
+
owner: owner,
|
135
|
+
current: current,
|
136
|
+
)
|
139
137
|
end
|
140
138
|
else
|
141
139
|
# First access - record owner:
|
data/lib/async/safe/version.rb
CHANGED
data/lib/async/safe.rb
CHANGED
@@ -12,47 +12,12 @@ require_relative "safe/builtins"
|
|
12
12
|
module Async
|
13
13
|
# Provides runtime thread safety monitoring for concurrent Ruby code.
|
14
14
|
#
|
15
|
-
# By default, all
|
16
|
-
#
|
17
|
-
#
|
15
|
+
# By default, all classes are assumed to be async-safe. Classes that follow a
|
16
|
+
# **single-owner model** should be explicitly marked with `ASYNC_SAFE = false` to
|
17
|
+
# enable tracking and violation detection.
|
18
18
|
#
|
19
19
|
# Enable monitoring in your test suite to catch concurrency bugs early.
|
20
20
|
module Safe
|
21
|
-
# Include this module to mark specific methods as async-safe
|
22
|
-
def self.included(base)
|
23
|
-
base.extend(ClassMethods)
|
24
|
-
end
|
25
|
-
|
26
|
-
# Class methods for marking async-safe methods
|
27
|
-
module ClassMethods
|
28
|
-
# Mark one or more methods as async-safe.
|
29
|
-
#
|
30
|
-
# @parameter method_names [Array(Symbol)] The methods to mark as async-safe.
|
31
|
-
def async_safe(*method_names)
|
32
|
-
@async_safe_methods ||= Set.new
|
33
|
-
@async_safe_methods.merge(method_names)
|
34
|
-
end
|
35
|
-
|
36
|
-
# Check if a method is async-safe.
|
37
|
-
#
|
38
|
-
# Overrides the default implementation from `Class` to also check method-level safety.
|
39
|
-
#
|
40
|
-
# @parameter method [Symbol | Nil] The method name to check, or nil to check if the entire class is async-safe.
|
41
|
-
# @returns [Boolean] Whether the method or class is async-safe.
|
42
|
-
def async_safe?(method = nil)
|
43
|
-
# Check if entire class is marked async-safe:
|
44
|
-
return true if super
|
45
|
-
|
46
|
-
# Check if specific method is marked async-safe:
|
47
|
-
if method
|
48
|
-
return @async_safe_methods&.include?(method)
|
49
|
-
end
|
50
|
-
|
51
|
-
# Default to false if no method is specified and the class is not async safe:
|
52
|
-
return false
|
53
|
-
end
|
54
|
-
end
|
55
|
-
|
56
21
|
class << self
|
57
22
|
# @attribute [Monitor] The global monitoring instance.
|
58
23
|
attr_reader :monitor
|
@@ -61,14 +26,8 @@ module Async
|
|
61
26
|
#
|
62
27
|
# This activates a TracePoint that tracks object access across fibers and threads.
|
63
28
|
# There is no performance overhead when monitoring is disabled.
|
64
|
-
|
65
|
-
|
66
|
-
def enable!(logger: nil)
|
67
|
-
if @monitor
|
68
|
-
raise "Async::Safe is already enabled!"
|
69
|
-
end
|
70
|
-
|
71
|
-
@monitor = Monitor.new(logger: logger)
|
29
|
+
def enable!
|
30
|
+
@monitor ||= Monitor.new
|
72
31
|
@monitor.enable!
|
73
32
|
end
|
74
33
|
|
data/readme.md
CHANGED
@@ -22,6 +22,16 @@ 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.3.0
|
26
|
+
|
27
|
+
- Inverted default model: classes are async-safe by default, use `ASYNC_SAFE = false` to enable tracking.
|
28
|
+
- Added flexible `ASYNC_SAFE` constant support: boolean, hash, or array configurations.
|
29
|
+
- Added `Class#async_safe!` method for marking classes.
|
30
|
+
- Added `Class#async_safe?(method)` method for querying safety.
|
31
|
+
- Removed logger feature: always raises `ViolationError` exceptions.
|
32
|
+
- Removed `Async::Safe::Concurrent` module: use `async_safe!` instead.
|
33
|
+
- Removed `reset!` method: use `disable!` + `enable!` instead.
|
34
|
+
|
25
35
|
### v0.2.0
|
26
36
|
|
27
37
|
- `Thread::Queue` transfers ownership of objects popped from it.
|
data/releases.md
CHANGED
@@ -1,5 +1,15 @@
|
|
1
1
|
# Releases
|
2
2
|
|
3
|
+
## v0.3.0
|
4
|
+
|
5
|
+
- Inverted default model: classes are async-safe by default, use `ASYNC_SAFE = false` to enable tracking.
|
6
|
+
- Added flexible `ASYNC_SAFE` constant support: boolean, hash, or array configurations.
|
7
|
+
- Added `Class#async_safe!` method for marking classes.
|
8
|
+
- Added `Class#async_safe?(method)` method for querying safety.
|
9
|
+
- Removed logger feature: always raises `ViolationError` exceptions.
|
10
|
+
- Removed `Async::Safe::Concurrent` module: use `async_safe!` instead.
|
11
|
+
- Removed `reset!` method: use `disable!` + `enable!` instead.
|
12
|
+
|
3
13
|
## v0.2.0
|
4
14
|
|
5
15
|
- `Thread::Queue` transfers ownership of objects popped from it.
|
data.tar.gz.sig
CHANGED
Binary file
|
metadata
CHANGED
metadata.gz.sig
CHANGED
Binary file
|