async 2.32.0 → 2.39.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 +4 -4
- checksums.yaml.gz.sig +0 -0
- data/context/best-practices.md +53 -32
- data/context/debugging.md +3 -3
- data/context/getting-started.md +6 -6
- data/context/scheduler.md +4 -4
- data/context/tasks.md +41 -36
- data/context/thread-safety.md +267 -224
- data/lib/async/barrier.rb +41 -10
- data/lib/async/cancel.rb +80 -0
- data/lib/async/clock.rb +22 -1
- data/lib/async/deadline.rb +1 -0
- data/lib/async/error.rb +17 -0
- data/lib/async/fork_handler.rb +32 -0
- data/lib/async/idler.rb +27 -15
- data/lib/async/loop.rb +84 -0
- data/lib/async/node.rb +28 -9
- data/lib/async/promise.rb +112 -37
- data/lib/async/queue.rb +1 -1
- data/lib/async/scheduler.rb +40 -17
- data/lib/async/stop.rb +3 -75
- data/lib/async/task.rb +160 -91
- data/lib/async/version.rb +3 -2
- data/lib/async.rb +3 -5
- data/lib/kernel/barrier.rb +31 -0
- data/lib/kernel/sync.rb +1 -1
- data/lib/traces/provider/async/barrier.rb +1 -1
- data/license.md +4 -2
- data/readme.md +26 -32
- data/releases.md +104 -33
- data.tar.gz.sig +0 -0
- metadata +9 -3
- metadata.gz.sig +0 -0
- data/lib/async/task.md +0 -30
data/lib/async/promise.rb
CHANGED
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
# Released under the MIT License.
|
|
4
4
|
# Copyright, 2025, by Shopify Inc.
|
|
5
|
-
# Copyright, 2025, by Samuel Williams.
|
|
5
|
+
# Copyright, 2025-2026, by Samuel Williams.
|
|
6
|
+
|
|
7
|
+
require_relative "error"
|
|
8
|
+
require_relative "deadline"
|
|
9
|
+
require_relative "cancel"
|
|
6
10
|
|
|
7
11
|
module Async
|
|
8
12
|
# A promise represents a value that will be available in the future.
|
|
@@ -30,39 +34,39 @@ module Async
|
|
|
30
34
|
|
|
31
35
|
# @returns [Boolean] Whether the promise has been resolved or rejected.
|
|
32
36
|
def resolved?
|
|
33
|
-
@mutex.synchronize
|
|
37
|
+
@mutex.synchronize{!!@resolved}
|
|
34
38
|
end
|
|
35
39
|
|
|
36
40
|
# @returns [Symbol | Nil] The internal resolved state (:completed, :failed, :cancelled, or nil if pending).
|
|
37
41
|
# @private For internal use by Task.
|
|
38
42
|
def resolved
|
|
39
|
-
@mutex.synchronize
|
|
43
|
+
@mutex.synchronize{@resolved}
|
|
40
44
|
end
|
|
41
45
|
|
|
42
46
|
# @returns [Boolean] Whether the promise has been cancelled.
|
|
43
47
|
def cancelled?
|
|
44
|
-
@mutex.synchronize
|
|
48
|
+
@mutex.synchronize{@resolved == :cancelled}
|
|
45
49
|
end
|
|
46
50
|
|
|
47
51
|
# @returns [Boolean] Whether the promise failed with an exception.
|
|
48
52
|
def failed?
|
|
49
|
-
@mutex.synchronize
|
|
53
|
+
@mutex.synchronize{@resolved == :failed}
|
|
50
54
|
end
|
|
51
55
|
|
|
52
56
|
# @returns [Boolean] Whether the promise has completed successfully.
|
|
53
57
|
def completed?
|
|
54
|
-
@mutex.synchronize
|
|
58
|
+
@mutex.synchronize{@resolved == :completed}
|
|
55
59
|
end
|
|
56
60
|
|
|
57
61
|
# @returns [Boolean] Whether any fibers are currently waiting for this promise.
|
|
58
62
|
def waiting?
|
|
59
|
-
@mutex.synchronize
|
|
63
|
+
@mutex.synchronize{@waiting > 0}
|
|
60
64
|
end
|
|
61
65
|
|
|
62
66
|
# Artificially mark that someone is waiting (useful for suppressing warnings).
|
|
63
67
|
# @private Internal use only.
|
|
64
68
|
def suppress_warnings!
|
|
65
|
-
@mutex.synchronize
|
|
69
|
+
@mutex.synchronize{@waiting += 1}
|
|
66
70
|
end
|
|
67
71
|
|
|
68
72
|
# Non-blocking access to the current value. Returns nil if not yet resolved.
|
|
@@ -71,34 +75,95 @@ module Async
|
|
|
71
75
|
#
|
|
72
76
|
# @returns [Object | Nil] The stored value, or nil if pending.
|
|
73
77
|
def value
|
|
74
|
-
@mutex.synchronize
|
|
78
|
+
@mutex.synchronize{@resolved ? @value : nil}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Wait indefinitely for the promise to be resolved.
|
|
82
|
+
private def wait_indefinitely
|
|
83
|
+
until @resolved
|
|
84
|
+
@condition.wait(@mutex)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Wait for the promise to be resolved, respecting the deadline timeout.
|
|
89
|
+
# @parameter timeout [Numeric] The timeout duration.
|
|
90
|
+
# @returns [Boolean] True if resolved, false if timeout expires.
|
|
91
|
+
private def wait_with_timeout(timeout)
|
|
92
|
+
# Create deadline for timeout tracking:
|
|
93
|
+
deadline = Deadline.start(timeout)
|
|
94
|
+
|
|
95
|
+
# Handle immediate timeout (non-blocking):
|
|
96
|
+
if deadline == Deadline::Zero && !@resolved
|
|
97
|
+
return false
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Wait with deadline tracking:
|
|
101
|
+
until @resolved
|
|
102
|
+
# Get remaining time for this wait iteration:
|
|
103
|
+
remaining = deadline.remaining
|
|
104
|
+
|
|
105
|
+
# Check if deadline has expired before waiting:
|
|
106
|
+
if remaining <= 0
|
|
107
|
+
return false
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
@condition.wait(@mutex, remaining)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
return true
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Wait for the promise to be resolved (without raising exceptions).
|
|
117
|
+
#
|
|
118
|
+
# If already resolved, returns immediately. Otherwise, waits until resolution or timeout.
|
|
119
|
+
#
|
|
120
|
+
# @parameter timeout [Numeric | Nil] Maximum time to wait. If nil, waits indefinitely. If 0, returns immediately if not resolved.
|
|
121
|
+
# @returns [Boolean] True if the promise is resolved, false if timeout expires
|
|
122
|
+
def wait?(timeout: nil)
|
|
123
|
+
unless @resolved
|
|
124
|
+
@mutex.synchronize do
|
|
125
|
+
# Increment waiting count:
|
|
126
|
+
@waiting += 1
|
|
127
|
+
|
|
128
|
+
begin
|
|
129
|
+
# Wait for resolution if not already resolved:
|
|
130
|
+
unless @resolved
|
|
131
|
+
if timeout.nil?
|
|
132
|
+
wait_indefinitely
|
|
133
|
+
else
|
|
134
|
+
unless wait_with_timeout(timeout)
|
|
135
|
+
# We don't want to race on @resolved after exiting the mutex:
|
|
136
|
+
return nil
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
ensure
|
|
141
|
+
# Decrement waiting count when done:
|
|
142
|
+
@waiting -= 1
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
return @resolved
|
|
75
148
|
end
|
|
76
149
|
|
|
77
150
|
# Wait for the promise to be resolved and return the value.
|
|
151
|
+
#
|
|
78
152
|
# If already resolved, returns immediately. If rejected, raises the stored exception.
|
|
79
153
|
#
|
|
80
154
|
# @returns [Object] The resolved value.
|
|
81
155
|
# @raises [Exception] The rejected or cancelled exception.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
return @value
|
|
94
|
-
else
|
|
95
|
-
# Both :failed and :cancelled store exceptions in @value
|
|
96
|
-
raise @value
|
|
97
|
-
end
|
|
98
|
-
ensure
|
|
99
|
-
# Decrement waiting count when done:
|
|
100
|
-
@waiting -= 1
|
|
101
|
-
end
|
|
156
|
+
# @raises [Async::TimeoutError] If timeout expires before the promise is resolved.
|
|
157
|
+
def wait(...)
|
|
158
|
+
resolved = wait?(...)
|
|
159
|
+
|
|
160
|
+
if resolved.nil?
|
|
161
|
+
raise TimeoutError, "Timeout while waiting for promise!"
|
|
162
|
+
elsif resolved == :completed
|
|
163
|
+
return @value
|
|
164
|
+
elsif @value
|
|
165
|
+
# If we aren't completed, we should have an exception or cancel reason stored:
|
|
166
|
+
raise @value
|
|
102
167
|
end
|
|
103
168
|
end
|
|
104
169
|
|
|
@@ -111,8 +176,8 @@ module Async
|
|
|
111
176
|
@mutex.synchronize do
|
|
112
177
|
return if @resolved
|
|
113
178
|
|
|
114
|
-
@value = value
|
|
115
179
|
@resolved = :completed
|
|
180
|
+
@value = value
|
|
116
181
|
|
|
117
182
|
# Wake up all waiting fibers:
|
|
118
183
|
@condition.broadcast
|
|
@@ -130,8 +195,8 @@ module Async
|
|
|
130
195
|
@mutex.synchronize do
|
|
131
196
|
return if @resolved
|
|
132
197
|
|
|
133
|
-
@value = exception
|
|
134
198
|
@resolved = :failed
|
|
199
|
+
@value = exception
|
|
135
200
|
|
|
136
201
|
# Wake up all waiting fibers:
|
|
137
202
|
@condition.broadcast
|
|
@@ -140,20 +205,16 @@ module Async
|
|
|
140
205
|
return nil
|
|
141
206
|
end
|
|
142
207
|
|
|
143
|
-
# Exception used to indicate cancellation.
|
|
144
|
-
class Cancel < Exception
|
|
145
|
-
end
|
|
146
|
-
|
|
147
208
|
# Cancel the promise, indicating cancellation.
|
|
148
209
|
# All current and future waiters will receive nil.
|
|
149
210
|
# Can only be called on pending promises - no-op if already resolved.
|
|
150
|
-
def cancel(exception = Cancel.new("Promise
|
|
211
|
+
def cancel(exception = Cancel.new("Promise cancelled!"))
|
|
151
212
|
@mutex.synchronize do
|
|
152
213
|
# No-op if already in any final state
|
|
153
214
|
return if @resolved
|
|
154
215
|
|
|
155
|
-
@value = exception
|
|
156
216
|
@resolved = :cancelled
|
|
217
|
+
@value = exception
|
|
157
218
|
|
|
158
219
|
# Wake up all waiting fibers:
|
|
159
220
|
@condition.broadcast
|
|
@@ -184,5 +245,19 @@ module Async
|
|
|
184
245
|
self.resolve(nil) unless @resolved
|
|
185
246
|
end
|
|
186
247
|
end
|
|
248
|
+
|
|
249
|
+
# If a promise is given, fulfill it with the result of the block.
|
|
250
|
+
# If no promise is given, simply yield to the block.
|
|
251
|
+
# This is useful for methods that may optionally take a promise to fulfill.
|
|
252
|
+
# @parameter promise [Promise | Nil] The optional promise to fulfill.
|
|
253
|
+
# @yields {...} The block to call to resolve the promise or return a value.
|
|
254
|
+
# @returns [Object] The result of the block.
|
|
255
|
+
def self.fulfill(promise, &block)
|
|
256
|
+
if promise
|
|
257
|
+
return promise.fulfill(&block)
|
|
258
|
+
else
|
|
259
|
+
return yield
|
|
260
|
+
end
|
|
261
|
+
end
|
|
187
262
|
end
|
|
188
263
|
end
|
data/lib/async/queue.rb
CHANGED
|
@@ -72,7 +72,7 @@ module Async
|
|
|
72
72
|
|
|
73
73
|
# Add multiple items to the queue.
|
|
74
74
|
def enqueue(*items)
|
|
75
|
-
items.each
|
|
75
|
+
items.each{|item| @delegate.push(item)}
|
|
76
76
|
rescue ClosedQueueError
|
|
77
77
|
raise ClosedError, "Cannot enqueue items to a closed queue!"
|
|
78
78
|
end
|
data/lib/async/scheduler.rb
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
# Released under the MIT License.
|
|
4
|
-
# Copyright, 2020-
|
|
4
|
+
# Copyright, 2020-2026, by Samuel Williams.
|
|
5
5
|
# Copyright, 2020, by Jun Jiang.
|
|
6
6
|
# Copyright, 2021, by Julien Portalier.
|
|
7
|
-
# Copyright, 2025, by Shopify Inc.
|
|
7
|
+
# Copyright, 2025-2026, by Shopify Inc.
|
|
8
8
|
|
|
9
9
|
require_relative "clock"
|
|
10
10
|
require_relative "task"
|
|
11
11
|
require_relative "timeout"
|
|
12
|
+
require_relative "fork_handler"
|
|
12
13
|
|
|
13
14
|
require "io/event"
|
|
14
15
|
|
|
@@ -146,24 +147,26 @@ module Async
|
|
|
146
147
|
# Terminate all child tasks and close the scheduler.
|
|
147
148
|
# @public Since *Async v1*.
|
|
148
149
|
def close
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
self.
|
|
150
|
+
unless @children.nil?
|
|
151
|
+
self.run_loop do
|
|
152
|
+
until self.terminate
|
|
153
|
+
self.run_once!
|
|
154
|
+
end
|
|
152
155
|
end
|
|
153
156
|
end
|
|
154
157
|
|
|
155
158
|
Kernel.raise "Closing scheduler with blocked operations!" if @blocked > 0
|
|
156
159
|
ensure
|
|
157
160
|
# We want `@selector = nil` to be a visible side effect from this point forward, specifically in `#interrupt` and `#unblock`. If the selector is closed, then we don't want to push any fibers to it.
|
|
158
|
-
selector = @selector
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
worker_pool = @worker_pool
|
|
164
|
-
@worker_pool = nil
|
|
161
|
+
if selector = @selector
|
|
162
|
+
@selector = nil
|
|
163
|
+
selector.close
|
|
164
|
+
end
|
|
165
165
|
|
|
166
|
-
worker_pool
|
|
166
|
+
if worker_pool = @worker_pool
|
|
167
|
+
@worker_pool = nil
|
|
168
|
+
worker_pool.close
|
|
169
|
+
end
|
|
167
170
|
|
|
168
171
|
consume
|
|
169
172
|
end
|
|
@@ -176,7 +179,7 @@ module Async
|
|
|
176
179
|
|
|
177
180
|
# @returns [String] A description of the scheduler.
|
|
178
181
|
def to_s
|
|
179
|
-
"\#<#{self.description} #{@children&.size || 0} children (#{
|
|
182
|
+
"\#<#{self.description} #{@children&.size || 0} children (#{cancelled? ? 'cancelled' : 'running'})>"
|
|
180
183
|
end
|
|
181
184
|
|
|
182
185
|
# Interrupt the event loop and cause it to exit.
|
|
@@ -508,15 +511,20 @@ module Async
|
|
|
508
511
|
return false
|
|
509
512
|
end
|
|
510
513
|
|
|
511
|
-
#
|
|
514
|
+
# Cancel all children, including transient children.
|
|
512
515
|
#
|
|
513
516
|
# @public Since *Async v1*.
|
|
514
|
-
def
|
|
517
|
+
def cancel
|
|
515
518
|
@children&.each do |child|
|
|
516
|
-
child.
|
|
519
|
+
child.cancel
|
|
517
520
|
end
|
|
518
521
|
end
|
|
519
522
|
|
|
523
|
+
# Backward compatibility alias for cancel.
|
|
524
|
+
def stop
|
|
525
|
+
cancel
|
|
526
|
+
end
|
|
527
|
+
|
|
520
528
|
private def run_loop(&block)
|
|
521
529
|
interrupt = nil
|
|
522
530
|
|
|
@@ -642,5 +650,20 @@ module Async
|
|
|
642
650
|
yield duration
|
|
643
651
|
end
|
|
644
652
|
end
|
|
653
|
+
|
|
654
|
+
# Handle fork in the child process. This method is called automatically when `Process.fork` is invoked on Ruby versions < 4 and cleans up the scheduler state. On Ruby 4+, the scheduler is automatically cleaned up by the Ruby runtime.
|
|
655
|
+
#
|
|
656
|
+
# The child process starts with a clean slate - no scheduler is set. Users can create a new scheduler if needed.
|
|
657
|
+
#
|
|
658
|
+
# @public Since *Async v2.35*.
|
|
659
|
+
def process_fork
|
|
660
|
+
@profiler = nil
|
|
661
|
+
@children = nil
|
|
662
|
+
@selector = nil
|
|
663
|
+
@timers = nil
|
|
664
|
+
|
|
665
|
+
# Close the scheduler:
|
|
666
|
+
Fiber.set_scheduler(nil)
|
|
667
|
+
end
|
|
645
668
|
end
|
|
646
669
|
end
|
data/lib/async/stop.rb
CHANGED
|
@@ -1,82 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
# Released under the MIT License.
|
|
4
|
-
# Copyright, 2025, by Samuel Williams.
|
|
4
|
+
# Copyright, 2025-2026, by Samuel Williams.
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
require "console"
|
|
6
|
+
require_relative "cancel"
|
|
8
7
|
|
|
9
8
|
module Async
|
|
10
|
-
|
|
11
|
-
class Stop < Exception
|
|
12
|
-
# Represents the source of the stop operation.
|
|
13
|
-
class Cause < Exception
|
|
14
|
-
if RUBY_VERSION >= "3.4"
|
|
15
|
-
# @returns [Array(Thread::Backtrace::Location)] The backtrace of the caller.
|
|
16
|
-
def self.backtrace
|
|
17
|
-
caller_locations(2..-1)
|
|
18
|
-
end
|
|
19
|
-
else
|
|
20
|
-
# @returns [Array(String)] The backtrace of the caller.
|
|
21
|
-
def self.backtrace
|
|
22
|
-
caller(2..-1)
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
# Create a new cause of the stop operation, with the given message.
|
|
27
|
-
#
|
|
28
|
-
# @parameter message [String] The error message.
|
|
29
|
-
# @returns [Cause] The cause of the stop operation.
|
|
30
|
-
def self.for(message = "Task was stopped")
|
|
31
|
-
instance = self.new(message)
|
|
32
|
-
instance.set_backtrace(self.backtrace)
|
|
33
|
-
return instance
|
|
34
|
-
end
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
if RUBY_VERSION < "3.5"
|
|
38
|
-
# Create a new stop operation.
|
|
39
|
-
#
|
|
40
|
-
# This is a compatibility method for Ruby versions before 3.5 where cause is not propagated correctly when using {Fiber#raise}
|
|
41
|
-
#
|
|
42
|
-
# @parameter message [String | Hash] The error message or a hash containing the cause.
|
|
43
|
-
def initialize(message = "Task was stopped")
|
|
44
|
-
if message.is_a?(Hash)
|
|
45
|
-
@cause = message[:cause]
|
|
46
|
-
message = "Task was stopped"
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
super(message)
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
# @returns [Exception] The cause of the stop operation.
|
|
53
|
-
#
|
|
54
|
-
# This is a compatibility method for Ruby versions before 3.5 where cause is not propagated correctly when using {Fiber#raise}, we explicitly capture the cause here.
|
|
55
|
-
def cause
|
|
56
|
-
super || @cause
|
|
57
|
-
end
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
# Used to defer stopping the current task until later.
|
|
61
|
-
class Later
|
|
62
|
-
# Create a new stop later operation.
|
|
63
|
-
#
|
|
64
|
-
# @parameter task [Task] The task to stop later.
|
|
65
|
-
# @parameter cause [Exception] The cause of the stop operation.
|
|
66
|
-
def initialize(task, cause = nil)
|
|
67
|
-
@task = task
|
|
68
|
-
@cause = cause
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
# @returns [Boolean] Whether the task is alive.
|
|
72
|
-
def alive?
|
|
73
|
-
true
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
# Transfer control to the operation - this will stop the task.
|
|
77
|
-
def transfer
|
|
78
|
-
@task.stop(false, cause: @cause)
|
|
79
|
-
end
|
|
80
|
-
end
|
|
81
|
-
end
|
|
9
|
+
Stop = Cancel
|
|
82
10
|
end
|