diff --git a/.github/workflows/development.yml b/.github/workflows/development.yml index 37175b59..47381cb4 100644 --- a/.github/workflows/development.yml +++ b/.github/workflows/development.yml @@ -4,6 +4,7 @@ on: [push, pull_request] jobs: test: + name: ${{matrix.ruby}} on ${{matrix.os}} runs-on: ${{matrix.os}}-latest continue-on-error: ${{matrix.experimental}} @@ -14,10 +15,10 @@ jobs: - macos ruby: - - 2.5 - - 2.6 - - 2.7 - - 3.0 + - "2.7" + - "3.0" + - "3.1" + - "3.2" experimental: [false] env: [""] @@ -34,8 +35,8 @@ jobs: ruby: head experimental: true - os: ubuntu - ruby: 2.6 - env: COVERAGE=PartialSummary,Coveralls + ruby: "3.2" + env: COVERAGE=PartialSummary experimental: true steps: diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 5c081abc..61956e97 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -3,7 +3,7 @@ name: Documentation on: push: branches: - - master + - main jobs: deploy: @@ -15,7 +15,7 @@ jobs: env: BUNDLE_WITH: maintenance with: - ruby-version: 2.7 + ruby-version: 3.0 bundler-cache: true - name: Installing packages @@ -28,5 +28,5 @@ jobs: - name: Deploy documentation uses: JamesIves/github-pages-deploy-action@4.0.0 with: - branch: gh-pages + branch: docs folder: docs diff --git a/async.gemspec b/async.gemspec index f5625e5c..8cd096be 100644 --- a/async.gemspec +++ b/async.gemspec @@ -11,7 +11,7 @@ Gem::Specification.new do |spec| spec.homepage = "https://github.com/socketry/async" - spec.files = Dir.glob('{lib}/**/*', File::FNM_DOTMATCH, base: __dir__) + spec.files = Dir.glob('{lib}/**/*.rb', File::FNM_DOTMATCH, base: __dir__) spec.required_ruby_version = ">= 2.5.0" diff --git a/bake.rb b/bake.rb index d4899c0e..b222aba2 100644 --- a/bake.rb +++ b/bake.rb @@ -5,18 +5,12 @@ def external Bundler.with_clean_env do clone_and_test("async-io") - clone_and_test("async-pool") - clone_and_test("async-websocket") - clone_and_test("async-dns") - clone_and_test("async-http") - clone_and_test("falcon") - clone_and_test("async-rest") end end private -def clone_and_test(name) +def clone_and_test(name, command = "rspec") require 'fileutils' path = "external/#{name}" @@ -35,5 +29,5 @@ def clone_and_test(name) file.puts('gem "async", path: "../../"') end - system("cd #{path} && bundle install && bundle exec rspec") or abort("Tests failed!") + system("cd #{path} && bundle install && bundle exec #{command}") or abort("Tests for #{name} failed!") end diff --git a/gems.rb b/gems.rb index 6110fab5..2e7913d2 100644 --- a/gems.rb +++ b/gems.rb @@ -5,7 +5,7 @@ gemspec group :maintenance, optional: true do - gem "bake-bundler" + gem "bake-gem" gem "bake-modernize" gem "utopia-project" diff --git a/lib/async/barrier.rb b/lib/async/barrier.rb index df47b0e6..b82a98eb 100644 --- a/lib/async/barrier.rb +++ b/lib/async/barrier.rb @@ -50,11 +50,29 @@ def empty? @tasks.empty? end - # Wait for tasks in FIFO order. + # Wait for all tasks. + # @asynchronous Will wait for tasks to finish executing. def wait - while task = @tasks.shift - task.wait + # TODO: This would be better with linked list. + while @tasks.any? + task = @tasks.first + + begin + task.wait + ensure + # We don't know for sure that the exception was due to the task completion. + unless task.running? + # Remove the task from the waiting list if it's finished: + @tasks.shift if @tasks.first == task + end + end end end + + def stop + # We have to be careful to avoid enumerating tasks while adding/removing to it: + tasks = @tasks.dup + tasks.each(&:stop) + end end end diff --git a/lib/async/condition.rb b/lib/async/condition.rb index 0be6c1e6..9940f92e 100644 --- a/lib/async/condition.rb +++ b/lib/async/condition.rb @@ -21,7 +21,6 @@ # THE SOFTWARE. require 'fiber' -require 'forwardable' require_relative 'node' module Async diff --git a/lib/async/queue.rb b/lib/async/queue.rb index 53eb5f2b..3629ac41 100644 --- a/lib/async/queue.rb +++ b/lib/async/queue.rb @@ -42,13 +42,17 @@ def empty? @items.empty? end - def enqueue(item) - @items.push(item) + def <<(item) + @items << item self.signal unless self.empty? end - alias << enqueue + def enqueue(*items) + @items.concat(items) + + self.signal unless self.empty? + end def dequeue while @items.empty? @@ -87,7 +91,7 @@ def limited? @items.size >= @limit end - def enqueue item + def <<(item) while limited? @full.wait end @@ -95,6 +99,19 @@ def enqueue item super end + def enqueue *items + while !items.empty? + while limited? + @full.wait + end + + available = @limit - @items.size + @items.concat(items.shift(available)) + + self.signal unless self.empty? + end + end + def dequeue item = super diff --git a/lib/async/reactor.rb b/lib/async/reactor.rb index 41caf91f..d769ae55 100644 --- a/lib/async/reactor.rb +++ b/lib/async/reactor.rb @@ -93,6 +93,10 @@ def initialize(parent = nil, selector: self.class.selector, logger: nil) @unblocked = [] end + def inspect + "#<#{self.class} children=#{@children&.size} stopped=#{stopped?}>" + end + attr :scheduler attr :logger diff --git a/lib/async/scheduler.rb b/lib/async/scheduler.rb index e6baa96a..02bdc016 100644 --- a/lib/async/scheduler.rb +++ b/lib/async/scheduler.rb @@ -71,7 +71,7 @@ def io_wait(io, events, timeout = nil) rescue TimeoutError return nil ensure - wrapper.reactor = nil + wrapper&.reactor = nil end # Wait for the specified process ID to exit. diff --git a/lib/async/semaphore.rb b/lib/async/semaphore.rb index 87ad49c9..753849b7 100644 --- a/lib/async/semaphore.rb +++ b/lib/async/semaphore.rb @@ -40,6 +40,25 @@ def initialize(limit = 1, parent: nil) # The tasks waiting on this semaphore. attr :waiting + # Allow setting the limit. This is useful for cases where the semaphore is used to limit the number of concurrent tasks, but the number of tasks is not known in advance or needs to be modified. + # + # On increasing the limit, some tasks may be immediately resumed. On decreasing the limit, some tasks may execute until the count is < than the limit. + # + # @parameter limit [Integer] The new limit. + def limit= limit + difference = limit - @limit + @limit = limit + + # We can't suspend + if difference > 0 + difference.times do + break unless fiber = @waiting.shift + + fiber.resume + end + end + end + # Is the semaphore currently acquired? def empty? @count.zero? diff --git a/lib/async/task.rb b/lib/async/task.rb index 6be28a05..ed32885d 100644 --- a/lib/async/task.rb +++ b/lib/async/task.rb @@ -84,6 +84,8 @@ def initialize(reactor, parent = Task.current?, logger: nil, finished: nil, **op @logger = logger || @parent.logger @fiber = make_fiber(&block) + + @defer_stop = nil end attr :logger @@ -162,6 +164,13 @@ def stop(later = false) return end + # If we are deferring stop... + if @defer_stop == false + # Don't stop now... but update the state so we know we need to stop later. + @defer_stop = true + return false + end + if self.running? if self.current? if later @@ -182,6 +191,41 @@ def stop(later = false) end end + # Defer the handling of stop. During the execution of the given block, if a stop is requested, it will be deferred until the block exits. This is useful for ensuring graceful shutdown of servers and other long-running tasks. You should wrap the response handling code in a defer_stop block to ensure that the task is stopped when the response is complete but not before. + # + # You can nest calls to defer_stop, but the stop will only be deferred until the outermost block exits. + # + # If stop is invoked a second time, it will be immediately executed. + # + # @yields {} The block of code to execute. + def defer_stop + # Tri-state variable for controlling stop: + # - nil: defer_stop has not been called. + # - false: defer_stop has been called and we are not stopping. + # - true: defer_stop has been called and we will stop when exiting the block. + if @defer_stop.nil? + # If we are not deferring stop already, we can defer it now: + @defer_stop = false + + begin + yield + rescue Stop + # If we are exiting due to a stop, we shouldn't try to invoke stop again: + @defer_stop = nil + raise + ensure + # If we were asked to stop, we should do so now: + if @defer_stop + @defer_stop = nil + self.stop + end + end + else + # If we are deferring stop already, entering it again is a no-op. + yield + end + end + # Lookup the {Task} for the current fiber. Raise `RuntimeError` if none is available. # @return [Async::Task] # @raise [RuntimeError] if task was not {set!} for the current fiber. @@ -230,18 +274,19 @@ def complete? private # This is a very tricky aspect of tasks to get right. I've modelled it after `Thread` but it's slightly different in that the exception can propagate back up through the reactor. If the user writes code which raises an exception, that exception should always be visible, i.e. cause a failure. If it's not visible, such code fails silently and can be very difficult to debug. - # As an explcit choice, the user can start a task which doesn't propagate exceptions. This only applies to `StandardError` and derived tasks. This allows tasks to internally capture their error state which is raised when invoking `Task#result` similar to how `Thread#join` works. This mode makes {ruby Async::Task} behave more like a promise, and you would need to ensure that someone calls `Task#result` otherwise you might miss important errors. - def fail!(exception = nil, propagate = true) + def fail!(exception = false, propagate = true) @status = :failed @result = exception - if propagate - raise - elsif @finished.nil? - # If no one has called wait, we log this as an error: - Console.logger.error(self) {$!} - else - Console.logger.debug(self) {$!} + if exception + if propagate + raise exception + elsif @finished.nil? + # If no one has called wait, we log this as a warning: + Console.logger.warn(self, "Task may have ended with unhandled exception.", exception) + else + Console.logger.debug(self, exception) + end end end diff --git a/lib/async/variable.rb b/lib/async/variable.rb new file mode 100644 index 00000000..95eb672b --- /dev/null +++ b/lib/async/variable.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +# Copyright, 2017, by Samuel G. D. Williams. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +require_relative 'condition' + +module Async + class Variable + def initialize(condition = Condition.new) + @condition = condition + @value = nil + end + + def resolve(value = true) + @value = value + condition = @condition + @condition = nil + + self.freeze + + condition.signal(value) + end + + def resolved? + @condition.nil? + end + + def value + @condition&.wait + return @value + end + + def wait + self.value + end + end +end diff --git a/lib/async/version.rb b/lib/async/version.rb index 83c4038b..9ea403d0 100644 --- a/lib/async/version.rb +++ b/lib/async/version.rb @@ -21,5 +21,5 @@ # THE SOFTWARE. module Async - VERSION = "1.29.1" + VERSION = "1.32.1" end diff --git a/spec/async/barrier_spec.rb b/spec/async/barrier_spec.rb index ab21fc7e..80ff663b 100644 --- a/spec/async/barrier_spec.rb +++ b/spec/async/barrier_spec.rb @@ -31,7 +31,7 @@ RSpec.describe Async::Barrier do include_context Async::RSpec::Reactor - context '#async' do + describe '#async' do let(:repeats) {40} let(:delay) {0.1} @@ -61,7 +61,7 @@ end end - context '#wait' do + describe '#wait' do it 'should wait for tasks even after exceptions' do task1 = subject.async do raise "Boom" @@ -93,6 +93,91 @@ expect(order).to be == [0, 1, 2, 3, 4] end + + # It's possible for Barrier#wait to be interrupted with an unexpected exception, and this should not cause the barrier to incorrectly remove that task from the wait list. + it 'waits for tasks with timeouts' do + begin + reactor.with_timeout(0.25) do + 5.times do |i| + subject.async do |task| + task.sleep(i/10.0) + end + end + + expect(subject.tasks.size).to be == 5 + subject.wait + end + rescue Async::TimeoutError + # Expected. + ensure + expect(subject.tasks.size).to be == 2 + subject.stop + end + end + end + + describe '#stop' do + it "can stop several tasks" do + task1 = subject.async do |task| + task.sleep(10) + end + + task2 = subject.async do |task| + task.sleep(10) + end + + subject.stop + + expect(task1).to be_stopped + expect(task2).to be_stopped + end + + it "can stop several tasks when waiting on barrier" do + task1 = subject.async do |task| + task.sleep(10) + end + + task2 = subject.async do |task| + task.sleep(10) + end + + task3 = reactor.async do + subject.wait + end + + subject.stop + + task1.wait + task2.wait + + expect(task1).to be_stopped + expect(task2).to be_stopped + + task3.wait + end + + it "several tasks can wait on the same barrier" do + task1 = subject.async do |task| + task.sleep(10) + end + + task2 = reactor.async do |task| + subject.wait + end + + task3 = reactor.async do + subject.wait + end + + subject.stop + + task1.wait + + expect(task1).to be_stopped + + task2.wait + task3.wait + end end context 'with semaphore' do diff --git a/spec/async/condition_examples.rb b/spec/async/condition_examples.rb index 0fcbb82d..6aaeb2b1 100644 --- a/spec/async/condition_examples.rb +++ b/spec/async/condition_examples.rb @@ -20,6 +20,8 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. +require 'async/variable' + RSpec.shared_examples Async::Condition do it 'can signal waiting task' do state = nil @@ -71,6 +73,9 @@ end context "with timeout" do + let!(:ready) {Async::Variable.new(subject)} + let!(:waiting) {Async::Variable.new(described_class.new)} + before do @state = nil end @@ -80,7 +85,9 @@ task.with_timeout(0.1) do begin @state = :waiting - subject.wait + waiting.resolve + + ready.wait @state = :signalled rescue Async::TimeoutError @state = :timeout @@ -96,7 +103,9 @@ end it 'can signal while waiting' do - subject.signal + waiting.wait + ready.resolve + task.wait expect(@state).to be == :signalled diff --git a/spec/async/queue_spec.rb b/spec/async/queue_spec.rb index 1e62a00c..e08d7073 100644 --- a/spec/async/queue_spec.rb +++ b/spec/async/queue_spec.rb @@ -42,6 +42,18 @@ end end + it 'can enqueue multiple items' do + items = Array.new(10) { rand(10) } + + reactor.async do |task| + subject.enqueue(*items) + end + + items.each do |item| + expect(subject.dequeue).to be == item + end + end + it 'can dequeue items asynchronously' do reactor.async do |task| subject << 1 @@ -53,6 +65,14 @@ end end + describe '#<<' do + it 'adds an item to the queue' do + subject << :item + expect(subject.size).to be == 1 + expect(subject.dequeue).to be == :item + end + end + describe '#size' do it 'returns queue size' do reactor.async do |task| @@ -120,7 +140,17 @@ subject.enqueue(10) expect(subject).to be_limited end - + + it 'enqueues items up to a limit' do + items = Array.new(2) { rand(10) } + reactor.async do + subject.enqueue(*items) + end + + expect(subject.size).to be 1 + expect(subject.dequeue).to be == items.first + end + it 'should resume waiting tasks in order' do total_resumed = 0 total_dequeued = 0 @@ -141,4 +171,27 @@ expect(total_resumed).to be == total_dequeued end end + + describe '#<<' do + context 'when queue is limited' do + before do + subject << :item1 + expect(subject.size).to be == 1 + expect(subject).to be_limited + end + + it 'waits until a queue is dequeued' do + reactor.async do + subject << :item2 + end + + reactor.async do |task| + task.sleep 0.01 + expect(subject.items).to contain_exactly :item1 + subject.dequeue + expect(subject.items).to contain_exactly :item2 + end + end + end + end end diff --git a/spec/async/scheduler_spec.rb b/spec/async/scheduler_spec.rb index 354c0c59..2d6faab6 100644 --- a/spec/async/scheduler_spec.rb +++ b/spec/async/scheduler_spec.rb @@ -56,21 +56,19 @@ let(:message) {"Helloooooo World!"} it "can send message via pipe" do - input, output = IO.pipe - - reactor.async do - sleep(0.001) - - message.each_char do |character| - output.write(character) + IO.pipe do |input, output| + reactor.async do + sleep(0.001) + + message.each_char do |character| + output.write(character) + end + + output.close end - output.close + expect(input.read).to be == message end - - expect(input.read).to be == message - - input.close end it "can fetch website using Net::HTTP" do diff --git a/spec/async/semaphore_spec.rb b/spec/async/semaphore_spec.rb index 18d435b5..c9c76071 100644 --- a/spec/async/semaphore_spec.rb +++ b/spec/async/semaphore_spec.rb @@ -62,7 +62,7 @@ 3.times.map do |i| semaphore.async do |task| order << i - task.sleep(0.1) + task.yield order << i end end.collect(&:result) @@ -72,17 +72,22 @@ it 'allows tasks to execute concurrently' do semaphore = Async::Semaphore.new(3) - order = [] + concurrency = 0 + latch = Async::Condition.new 3.times.map do |i| semaphore.async do |task| - order << i - task.sleep(0.1) - order << i + concurrency += 1 + + if concurrency == 3 + latch.signal + else + latch.wait + end end - end.collect(&:result) + end.each(&:wait) - expect(order).to be == [0, 1, 2, 0, 1, 2] + expect(concurrency).to be == 3 end end @@ -116,6 +121,43 @@ end end + context '#limit=' do + it "releases tasks when limit is increased" do + subject.acquire + expect(subject.count).to be == 1 + expect(subject.blocking?).to be_truthy + + task = Async do + subject.acquire + end + + subject.limit = 2 + task.wait + + expect(subject.count).to be == 2 + end + + it "blocks tasks when limit is decreased" do + subject.limit = 2 + subject.acquire + subject.acquire + + expect(subject.count).to be == 2 + expect(subject.blocking?).to be_truthy + + task = Async do + subject.acquire + end + + subject.limit = 1 + subject.release + subject.release + task.wait + + expect(subject.count).to be == 1 + end + end + context '#empty?' do it 'should be empty unless acquired' do expect(subject).to be_empty diff --git a/spec/async/task_spec.rb b/spec/async/task_spec.rb index 12679577..a57c1843 100644 --- a/spec/async/task_spec.rb +++ b/spec/async/task_spec.rb @@ -22,6 +22,7 @@ require 'async' require 'async/clock' +require 'async/notification' RSpec.describe Async::Task do let(:reactor) {Async::Reactor.new} @@ -300,12 +301,13 @@ it "can sleep for the requested duration" do state = nil - reactor.async do |task| - task.sleep(duration) - state = :finished - end - + # Measure the entire time, it's got to be at least bigger than the requested duration: time = Async::Clock.measure do + reactor.async do |task| + task.sleep(duration) + state = :finished + end + reactor.run end @@ -495,4 +497,62 @@ def sleep_forever expect(apples_task.to_s).to include "complete" end end + + describe '#defer_stop' do + it "can defer stopping a task" do + child_task = reactor.async do |task| + task.defer_stop do + task.sleep(10) + end + end + + reactor.run_once(0) + + child_task.stop + expect(child_task).to be_running + + child_task.stop + expect(child_task).to be_stopped + end + + it "will stop the task if it was deferred" do + condition = Async::Notification.new + + child_task = reactor.async do |task| + task.defer_stop do + condition.wait + end + end + + reactor.run_once(0) + + child_task.stop(true) + expect(child_task).to be_running + + reactor.async do + condition.signal + end + + reactor.run_once(0) + expect(child_task).to be_stopped + end + + it "can defer stop in a deferred stop" do + child_task = reactor.async do |task| + task.defer_stop do + task.defer_stop do + task.sleep(10) + end + end + end + + reactor.run_once(0) + + child_task.stop + expect(child_task).to be_running + + child_task.stop + expect(child_task).to be_stopped + end + end end diff --git a/spec/async/variable_spec.rb b/spec/async/variable_spec.rb new file mode 100644 index 00000000..7e943bd8 --- /dev/null +++ b/spec/async/variable_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +# Copyright, 2017, by Samuel G. D. Williams. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +require 'async/variable' + +RSpec.shared_examples_for Async::Variable do |value| + it "can resolve the value to #{value.inspect}" do + subject.resolve(value) + is_expected.to be_resolved + end + + it "can wait for the value to be resolved" do + Async do + expect(subject.wait).to be value + end + + subject.resolve(value) + end + + it "can't resolve it a 2nd time" do + subject.resolve(value) + expect do + subject.resolve(value) + end.to raise_error(FrozenError) + end +end + +RSpec.describe Async::Variable do + include_context Async::RSpec::Reactor + + it_behaves_like Async::Variable, true + it_behaves_like Async::Variable, false + it_behaves_like Async::Variable, nil + it_behaves_like Async::Variable, Object.new +end