async 2.24.0 → 2.28.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.
data/lib/async/barrier.rb CHANGED
@@ -1,10 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2019-2024, by Samuel Williams.
4
+ # Copyright, 2019-2025, by Samuel Williams.
5
5
 
6
6
  require_relative "list"
7
7
  require_relative "task"
8
+ require_relative "queue"
8
9
 
9
10
  module Async
10
11
  # A general purpose synchronisation primitive, which allows one task to wait for a number of other tasks to complete. It can be used in conjunction with {Semaphore}.
@@ -16,6 +17,7 @@ module Async
16
17
  # @public Since *Async v1*.
17
18
  def initialize(parent: nil)
18
19
  @tasks = List.new
20
+ @finished = Queue.new
19
21
 
20
22
  @parent = parent
21
23
  end
@@ -41,11 +43,17 @@ module Async
41
43
  # Execute a child task and add it to the barrier.
42
44
  # @asynchronous Executes the given block concurrently.
43
45
  def async(*arguments, parent: (@parent or Task.current), **options, &block)
44
- task = parent.async(*arguments, **options, &block)
46
+ raise "Barrier is stopped!" if @finished.closed?
45
47
 
46
- @tasks.append(TaskNode.new(task))
48
+ waiting = nil
47
49
 
48
- return task
50
+ parent.async(*arguments, **options) do |task, *arguments|
51
+ waiting = TaskNode.new(task)
52
+ @tasks.append(waiting)
53
+ block.call(task, *arguments)
54
+ ensure
55
+ @finished.signal(waiting) unless @finished.closed?
56
+ end
49
57
  end
50
58
 
51
59
  # Whether there are any tasks being held by the barrier.
@@ -55,14 +63,27 @@ module Async
55
63
  end
56
64
 
57
65
  # Wait for all tasks to complete by invoking {Task#wait} on each waiting task, which may raise an error. As long as the task has completed, it will be removed from the barrier.
66
+ #
67
+ # @yields {|task| ...} If a block is given, the unwaited task is yielded. You must invoke {Task#wait} yourself. In addition, you may `break` if you have captured enough results.
68
+ #
58
69
  # @asynchronous Will wait for tasks to finish executing.
59
70
  def wait
60
- @tasks.each do |waiting|
71
+ while !@tasks.empty?
72
+ # Wait for a task to finish (we get the task node):
73
+ return unless waiting = @finished.wait
74
+
75
+ # Remove the task as it is now finishing:
76
+ @tasks.remove?(waiting)
77
+
78
+ # Get the task:
61
79
  task = waiting.task
62
- begin
80
+
81
+ # If a block is given, the user can implement their own behaviour:
82
+ if block_given?
83
+ yield task
84
+ else
85
+ # Wait for it to either complete or raise an error:
63
86
  task.wait
64
- ensure
65
- @tasks.remove?(waiting) unless task.alive?
66
87
  end
67
88
  end
68
89
  end
@@ -73,6 +94,8 @@ module Async
73
94
  @tasks.each do |waiting|
74
95
  waiting.task.stop
75
96
  end
97
+
98
+ @finished.close
76
99
  end
77
100
  end
78
101
  end
data/lib/async/clock.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2018-2022, by Samuel Williams.
4
+ # Copyright, 2018-2025, by Samuel Williams.
5
5
 
6
6
  module Async
7
7
  # A convenient wrapper around the internal monotonic clock.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2017-2024, by Samuel Williams.
4
+ # Copyright, 2017-2025, by Samuel Williams.
5
5
  # Copyright, 2017, by Kent Gruber.
6
6
 
7
7
  require "fiber"
@@ -42,6 +42,8 @@ module Async
42
42
 
43
43
  # @deprecated Replaced by {#waiting?}
44
44
  def empty?
45
+ warn("`Async::Condition#empty?` is deprecated, use `Async::Condition#waiting?` instead.", uplevel: 1, category: :deprecated) if $VERBOSE
46
+
45
47
  @waiting.empty?
46
48
  end
47
49
 
