async 2.14.2 → 2.23.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.
@@ -5,17 +5,30 @@
5
5
  # Copyright, 2020, by Jun Jiang.
6
6
  # Copyright, 2021, by Julien Portalier.
7
7
 
8
- require_relative 'clock'
9
- require_relative 'task'
8
+ require_relative "clock"
9
+ require_relative "task"
10
+ require_relative "worker_pool"
10
11
 
11
- require 'io/event'
12
+ require "io/event"
12
13
 
13
- require 'console'
14
- require 'resolv'
14
+ require "console"
15
+ require "resolv"
15
16
 
16
17
  module Async
18
+ begin
19
+ require "fiber/profiler"
20
+ Profiler = Fiber::Profiler
21
+ rescue LoadError
22
+ # Fiber::Profiler is not available.
23
+ Profiler = nil
24
+ end
25
+
17
26
  # Handles scheduling of fibers. Implements the fiber scheduler interface.
18
27
  class Scheduler < Node
28
+ WORKER_POOL = ENV.fetch("ASYNC_SCHEDULER_WORKER_POOL", nil).then do |value|
29
+ value == "true" ? true : nil
30
+ end
31
+
19
32
  # Raised when an operation is attempted on a closed scheduler.
20
33
  class ClosedError < RuntimeError
21
34
  # Create a new error.
@@ -27,20 +40,22 @@ module Async
27
40
  end
28
41
 
29
42
  # Whether the fiber scheduler is supported.
30
- # @public Since `stable-v1`.
43
+ # @public Since *Async v1*.
31
44
  def self.supported?
32
45
  true
33
46
  end
34
47
 
35
48
  # Create a new scheduler.
36
49
  #
37
- # @public Since `stable-v1`.
50
+ # @public Since *Async v1*.
38
51
  # @parameter parent [Node | Nil] The parent node to use for task hierarchy.
39
52
  # @parameter selector [IO::Event::Selector] The selector to use for event handling.
40
- def initialize(parent = nil, selector: nil)
53
+ def initialize(parent = nil, selector: nil, profiler: Profiler&.default, worker_pool: WORKER_POOL)
41
54
  super(parent)
42
55
 
43
56
  @selector = selector || ::IO::Event::Selector.new(Fiber.current)
57
+ @profiler = profiler
58
+
44
59
  @interrupted = false
45
60
 
46
61
  @blocked = 0
@@ -49,9 +64,19 @@ module Async
49
64
  @idle_time = 0.0
50
65
 
51
66
  @timers = ::IO::Event::Timers.new
67
+ if worker_pool == true
68
+ @worker_pool = WorkerPool.new
69
+ else
70
+ @worker_pool = worker_pool
71
+ end
72
+
73
+ if @worker_pool
74
+ self.singleton_class.prepend(WorkerPool::BlockingOperationWait)
75
+ end
52
76
  end
53
77
 
54
78
  # Compute the scheduler load according to the busy and idle times that are updated by the run loop.
79
+ #
55
80
  # @returns [Float] The load of the scheduler. 0.0 means no load, 1.0 means fully loaded or over-loaded.
56
81
  def load
57
82
  total_time = @busy_time + @idle_time
@@ -71,52 +96,56 @@ module Async
71
96
  return @busy_time / total_time
72
97
  end
73
98
  end
74
-
99
+
75
100
  # Invoked when the fiber scheduler is being closed.
76
101
  #
77
102
  # Executes the run loop until all tasks are finished, then closes the scheduler.
78
- def scheduler_close
103
+ def scheduler_close(error = $!)
79
104
  # If the execution context (thread) was handling an exception, we want to exit as quickly as possible:
80
- unless $!
105
+ unless error
81
106
  self.run
82
107
  end
83
108
  ensure
84
109
  self.close
85
110
  end
86
111
 
87
- # Terminate the scheduler. We deliberately ignore interrupts here, as this code can be called from an interrupt, and we don't want to be interrupted while cleaning up.
112
+ # Terminate all child tasks.
88
113
  def terminate
89
- Thread.handle_interrupt(::Interrupt => :never) do
90
- super
114
+ # If that doesn't work, take more serious action:
115
+ @children&.each do |child|
116
+ child.terminate
91
117
  end
