diff --git a/lib/async.rb b/lib/async.rb index c3ba03df..d72be6c6 100644 --- a/lib/async.rb +++ b/lib/async.rb @@ -6,6 +6,7 @@ require_relative "async/version" require_relative "async/reactor" +require_relative "async/loop" require_relative "kernel/async" require_relative "kernel/sync" diff --git a/lib/async/loop.rb b/lib/async/loop.rb new file mode 100644 index 00000000..e7d135ea --- /dev/null +++ b/lib/async/loop.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "console" + +module Async + # A helper for running loops at aligned intervals. + module Loop + # A robust loop that executes a block at aligned intervals. + # + # The alignment is modulo the current clock in seconds. + # + # If an error occurs during the execution of the block, it is logged and the loop continues. + # + # @parameter interval [Integer] The interval in seconds between executions of the block. + def self.run(interval: 60, &block) + while true + # Compute the wait time to the next interval: + wait = interval - (Time.now.to_f % interval) + if wait.positive? + # Sleep until the next interval boundary: + sleep(wait) + end + + begin + yield + rescue => error + Console.error(self, "Loop error:", error) + end + end + end + end +end diff --git a/releases.md b/releases.md index 92c46fc5..04982106 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,9 @@ # Releases +## Unreleased + + - Introduce `Async::Loop` for robust, time-aligned loops. + ## v2.36.0 - Introduce `Task#wait_all` which recursively waits for all children and self, excepting the current task. diff --git a/test/async/loop.rb b/test/async/loop.rb new file mode 100644 index 00000000..5363c8c6 --- /dev/null +++ b/test/async/loop.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "async/loop" +require "sus/fixtures/console" + +describe Async::Loop do + include Sus::Fixtures::Console::CapturedLogger + + with ".run" do + it "invokes the block at aligned intervals" do + iterations = 0 + thread = Thread.new do + Async::Loop.run(interval: 0.1) do + iterations += 1 + end + end + + sleep(0.35) + expect(iterations).to be >= 2 + ensure + thread.kill + thread.join + end + + it "uses the given interval" do + iterations = 0 + interval = 0.05 + thread = Thread.new do + Async::Loop.run(interval: interval) do + iterations += 1 + end + end + + sleep(0.2) + expect(iterations).to be >= 2 + ensure + thread.kill + thread.join + end + + it "continues after an error and logs it" do + iterations = 0 + thread = Thread.new do + Async::Loop.run(interval: 0.05) do + iterations += 1 + raise "test error" if iterations == 1 + end + end + + # Allow first iteration (raises), then at least one more (succeeds) + sleep(0.2) + expect(iterations).to be >= 2 + expect_console.to have_logged( + severity: be == :error, + subject: be_equal(Async::Loop), + message: be =~ /Loop error:/ + ) + ensure + thread.kill + thread.join + end + end +end