@@ -1,7 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2024, by Samuel Williams.
4
+ # Copyright, 2025, by Samuel Williams.
5
5
 
6
6
  # The implementation lives in `queue.rb` but later we may move it here for better autoload/inference.
7
7
  require_relative "queue"
8
+
9
+ module Async
10
+ class LimitedQueue < Queue
11
+ singleton_class.remove_method(:new)
12
+ end
13
+ end
data/lib/async/list.rb CHANGED
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2022-2024, by Samuel Williams.
4
+ # Copyright, 2022-2025, by Samuel Williams.
5
+ # Copyright, 2025, by Shopify Inc.
5
6
 
6
7
  module Async
7
8
  # A general doublely linked list. This is used internally by {Async::Barrier} and {Async::Condition} to manage child tasks.
@@ -18,6 +19,7 @@ module Async
18
19
  sprintf("#<%s:0x%x size=%d>", self.class.name, object_id, @size)
19
20
  end
20
21
 
22
+ # @returns [String] A short summary of the list.
21
23
  alias inspect to_s
22
24
 
23
25
  # Fast, safe, unbounded accumulation of children.
@@ -134,7 +136,7 @@ module Async
134
136
  return removed(node)
135
137
  end
136
138
 
137
- # @returns [Boolean] Returns true if the list is empty.
139
+ # @returns [Boolean] True if the list is empty.
138
140
  def empty?
139
141
  @size == 0
140
142
  end
@@ -143,26 +145,26 @@ module Async
143
145
  # previous = self
144
146
  # current = @tail
145
147
  # found = node.equal?(self)
146
-
148
+
147
149
  # while true
148
150
  # break if current.equal?(self)
149
-
151
+
150
152
  # if current.head != previous
151
153
  # raise "Invalid previous linked list node!"
152
154
  # end
153
-
155
+
154
156
  # if current.is_a?(List) and !current.equal?(self)
155
157
  # raise "Invalid list in list node!"
156
158
  # end
157
-
159
+
158
160
  # if node
159
161
  # found ||= current.equal?(node)
160
162
  # end
161
-
163
+
162
164
  # previous = current
163
165
  # current = current.tail
164
166
  # end
165
-
167
+
166
168
  # if node and !found
167
169
  # raise "Node not found in list!"
168
170
  # end
@@ -238,6 +240,12 @@ module Async
238
240
  attr_accessor :head
239
241
  attr_accessor :tail
240
242
 
243
+ # @returns [String] A string representation of the node.
244
+ def to_s
245
+ sprintf("#<%s:0x%x>", self.class.name, object_id)
246
+ end
247
+
248
+ # @returns [String] A string representation of the node.
241
249
  alias inspect to_s
242
250
  end
243
251
 
data/lib/async/node.rb CHANGED
@@ -1,9 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2017-2024, by Samuel Williams.
4
+ # Copyright, 2017-2025, by Samuel Williams.
5
5
  # Copyright, 2017, by Kent Gruber.
6
6
  # Copyright, 2022, by Shannon Skipper.
7
+ # Copyright, 2025, by Shopify Inc.
7
8
 
8
9
  require "fiber/annotation"
9
10
 
@@ -180,6 +181,7 @@ module Async
180
181
  "\#<#{self.description}>"
181
182
  end
182
183
 
184
+ # @returns [String] A description of the node.
183
185
  alias inspect to_s
184
186
 
185
187
  # Change the parent of this node.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2018-2024, by Samuel Williams.
4
+ # Copyright, 2018-2025, by Samuel Williams.
5
5
 
6
6
  require_relative "condition"
7
7
 
@@ -10,12 +10,14 @@ module Async
10
10
  # @public Since *Async v1*.
11
11
  class Notification < Condition
12
12
  # Signal to a given task that it should resume operations.
13
+ #
14
+ # @returns [Boolean] if a task was signalled.
13
15
  def signal(value = nil, task: Task.current)
14
- return if @waiting.empty?
16
+ return false if @waiting.empty?
15
17
 
16
18
  Fiber.scheduler.push Signal.new(self.exchange, value)
17
19
 
18
- return nil
20
+ return true
19
21
  end