118
+
119
+ return @children.nil?
92
120
  end
93
121
 
94
122
  # Terminate all child tasks and close the scheduler.
95
- # @public Since `stable-v1`.
123
+ # @public Since *Async v1*.
96
124
  def close
97
- # It's critical to stop all tasks. Otherwise they might be holding on to resources which are never closed/released correctly.
98
- until self.terminate
99
- self.run_once!
125
+ self.run_loop do
126
+ until self.terminate
127
+ self.run_once!
128
+ end
100
129
  end
101
130
 
102
131
  Kernel.raise "Closing scheduler with blocked operations!" if @blocked > 0
103
-
104
- # We depend on GVL for consistency:
105
- # @guard.synchronize do
106
-
132
+ ensure
107
133
  # 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.
108
134
  selector = @selector
109
135
  @selector = nil
110
136
 
111
137
  selector&.close
112
138
 
113
- # end
139
+ worker_pool = @worker_pool
140
+ @worker_pool = nil
141
+
142
+ worker_pool&.close
114
143
 
115
144
  consume
116
145
  end
117
146
 
118
147
  # @returns [Boolean] Whether the scheduler has been closed.
119
- # @public Since `stable-v1`.
148
+ # @public Since *Async v1*.
120
149
  def closed?
121
150
  @selector.nil?
122
151
  end
@@ -168,7 +197,12 @@ module Async
168
197
  end
169
198
 
170
199
  # Invoked when a fiber tries to perform a blocking operation which cannot continue. A corresponding call {unblock} must be performed to allow this fiber to continue.
200
+ #
201
+ # @public Since *Async v2*.
171
202
  # @asynchronous May only be called on same thread as fiber scheduler.
203
+ #
204
+ # @parameter blocker [Object] The object that is blocking the fiber.
205
+ # @parameter timeout [Float | Nil] The maximum time to block, or if nil, indefinitely.
172
206
  def block(blocker, timeout)
173
207
  # $stderr.puts "block(#{blocker}, #{Fiber.current}, #{timeout})"
174
208
  fiber = Fiber.current
@@ -191,7 +225,13 @@ module Async
191
225
  timer&.cancel!
192
226
  end
193
227
 
228
+ # Unblock a fiber that was previously blocked.
229
+ #
230
+ # @public Since *Async v2* and *Ruby v3.1*.
194
231
  # @asynchronous May be called from any thread.
232
+ #
233
+ # @parameter blocker [Object] The object that was blocking the fiber.
234
+ # @parameter fiber [Fiber] The fiber to unblock.
195
235
  def unblock(blocker, fiber)
196
236
  # $stderr.puts "unblock(#{blocker}, #{fiber})"
197
237
 
@@ -202,7 +242,12 @@ module Async
202
242
  end
203
243
  end
204
244
 
205
- # @asynchronous May be non-blocking..
245
+ # Sleep for the specified duration.
246
+ #
247
+ # @public Since *Async v2* and *Ruby v3.1*.
248
+ # @asynchronous May be non-blocking.
249
+ #
250
+ # @parameter duration [Numeric | Nil] The time in seconds to sleep, or if nil, indefinitely.
206
251
  def kernel_sleep(duration = nil)
207
252
  if duration
208
253
  self.block(nil, duration)
@@ -211,7 +256,12 @@ module Async
211
256
  end
212
257
  end
213
258
 
214
- # @asynchronous May be non-blocking..
259
+ # Resolve the address of the given hostname.
260
+ #
261
+ # @public Since *Async v2*.
262
+ # @asynchronous May be non-blocking.
263
+ #
264
+ # @parameter hostname [String] The hostname to resolve.
215
265
  def address_resolve(hostname)
216
266
  # On some platforms, hostnames may contain a device-specific suffix (e.g. %en0). We need to strip this before resolving.
217
267
  # See <https://github.com/socketry/async/issues/180> for more details.
@@ -219,7 +269,6 @@ module Async
219
269
  ::Resolv.getaddresses(hostname)
220
270
  end
221
271
 
222
-
223
272
  if IO.method_defined?(:timeout)
224
273
  private def get_timeout(io)
225
274
  io.timeout
@@ -230,7 +279,14 @@ module Async
230
279
  end
