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.
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 {!!@resolved}
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 {@resolved}
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 {@resolved == :cancelled}
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 {@resolved == :failed}
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 {@resolved == :completed}
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 {@waiting > 0}
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 {@waiting += 1}
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 {@resolved ? @value : nil}
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
- def wait
83
- @mutex.synchronize do
84
- # Increment waiting count:
85
- @waiting += 1
86
-
87
- begin
88
- # Wait for resolution if not already resolved:
89
- @condition.wait(@mutex) unless @resolved
90
-
91
- # Return value or raise exception based on resolution type:
92
- if @resolved == :completed
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 was cancelled!"))
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 {|item| @delegate.push(item)}
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
@@ -1,14 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2020-2025, by Samuel Williams.
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
- self.run_loop do
150
- until self.terminate
151
- self.run_once!
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
- @selector = nil
160
-
161
- selector&.close
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&.close
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 (#{stopped? ? 'stopped' : 'running'})>"
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
- # Stop all children, including transient children.
514
+ # Cancel all children, including transient children.
512
515
  #
513
516
  # @public Since *Async v1*.
514
- def stop
517
+ def cancel
515
518
  @children&.each do |child|
516
- child.stop
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
- require "fiber"
7
- require "console"
6
+ require_relative "cancel"
8
7
 
9
8
  module Async
10
- # Raised when a task is explicitly stopped.
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