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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 52f743f5c66ba254659d3f6afb52e8f6bf1189f395ca931e708890eea1fe4e00
4
- data.tar.gz: 4a89412e5fd61a9c4c5bdac4d90a6897e70fbeccac5301b58e56a3cf622542a8
3
+ metadata.gz: a165b5a9b594d4eec52f9a7c80ee3cc020db7925ac7630ddc342a552a3a20112
4
+ data.tar.gz: c9cedd555a12b366f00bf4fa43906501bd6596a8abd3db2531b457d3083bd093
5
5
  SHA512:
6
- metadata.gz: b7b6981fe90ffdbef57060678ba4988202333120848cc40d4528873b9a6ccd5b39a53549d35886d75a08343db12697855eb56c74af9d06c9434e2a153d6caef5
7
- data.tar.gz: 8a0ec23a746938beeadd581af8e7aaa19dc0219826343a40ef9d93071e34b2956e67fcab1c33c3529940716195b7958e2f99a127c2e6c47ca72e333f0c783f61
6
+ metadata.gz: 5816b73d736aa8570fbdf3eed10ba2424e0dc138ac823bd37577cda99b42ac5c120556ac34b60cb645d08206a0214bd5b9a36bd8b25fae829791c522c2586ccf
7
+ data.tar.gz: d5c2d51e17041f290616eb45a4be80acd5e33b6a6b4e59a801462a3d7bfaccb453633c8e921a2d615136ac784b20fa92436fa73cf664ba4bcf6eaa807ab6ec7e
checksums.yaml.gz.sig CHANGED
Binary file
@@ -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 objects are assumed to follow a **single-owner model** - they should only be accessed from one fiber/thread at a time:
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
- ### Marking Async-Safe Methods
95
+ Or use a hash for per-method configuration:
94
96
 
95
- Mark specific methods as async-safe:
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
- include Async::Safe
100
-
101
- async_safe :safe_read
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).
@@ -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)
@@ -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
- # Check if entire class is marked async-safe via constant:
14
- if const_defined?(:ASYNC_SAFE, false) && const_get(:ASYNC_SAFE)
15
- return true
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
- false
42
+ # Default to true:
43
+ return true
19
44
  end
20
45
 
21
46
  # Mark the class as async-safe or not.
@@ -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
- if @logger
135
- @logger.warn(self, "Async::Safe violation detected!", klass: klass, method: method, owner: owner, current: current, backtrace: caller_locations(3..))
136
- else
137
- raise ViolationError.new(target: object, method: method, owner: owner, current: current)
138
- end
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:
@@ -5,7 +5,7 @@
5
5
 
6
6
  module Async
7
7
  module Safe
8
- VERSION = "0.2.0"
8
+ VERSION = "0.3.1"
9
9
  end
10
10
  end
11
11
 
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 objects follow a **single-owner model** - they should only be accessed
16
- # from one fiber/thread at a time. Objects or methods can be explicitly marked as
17
- # async-safe to allow concurrent access.
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
- # @parameter logger [Object | Nil] Optional logger to use for violations instead of raising exceptions.
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
@@ -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.2.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
metadata.gz.sig CHANGED
Binary file