231
280
  end
232
281
 
233
- # @asynchronous May be non-blocking..
282
+ # Wait for the specified IO to become ready for the specified events.
283
+ #
284
+ # @public Since *Async v2*.
285
+ # @asynchronous May be non-blocking.
286
+ #
287
+ # @parameter io [IO] The IO object to wait on.
288
+ # @parameter events [Integer] The events to wait for, e.g. `IO::READABLE`, `IO::WRITABLE`, etc.
289
+ # @parameter timeout [Float | Nil] The maximum time to wait, or if nil, indefinitely.
234
290
  def io_wait(io, events, timeout = nil)
235
291
  fiber = Fiber.current
236
292
 
@@ -242,7 +298,7 @@ module Async
242
298
  elsif timeout = get_timeout(io)
243
299
  # Otherwise, if we default to the io's timeout, we raise an exception:
244
300
  timer = @timers.after(timeout) do
245
- fiber.raise(::IO::TimeoutError, "Timeout while waiting for IO to become ready!")
301
+ fiber.raise(::IO::TimeoutError, "Timeout (#{timeout}s) while waiting for IO to become ready!")
246
302
  end
247
303
  end
248
304
 
@@ -252,12 +308,21 @@ module Async
252
308
  end
253
309
 
254
310
  if ::IO::Event::Support.buffer?
311
+ # Read from the specified IO into the buffer.
312
+ #
313
+ # @public Since *Async v2* and Ruby with `IO::Buffer` support.
314
+ # @asynchronous May be non-blocking.
315
+ #
316
+ # @parameter io [IO] The IO object to read from.
317
+ # @parameter buffer [IO::Buffer] The buffer to read into.
318
+ # @parameter length [Integer] The minimum number of bytes to read.
319
+ # @parameter offset [Integer] The offset within the buffer to read into.
255
320
  def io_read(io, buffer, length, offset = 0)
256
321
  fiber = Fiber.current
257
322
 
258
323
  if timeout = get_timeout(io)
259
324
  timer = @timers.after(timeout) do
260
- fiber.raise(::IO::TimeoutError, "Timeout while waiting for IO to become readable!")
325
+ fiber.raise(::IO::TimeoutError, "Timeout (#{timeout}s) while waiting for IO to become readable!")
261
326
  end
262
327
  end
263
328
 
@@ -267,12 +332,21 @@ module Async
267
332
  end
268
333
 
269
334
  if RUBY_ENGINE != "ruby" || RUBY_VERSION >= "3.3.1"
335
+ # Write the specified buffer to the IO.
336
+ #
337
+ # @public Since *Async v2* and *Ruby v3.3.1* with `IO::Buffer` support.
338
+ # @asynchronous May be non-blocking.
339
+ #
340
+ # @parameter io [IO] The IO object to write to.
341
+ # @parameter buffer [IO::Buffer] The buffer to write from.
342
+ # @parameter length [Integer] The minimum number of bytes to write.
343
+ # @parameter offset [Integer] The offset within the buffer to write from.
270
344
  def io_write(io, buffer, length, offset = 0)
271
345
  fiber = Fiber.current
272
346
 
273
347
  if timeout = get_timeout(io)
274
348
  timer = @timers.after(timeout) do
275
- fiber.raise(::IO::TimeoutError, "Timeout while waiting for IO to become writable!")
349
+ fiber.raise(::IO::TimeoutError, "Timeout (#{timeout}s) while waiting for IO to become writable!")
276
350
  end
277
351
  end
278
352
 
@@ -284,6 +358,10 @@ module Async
284
358
  end
285
359
 
286
360
  # Wait for the specified process ID to exit.
361
+ #
362
+ # @public Since *Async v2*.
363
+ # @asynchronous May be non-blocking.
364
+ #
287
365
  # @parameter pid [Integer] The process ID to wait for.
288
366
  # @parameter flags [Integer] A bit-mask of flags suitable for `Process::Status.wait`.
289
367
  # @returns [Process::Status] A process status instance.
@@ -292,28 +370,13 @@ module Async
292
370
  return @selector.process_wait(Fiber.current, pid, flags)
293
371
  end
294
372
 