20
22
 
21
23
  Signal = Struct.new(:waiting, :value) do
data/lib/async/queue.rb CHANGED
@@ -1,9 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2018-2024, by Samuel Williams.
4
+ # Copyright, 2018-2025, by Samuel Williams.
5
5
  # Copyright, 2019, by Ryan Musgrave.
6
6
  # Copyright, 2020-2022, by Bruno Sutic.
7
+ # Copyright, 2025, by Jahfer Husain.
8
+ # Copyright, 2025, by Shopify Inc.
7
9
 
8
10
  require_relative "notification"
9
11
 
@@ -14,16 +16,36 @@ module Async
14
16
  #
15
17
  # @public Since *Async v1*.
16
18
  class Queue
19
+ # An error raised when trying to enqueue items to a closed queue.
20
+ # @public Since *Async v2.24*.
21
+ class ClosedError < RuntimeError
22
+ end
23
+
17
24
  # Create a new queue.
18
25
  #
19
26
  # @parameter parent [Interface(:async) | Nil] The parent task to use for async operations.
20
27
  # @parameter available [Notification] The notification to use for signaling when items are available.
21
28
  def initialize(parent: nil, available: Notification.new)
22
29
  @items = []
30
+ @closed = false
23
31
  @parent = parent
24
32
  @available = available
25
33
  end
26
34
 
35
+ # @returns [Boolean] Whether the queue is closed.
36
+ def closed?
37
+ @closed
38
+ end
39
+
40
+ # Close the queue, causing all waiting tasks to return `nil`. Any subsequent calls to {enqueue} will raise an exception.
41
+ def close
42
+ @closed = true
43
+
44
+ while @available.waiting?
45
+ @available.signal(nil)
46
+ end
47
+ end
48
+
27
49
  # @attribute [Array] The items in the queue.
28
50
  attr :items
29
51
 
@@ -39,6 +61,10 @@ module Async
39
61
 
40
62
  # Add an item to the queue.
41
63
  def push(item)
64
+ if @closed
65
+ raise ClosedError, "Cannot push items to a closed queue."
66
+ end
67
+
42
68
  @items << item
43
69
 
44
70
  @available.signal unless self.empty?
@@ -51,6 +77,10 @@ module Async
51
77
 
52
78
  # Add multiple items to the queue.
53
79
  def enqueue(*items)
80
+ if @closed
81
+ raise ClosedError, "Cannot enqueue items to a closed queue."
82
+ end
83
+
54
84
  @items.concat(items)
55
85
 
56
86
  @available.signal unless self.empty?
@@ -59,6 +89,10 @@ module Async
59
89
  # Remove and return the next item from the queue.
60
90
  def dequeue
61
91
  while @items.empty?
92
+ if @closed
93
+ return nil
94
+ end
95
+
62
96
  @available.wait
63
97
  end
64
98
 
@@ -105,6 +139,13 @@ module Async
105
139
  # A queue which limits the number of items that can be enqueued.
106
140
  # @public Since *Async v1*.
107
141
  class LimitedQueue < Queue
142
+ # @private This exists purely for emitting a warning.
143
+ def self.new(...)
144
+ warn("`require 'async/limited_queue'` to use `Async::LimitedQueue`.", uplevel: 1, category: :deprecated) if $VERBOSE
145
+
146
+ super
147
+ end
148
+
108
149
  # Create a new limited queue.
109
150
  #
110
151
  # @parameter limit [Integer] The maximum number of items that can be enqueued.
@@ -119,9 +160,19 @@ module Async
119
160
  # @attribute [Integer] The maximum number of items that can be enqueued.
120
161
  attr :limit
121
162
 
163
+ # Close the queue, causing all waiting tasks to return `nil`. Any subsequent calls to {enqueue} will raise an exception.
164
+ # Also signals all tasks waiting for the queue to be full.
165
+ def close
166
+ super
167
+
168
+ while @full.waiting?
169
+ @full.signal(nil)
170
+ end
171
+ end
172
+
122
173
  # @returns [Boolean] Whether trying to enqueue an item would block.
