Skip to content

Commit 53c447e

Browse files
authored
Add more flexible timeout. (#386)
1 parent a5b59e1 commit 53c447e

File tree

4 files changed

+198
-3
lines changed

4 files changed

+198
-3
lines changed

lib/async/scheduler.rb

+8-3
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
require_relative "clock"
99
require_relative "task"
10+
require_relative "timeout"
1011
require_relative "worker_pool"
1112

1213
require "io/event"
@@ -539,7 +540,7 @@ def fiber(...)
539540
# @parameter duration [Numeric] The time in seconds, in which the task should complete.
540541
# @parameter exception [Class] The exception class to raise.
541542
# @parameter message [String] The message to pass to the exception.
542-
# @yields {|duration| ...} The block to execute with a timeout.
543+
# @yields {|timeout| ...} The block to execute with a timeout.
543544
def with_timeout(duration, exception = TimeoutError, message = "execution expired", &block)
544545
fiber = Fiber.current
545546

@@ -549,7 +550,11 @@ def with_timeout(duration, exception = TimeoutError, message = "execution expire
549550
end
550551
end
551552

552-
yield timer
553+
if block.arity.zero?
554+
yield
555+
else
556+
yield Timeout.new(@timers, timer)
557+
end
553558
ensure
554559
timer&.cancel!
555560
end
@@ -564,7 +569,7 @@ def with_timeout(duration, exception = TimeoutError, message = "execution expire
564569
# @parameter message [String] The message to pass to the exception.
565570
# @yields {|duration| ...} The block to execute with a timeout.
566571
def timeout_after(duration, exception, message, &block)
567-
with_timeout(duration, exception, message) do |timer|
572+
with_timeout(duration, exception, message) do
568573
yield duration
569574
end
570575
end

lib/async/timeout.rb

+88
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2025, by Samuel Williams.
5+
6+
module Async
7+
# Represents a flexible timeout that can be rescheduled or extended.
8+
# @public Since *Async v2.24*.
9+
class Timeout
10+
# Initialize a new timeout.
11+
def initialize(timers, handle)
12+
@timers = timers
13+
@handle = handle
14+
end
15+
16+
# @returns [Numeric] The time remaining until the timeout occurs, in seconds.
17+
def duration
18+
@handle.time - @timers.now
19+
end
20+
21+
# Update the duration of the timeout.
22+
#
23+
# The duration is relative to the current time, e.g. setting the duration to 5 means the timeout will occur in 5 seconds from now.
24+
#
25+
# @parameter value [Numeric] The new duration to assign to the timeout, in seconds.
26+
def duration=(value)
27+
self.reschedule(@timers.now + value)
28+
end
29+
30+
# Adjust the timeout by the specified duration.
31+
#
32+
# The duration is relative to the timeout time, e.g. adjusting the timeout by 5 increases the current duration by 5 seconds.
33+
#
34+
# @parameter duration [Numeric] The duration to adjust the timeout by, in seconds.
35+
# @returns [Numeric] The new time at which the timeout will occur.
36+
def adjust(duration)
37+
self.reschedule(time + duration)
38+
end
39+
40+
# @returns [Numeric] The time at which the timeout will occur, in seconds since {now}.
41+
def time
42+
@handle.time
43+
end
44+
45+
# Assign a new time to the timeout, rescheduling it if necessary.
46+
#
47+
# @parameter value [Numeric] The new time to assign to the timeout.
48+
# @returns [Numeric] The new time at which the timeout will occur.
49+
def time=(value)
50+
self.reschedule(value)
51+
end
52+
53+
# @returns [Numeric] The current time in the scheduler, relative to the time of this timeout, in seconds.
54+
def now
55+
@timers.now
56+
end
57+
58+
# Cancel the timeout, preventing it from executing.
59+
def cancel!
60+
@handle.cancel!
61+
end
62+
63+
# @returns [Boolean] Whether the timeout has been cancelled.
64+
def cancelled?
65+
@handle.cancelled?
66+
end
67+
68+
# Raised when attempting to reschedule a cancelled timeout.
69+
class CancelledError < RuntimeError
70+
end
71+
72+
# Reschedule the timeout to occur at the specified time.
73+
#
74+
# @parameter time [Numeric] The new time to schedule the timeout for.
75+
# @returns [Numeric] The new time at which the timeout will occur.
76+
private def reschedule(time)
77+
if block = @handle&.block
78+
@handle.cancel!
79+
80+
@handle = @timers.schedule(time, block)
81+
82+
return time
83+
else
84+
raise CancelledError, "Cannot reschedule a cancelled timeout!"
85+
end
86+
end
87+
end
88+
end

releases.md

+28
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,34 @@
55
- Ruby v3.1 support is dropped.
66
- `Async::Wrapper` which was previously deprecated, is now removed.
77

8+
### Flexible Timeouts
9+
10+
When {ruby Async::Scheduler#with_timeout} is invoked with a block, it can receive a {ruby Async::Timeout} instance. This allows you to adjust or cancel the timeout while the block is executing. This is useful for long-running tasks that may need to adjust their timeout based on external factors.
11+
12+
``` ruby
13+
Async do
14+
Async::Scheduler.with_timeout(5) do |timeout|
15+
# Do some work that may take a while...
16+
17+
if some_condition
18+
timeout.cancel! # Cancel the timeout
19+
else
20+
# Add 10 seconds to the current timeout:
21+
timeout.adjust(10)
22+
23+
# Reduce the timeout by 10 seconds:
24+
timeout.adjust(-10)
25+
26+
# Set the timeout to 10 seconds from now:
27+
timeout.duration = 10
28+
29+
# Increase the current duration:
30+
timeout.duration += 10
31+
end
32+
end
33+
end
34+
```
35+
836
## v2.23.0
937

1038
- Rename `ASYNC_SCHEDULER_DEFAULT_WORKER_POOL` to `ASYNC_SCHEDULER_WORKER_POOL`.

test/async/timeout.rb

+74
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# frozen_string_literal: true
2+
require "sus/fixtures/async"
3+
4+
describe Async::Timeout do
5+
include Sus::Fixtures::Async::ReactorContext
6+
7+
it "can schedule a timeout" do
8+
scheduler.with_timeout(1) do |timeout|
9+
expect(timeout.time).to be >= 0
10+
expect(timeout.duration).to (be > 0).and(be <= 1)
11+
end
12+
end
13+
14+
with "#now" do
15+
it "can get the current time" do
16+
scheduler.with_timeout(1) do |timeout|
17+
expect(timeout.now).to be >= 0
18+
expect(timeout.now).to be <= timeout.time
19+
end
20+
end
21+
end
22+
23+
with "#adjust" do
24+
it "can adjust the timeout" do
25+
scheduler.with_timeout(1) do |timeout|
26+
timeout.adjust(1)
27+
expect(timeout.duration).to (be > 1).and(be <= 2)
28+
end
29+
end
30+
end
31+
32+
with "#duration=" do
33+
it "can set the timeout duration" do
34+
scheduler.with_timeout(1) do |timeout|
35+
timeout.duration = 2
36+
expect(timeout.duration).to (be > 1).and(be <= 2)
37+
end
38+
end
39+
40+
it "can increase the timeout duration" do
41+
scheduler.with_timeout(1) do |timeout|
42+
timeout.duration += 2
43+
expect(timeout.duration).to (be > 2).and(be <= 3)
44+
end
45+
end
46+
end
47+
48+
with "#time=" do
49+
it "can set the timeout time" do
50+
scheduler.with_timeout(1) do |timeout|
51+
timeout.time = timeout.time + 1
52+
expect(timeout.duration).to (be > 1).and(be < 2)
53+
end
54+
end
55+
end
56+
57+
with "#cancel!" do
58+
it "can cancel the timeout" do
59+
scheduler.with_timeout(1) do |timeout|
60+
timeout.cancel!
61+
expect(timeout).to be(:cancelled?)
62+
end
63+
end
64+
65+
it "can't reschedule a cancelled timeout" do
66+
scheduler.with_timeout(1) do |timeout|
67+
timeout.cancel!
68+
expect do
69+
timeout.adjust(1)
70+
end.to raise_exception(Async::Timeout::CancelledError)
71+
end
72+
end
73+
end
74+
end

0 commit comments

Comments
 (0)