295
- # Run one iteration of the event loop.
296
- # Does not handle interrupts.
297
- # @parameter timeout [Float | Nil] The maximum timeout, or if nil, indefinite.
298
- # @returns [Boolean] Whether there is more work to do.
299
- def run_once(timeout = nil)
300
- Kernel::raise "Running scheduler on non-blocking fiber!" unless Fiber.blocking?
301
-
302
- # If we are finished, we stop the task tree and exit:
303
- if self.finished?
304
- return false
305
- end
306
-
307
- return run_once!(timeout)
308
- end
309
-
310
373
  # Run one iteration of the event loop.
311
374
  #
312
375
  # 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.
313
376
  #
314
377
  # @parameter timeout [Float | Nil] The maximum timeout, or if nil, indefinite.
315
378
  # @returns [Boolean] Whether there is more work to do.
316
- private def run_once!(timeout = 0)
379
+ private def run_once!(timeout = nil)
317
380
  start_time = Async::Clock.now
318
381
 
319
382
  interval = @timers.wait_interval
@@ -350,7 +413,30 @@ module Async
350
413
  return true
351
414
  end
352
415
 
416
+ # Run one iteration of the event loop.
417
+ #
418
+ # @public Since *Async v1*.
419
+ # @asynchronous Must be invoked from blocking (root) fiber.
420
+ #
421
+ # @parameter timeout [Float | Nil] The maximum timeout, or if nil, indefinite.
422
+ # @returns [Boolean] Whether there is more work to do.
423
+ def run_once(timeout = nil)
424
+ Kernel.raise "Running scheduler on non-blocking fiber!" unless Fiber.blocking?
425
+
426
+ if self.finished?
427
+ self.stop
428
+ end
429
+
430
+ # If we are finished, we stop the task tree and exit:
431
+ if @children.nil?
432
+ return false
433
+ end
434
+
435
+ return run_once!(timeout)
436
+ end
437
+
353
438
  # Checks and clears the interrupted state of the scheduler.
439
+ #
354
440
  # @returns [Boolean] Whether the reactor has been interrupted.
355
441
  private def interrupted?
356
442
  if @interrupted
@@ -365,26 +451,34 @@ module Async
365
451
  return false
366
452
  end
367
453
 
368
- # Run the reactor until all tasks are finished. Proxies arguments to {#async} immediately before entering the loop, if a block is provided.
369
- def run(...)
370
- Kernel::raise ClosedError if @selector.nil?
371
-
372
- initial_task = self.async(...) if block_given?
454
+ # Stop all children, including transient children.
455
+ #
456
+ # @public Since *Async v1*.
457
+ def stop
458
+ @children&.each do |child|
459
+ child.stop
460
+ end
461
+ end
462
+
463
+ private def run_loop(&block)
373
464
  interrupt = nil
374
465
 
375
466
  begin
376
467
  # In theory, we could use Exception here to be a little bit safer, but we've only shown the case for SignalException to be a problem, so let's not over-engineer this.
377
468
  Thread.handle_interrupt(::SignalException => :never) do
378
- while true
379
- # If we are interrupted, we need to exit:
380
- break if self.interrupted?
381
-
469
+ until self.interrupted?
382
470
  # If we are finished, we need to exit:
383
- break unless self.run_once
471
+ break unless yield
384
472
  end
385
473
  end
386
474
  rescue Interrupt => interrupt
475
+ # If an interrupt did occur during an iteration of the event loop, we need to handle it. More specifically, `self.stop` is not safe to interrupt without potentially corrupting the task tree.
387
476
  Thread.handle_interrupt(::SignalException => :never) do
477
+ Console.debug(self) do |buffer|
478
+ buffer.puts "Scheduler interrupted: #{interrupt.inspect}"
479
+ self.print_hierarchy(buffer)
480
+ end
481
+
388
482
  self.stop
389
483
  end
390
484
 
@@ -392,37 +486,54 @@ module Async
392
486
  end
393
487
 
394
488
  # If the event loop was interrupted, and we finished exiting normally (due to the interrupt), we need to re-raise the interrupt so that the caller can handle it too.