123
174
  def limited?
124
- @items.size >= @limit
175
+ !@closed && @items.size >= @limit
125
176
  end
126
177
 
127
178
  # Add an item to the queue.
@@ -148,6 +199,10 @@ module Async
148
199
  @full.wait
149
200
  end
150
201
 
202
+ if @closed
203
+ raise ClosedError, "Cannot enqueue items to a closed queue."
204
+ end
205
+
151
206
  available = @limit - @items.size
152
207
  @items.concat(items.shift(available))
153
208
 
data/lib/async/reactor.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2017-2024, by Samuel Williams.
4
+ # Copyright, 2017-2025, by Samuel Williams.
5
5
  # Copyright, 2017, by Kent Gruber.
6
6
  # Copyright, 2018, by Sokolov Yura.
7
7
 
@@ -12,6 +12,8 @@ module Async
12
12
  class Reactor < Scheduler
13
13
  # @deprecated Replaced by {Kernel::Async}.
14
14
  def self.run(...)
15
+ warn("`Async::Reactor.run{}` is deprecated, use `Async{}` instead.", uplevel: 1, category: :deprecated) if $VERBOSE
16
+
15
17
  Async(...)
16
18
  end
17
19
 
@@ -1,14 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2020-2024, by Samuel Williams.
4
+ # Copyright, 2020-2025, by Samuel Williams.
5
5
  # Copyright, 2020, by Jun Jiang.
6
6
  # Copyright, 2021, by Julien Portalier.
7
+ # Copyright, 2025, by Shopify Inc.
7
8
 
8
9
  require_relative "clock"
9
10
  require_relative "task"
10
11
  require_relative "timeout"
11
- require_relative "worker_pool"
12
12
 
13
13
  require "io/event"
14
14
 
@@ -46,6 +46,28 @@ module Async
46
46
  true
47
47
  end
48
48
 
49
+ # Used to augment the scheduler to add support for blocking operations.
50
+ module BlockingOperationWait
51
+ # Wait for the given work to be executed.
52
+ #
53
+ # @public Since *Async v2.21* and *Ruby v3.4*.
54
+ # @asynchronous May be non-blocking.
55
+ #
56
+ # @parameter work [Proc] The work to execute on a background thread.
57
+ # @returns [Object] The result of the work.
58
+ def blocking_operation_wait(work)
59
+ @worker_pool.call(work)
60
+ end
61
+ end
62
+
63
+ private_constant :BlockingOperationWait
64
+
65
+ if ::IO::Event.const_defined?(:WorkerPool)
66
+ WorkerPool = ::IO::Event::WorkerPool
67
+ else
68
+ WorkerPool = nil
69
+ end
70
+
49
71
  # Create a new scheduler.
50
72
  #
51
73
  # @public Since *Async v1*.
@@ -65,14 +87,15 @@ module Async
65
87
  @idle_time = 0.0
66
88
 
67
89
  @timers = ::IO::Event::Timers.new
90
+
68
91
  if worker_pool == true
69
- @worker_pool = WorkerPool.new
92
+ @worker_pool = WorkerPool&.new
70
93
  else
71
94
  @worker_pool = worker_pool
72
95
  end
73
96
 
74
97
  if @worker_pool
75
- self.singleton_class.prepend(WorkerPool::BlockingOperationWait)
98
+ self.singleton_class.prepend(BlockingOperationWait)
76
99
  end
77
100
  end
78
101
 
@@ -97,7 +120,7 @@ module Async
97
120
  return @busy_time / total_time
98
121
  end
99
122
  end
100
-
123
+
101
124
  # Invoked when the fiber scheduler is being closed.
102
125
  #
103
126
  # Executes the run loop until all tasks are finished, then closes the scheduler.
@@ -234,7 +257,7 @@ module Async
234
257
  # @parameter blocker [Object] The object that was blocking the fiber.
235
258
  # @parameter fiber [Fiber] The fiber to unblock.
236
259
  def unblock(blocker, fiber)