395
- Kernel.raise interrupt if interrupt
489
+ if interrupt
490
+ Kernel.raise(interrupt)
491
+ end
492
+ end
493
+
494
+ # Run the reactor until all tasks are finished. Proxies arguments to {#async} immediately before entering the loop, if a block is provided.
495
+ #
496
+ # Forwards all parameters to {#async} if a block is given.
497
+ #
498
+ # @public Since *Async v1*.
499
+ #
500
+ # @yields {|task| ...} The top level task, if a block is given.
501
+ # @returns [Task] The initial task that was scheduled into the reactor.
502
+ def run(...)
503
+ Kernel.raise ClosedError if @selector.nil?
396
504
 
397
- return initial_task
398
- ensure
399
- Console.debug(self) {"Exiting run-loop because #{$! ? $! : 'finished'}."}
505
+ begin
506
+ @profiler&.start
507
+
508
+ initial_task = self.async(...) if block_given?
509
+
510
+ self.run_loop do
511
+ run_once
512
+ end
513
+
514
+ return initial_task
515
+ ensure
516
+ @profiler&.stop
517
+ end
400
518
  end
401
519
 
402
- # Start an asynchronous task within the specified reactor. The task will be
403
- # executed until the first blocking call, at which point it will yield and
404
- # and this method will return.
520
+ # Start an asynchronous task within the specified reactor. The task will be executed until the first blocking call, at which point it will yield and and this method will return.
405
521
  #
406
- # This is the main entry point for scheduling asynchronus tasks.
522
+ # @public Since *Async v1*.
523
+ # @asynchronous May context switch immediately to new task.
524
+ # @deprecated Use {#run} or {Task#async} instead.
407
525
  #
408
526
  # @yields {|task| ...} Executed within the task.
409
527
  # @returns [Task] The task that was scheduled into the reactor.
410
- # @deprecated With no replacement.
411
528
  def async(*arguments, **options, &block)
412
- Kernel::raise ClosedError if @selector.nil?
529
+ # warn "Async::Scheduler#async is deprecated. Use `run` or `Task#async` instead.", uplevel: 1, category: :deprecated
530
+
531
+ Kernel.raise ClosedError if @selector.nil?
413
532
 
414
533
  task = Task.new(Task.current? || self, **options, &block)
415
534
 
416
- # I want to take a moment to explain the logic of this.
417
- # When calling an async block, we deterministically execute it until the
418
- # first blocking operation. We don't *have* to do this - we could schedule
419
- # it for later execution, but it's useful to:
420
- # - Fail at the point of the method call where possible.
421
- # - Execute determinstically where possible.
422
- # - Avoid scheduler overhead if no blocking operation is performed.
423
535
  task.run(*arguments)
424
536
 
425
- # Console.debug "Initial execution of task #{fiber} complete (#{result} -> #{fiber.alive?})..."
426
537
  return task
427
538
  end
428
539
 
@@ -431,7 +542,14 @@ module Async
431
542
  end
432
543
 
433
544
  # Invoke the block, but after the specified timeout, raise {TimeoutError} in any currenly blocking operation. If the block runs to completion before the timeout occurs or there are no non-blocking operations after the timeout expires, the code will complete without any exception.
545
+ #
546
+ # @public Since *Async v1*.
547
+ # @asynchronous May raise an exception at any interruption point (e.g. blocking operations).
548
+ #
434
549
  # @parameter duration [Numeric] The time in seconds, in which the task should complete.
550
+ # @parameter exception [Class] The exception class to raise.
551
+ # @parameter message [String] The message to pass to the exception.
552
+ # @yields {|duration| ...} The block to execute with a timeout.
435
553
  def with_timeout(duration, exception = TimeoutError, message = "execution expired", &block)
436
554
  fiber = Fiber.current
437
555
 
@@ -446,6 +564,15 @@ module Async
446
564
  timer&.cancel!
447
565
  end
448
566
 
567
+ # Invoke the block, but after the specified timeout, raise the specified exception with the given message. If the block runs to completion before the timeout occurs or there are no non-blocking operations after the timeout expires, the code will complete without any exception.
568
+ #
569
+ # @public Since *Async v1* and *Ruby v3.1*. May be invoked from `Timeout.timeout`.
570
+ # @asynchronous May raise an exception at any interruption point (e.g. blocking operations).
571
+ #
572
+ # @parameter duration [Numeric] The time in seconds, in which the task should complete.
573
+ # @parameter exception [Class] The exception class to raise.
574
+ # @parameter message [String] The message to pass to the exception.
575
+ # @yields {|duration| ...} The block to execute with a timeout.
449
576
  def timeout_after(duration, exception, message, &block)
450
577
  with_timeout(duration, exception, message) do |timer|
451
578
  yield duration
@@ -1,13 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2018-2023, by Samuel Williams.
4
+ # Copyright, 2018-2024, by Samuel Williams.
5
5
 
6
- require_relative 'list'
6
+ require_relative "list"
7
7
 
8
8
  module Async
9
9
  # A synchronization primitive, which limits access to a given resource.
10
- # @public Since `stable-v1`.
10
+ # @public Since *Async v1*.
11
11
  class Semaphore
12
12
  # @parameter limit [Integer] The maximum number of times the semaphore can be acquired before it blocks.
13
13
  # @parameter parent [Task | Semaphore | Nil] The parent for holding any children tasks.
data/lib/async/task.rb CHANGED
@@ -7,11 +7,11 @@
7
7
  # Copyright, 2020, by Patrik Wenger.
8
8
  # Copyright, 2023, by Math Ieu.
9
9
 
10
- require 'fiber'
11
- require 'console/event/failure'
10
+ require "fiber"
11
+ require "console"
12
12
 
13
- require_relative 'node'
14
- require_relative 'condition'
13
+ require_relative "node"
14
+ require_relative "condition"
15
15
 
16
16
  Fiber.attr_accessor :async_task
17
17
 
@@ -40,7 +40,7 @@ module Async
40
40
  end
41
41
 
42
42
  # Raised if a timeout occurs on a specific Fiber. Handled gracefully by `Task`.
43
- # @public Since `stable-v1`.
43
+ # @public Since *Async v1*.
44
44
  class TimeoutError < StandardError
45
45
  # Create a new timeout error.
46
46
  #
@@ -50,7 +50,7 @@ module Async
50
50
  end
51
51
  end
52
52
 
53
- # @public Since `stable-v1`.
53
+ # @public Since *Async v1*.
54
54
  class Task < Node
55
55
  # Raised when a child task is created within a task that has finished execution.
56
56
  class FinishedError < RuntimeError
@@ -67,6 +67,13 @@ module Async
67
67
  Fiber.scheduler.transfer
68
68
  end
69
69
 
70
+ # Run the given block of code in a task, asynchronously, in the given scheduler.
71
+ def self.run(scheduler, *arguments, **options, &block)
72
+ self.new(scheduler, **options, &block).tap do |task|
73
+ task.run(*arguments)
74
+ end
75
+ end
76
+
70
77
  # Create a new task.
71
78
  # @parameter reactor [Reactor] the reactor this task will run within.
72
79
  # @parameter parent [Task] the parent task.
@@ -174,9 +181,12 @@ module Async
174
181
  @status == :completed
175
182
  end
176
183
 
177
- alias complete? completed?
184
+ # Alias for {#completed?}.
185
+ def complete?
186
+ self.completed?
187
+ end
178
188
 
179
- # @attribute [Symbol] The status of the execution of the fiber, one of `:initialized`, `:running`, `:complete`, `:stopped` or `:failed`.
189
+ # @attribute [Symbol] The status of the execution of the task, one of `:initialized`, `:running`, `:complete`, `:stopped` or `:failed`.
180
190
  attr :status
181
191
 
182
192
  # Begin the execution of the task.
@@ -191,9 +201,7 @@ module Async
191
201
  rescue => error
192
202
  # I'm not completely happy with this overhead, but the alternative is to not log anything which makes debugging extremely difficult. Maybe we can introduce a debug wrapper which adds extra logging.
193
203
  if @finished.nil?
194
- Console::Event::Failure.for(error).emit(self, "Task may have ended with unhandled exception.", severity: :warn)
195
- # else
196
- # Console::Event::Failure.for(error).emit(self, severity: :debug)
204
+ warn(self, "Task may have ended with unhandled exception.", exception: error)
197
205
  end
198
206
 
199
207
  raise
@@ -205,6 +213,10 @@ module Async
205
213
 