237
- # $stderr.puts "unblock(#{blocker}, #{fiber})"
260
+ # Fiber.blocking{$stderr.puts "unblock(#{blocker}, #{fiber})"}
238
261
 
239
262
  # This operation is protected by the GVL:
240
263
  if selector = @selector
@@ -250,6 +273,8 @@ module Async
250
273
  #
251
274
  # @parameter duration [Numeric | Nil] The time in seconds to sleep, or if nil, indefinitely.
252
275
  def kernel_sleep(duration = nil)
276
+ # Fiber.blocking{$stderr.puts "kernel_sleep(#{duration}, #{Fiber.current})"}
277
+
253
278
  if duration
254
279
  self.block(nil, duration)
255
280
  else
@@ -348,6 +373,34 @@ module Async
348
373
  end
349
374
  end
350
375
 
376
+ # Used to defer stopping the current task until later.
377
+ class FiberInterrupt
378
+ # Create a new stop later operation.
379
+ #
380
+ # @parameter task [Task] The task to stop later.
381
+ def initialize(fiber, exception)
382
+ @fiber = fiber
383
+ @exception = exception
384
+ end
385
+
386
+ # @returns [Boolean] Whether the task is alive.
387
+ def alive?
388
+ @fiber.alive?
389
+ end
390
+
391
+ # Transfer control to the operation - this will stop the task.
392
+ def transfer
393
+ # Fiber.blocking{$stderr.puts "FiberInterrupt#transfer(#{@fiber}, #{@exception})"}
394
+ @fiber.raise(@exception)
395
+ end
396
+ end
397
+
398
+ # Raise an exception on the specified fiber, waking up the event loop if necessary.
399
+ def fiber_interrupt(fiber, exception)
400
+ # Fiber.blocking{$stderr.puts "fiber_interrupt(#{fiber}, #{exception})"}
401
+ unblock(nil, FiberInterrupt.new(fiber, exception))
402
+ end
403
+
351
404
  # Wait for the specified process ID to exit.
352
405
  #
353
406
  # @public Since *Async v2*.
@@ -361,6 +414,19 @@ module Async
361
414
  return @selector.process_wait(Fiber.current, pid, flags)
362
415
  end
363
416
 
417
+ # Wait for the specified IOs to become ready for the specified events.
418
+ #
419
+ # @public Since *Async v2.25*.
420
+ # @asynchronous May be non-blocking.
421
+ def io_select(...)
422
+ Thread.new do
423
+ # Don't make unnecessary output, since we will propagate the exception:
424
+ Thread.current.report_on_exception = false
425
+
426
+ ::IO.select(...)
427
+ end.value
428
+ end
429
+
364
430
  # Run one iteration of the event loop.
365
431
  #
366
432
  # When terminating the event loop, we already know we are finished. So we don't need to check the task tree. This is a logical requirement because `run_once` ignores transient tasks. For example, a single top level transient task is not enough to keep the reactor running, but during termination we must still process it in order to terminate child tasks.
@@ -517,7 +583,8 @@ module Async
517
583
  # @yields {|task| ...} Executed within the task.
518
584
  # @returns [Task] The task that was scheduled into the reactor.
519
585
  def async(*arguments, **options, &block)
520
- # warn "Async::Scheduler#async is deprecated. Use `run` or `Task#async` instead.", uplevel: 1, category: :deprecated
586
+ # Since this method is called by `run`, this warning is too excessive:
587
+ # warn("Async::Scheduler#async is deprecated. Use `run` or `Task#async` instead.", uplevel: 1, category: :deprecated) if $VERBOSE
521
588
 
522
589
  Kernel.raise ClosedError if @selector.nil?
523
590
 
@@ -528,6 +595,8 @@ module Async
528
595
  return task
529
596
  end
530
597
 
598
+ # Create a new fiber and return it without starting execution.
599
+ # @returns [Fiber] The fiber that was created.
531
600
  def fiber(...)
532
601
  return async(...).fiber
533
602
  end
data/lib/async/stop.rb ADDED
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2025, by Samuel Williams.
5
+
6
+ require "fiber"
7
+ require "console"
8
+
9
+ 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
82
+ end