206
214
  # Run an asynchronous task as a child of the current task.
207
215
  #
216
+ # @public Since *Async v1*.
217
+ # @asynchronous May context switch immediately to the new task.
218
+ #
219
+ # @yields {|task| ...} in the context of the new task.
208
220
  # @raises [FinishedError] If the task has already finished.
209
221
  # @returns [Task] The child task.
210
222
  def async(*arguments, **options, &block)
@@ -212,6 +224,13 @@ module Async
212
224
 
213
225
  task = Task.new(self, **options, &block)
214
226
 
227
+ # When calling an async block, we deterministically execute it until the first blocking operation. We don't *have* to do this - we could schedule it for later execution, but it's useful to:
228
+ #
229
+ # - Fail at the point of the method call where possible.
230
+ # - Execute determinstically where possible.
231
+ # - Avoid scheduler overhead if no blocking operation is performed.
232
+ #
233
+ # There are different strategies (greedy vs non-greedy). We are currently using a greedy strategy.
215
234
  task.run(*arguments)
216
235
 
217
236
  return task
@@ -253,15 +272,18 @@ module Async
253
272
  return stopped!
254
273
  end
255
274
 
256
- # If we are deferring stop...
257
- if @defer_stop == false
258
- # Don't stop now... but update the state so we know we need to stop later.
259
- @defer_stop = true
260
- return false
261
- end
262
-
263
275
  # If the fiber is alive, we need to stop it:
264
276
  if @fiber&.alive?
277
+ # As the task is now exiting, we want to ensure the event loop continues to execute until the task finishes.
278
+ self.transient = false
279
+
280
+ # If we are deferring stop...
281
+ if @defer_stop == false
282
+ # Don't stop now... but update the state so we know we need to stop later.
283
+ @defer_stop = true
284
+ return false
285
+ end
286
+
265
287
  if self.current?
266
288
  # If the fiber is current, and later is `true`, we need to schedule the fiber to be stopped later, as it's currently invoking `stop`:
267
289
  if later
@@ -294,26 +316,30 @@ module Async
294
316
  # If stop is invoked a second time, it will be immediately executed.
295
317
  #
296
318
  # @yields {} The block of code to execute.
297
- # @public Since `stable-v1`.
319
+ # @public Since *Async v1*.
298
320
  def defer_stop
299
321
  # Tri-state variable for controlling stop:
300
322
  # - nil: defer_stop has not been called.
301
323
  # - false: defer_stop has been called and we are not stopping.
302
324
  # - true: defer_stop has been called and we will stop when exiting the block.
303
325
  if @defer_stop.nil?
304
- # If we are not deferring stop already, we can defer it now:
305
- @defer_stop = false
306
-
307
326
  begin
327
+ # If we are not deferring stop already, we can defer it now:
328
+ @defer_stop = false
329
+
308
330
  yield
309
331
  rescue Stop
310
332
  # If we are exiting due to a stop, we shouldn't try to invoke stop again:
311
333
  @defer_stop = nil
312
334
  raise
313
335
  ensure
336
+ defer_stop = @defer_stop
337
+
338
+ # We need to ensure the state is reset before we exit the block:
339
+ @defer_stop = nil
340
+
314
341
  # If we were asked to stop, we should do so now:
315
- if @defer_stop
316
- @defer_stop = nil
342
+ if defer_stop
317
343
  raise Stop, "Stopping current task (was deferred)!"
318
344
  end
319
345
  end
@@ -323,6 +349,11 @@ module Async
323
349
  end
324
350
  end
325
351
 
352
+ # @returns [Boolean] Whether stop has been deferred.
353
+ def stop_deferred?
354
+ @defer_stop
355
+ end
356
+
326
357
  # Lookup the {Task} for the current fiber. Raise `RuntimeError` if none is available.
327
358
  # @returns [Task]
328
359
  # @raises[RuntimeError] If task was not {set!} for the current fiber.
@@ -343,6 +374,10 @@ module Async
343
374
 
344
375
  private
345
376
 
377
+ def warn(...)
378
+ Console.warn(...)
379
+ end
380
+
346
381
  # Finish the current task, moving any children to the parent.
347
382
  def finish!
348
383
  # Don't hold references to the fiber or block after the task has finished: