Skip to content

Commit 941d78d

Browse files
committed
Add support for Async::Promise#wait(timeout: N). (#446)
1 parent 18eb7da commit 941d78d

File tree

4 files changed

+187
-14
lines changed

4 files changed

+187
-14
lines changed

lib/async/error.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2026, by Samuel Williams.
5+
6+
module Async
7+
# Raised if a timeout occurs on a specific Fiber. Handled gracefully by `Task`.
8+
# @public Since *Async v1*.
9+
class TimeoutError < StandardError
10+
# Create a new timeout error.
11+
#
12+
# @parameter message [String] The error message.
13+
def initialize(message = "execution expired")
14+
super
15+
end
16+
end
17+
end

lib/async/promise.rb

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
# Copyright, 2025, by Shopify Inc.
55
# Copyright, 2025-2026, by Samuel Williams.
66

7+
require_relative "error"
8+
require_relative "deadline"
9+
710
module Async
811
# A promise represents a value that will be available in the future.
912
# Unlike Condition, once resolved (or rejected), all future waits return immediately
@@ -77,17 +80,23 @@ def value
7780
# Wait for the promise to be resolved and return the value.
7881
# If already resolved, returns immediately. If rejected, raises the stored exception.
7982
#
83+
# @parameter timeout [Numeric | Nil] Maximum time to wait. If nil, waits indefinitely. If 0, raises immediately if not resolved.
8084
# @returns [Object] The resolved value.
8185
# @raises [Exception] The rejected or cancelled exception.
82-
def wait
86+
# @raises [Async::TimeoutError] If timeout expires before the promise is resolved.
87+
def wait(timeout: nil)
8388
@mutex.synchronize do
8489
# Increment waiting count:
8590
@waiting += 1
8691

8792
begin
8893
# Wait for resolution if not already resolved:
89-
until @resolved
90-
@condition.wait(@mutex)
94+
unless @resolved
95+
if timeout.nil?
96+
wait_indefinitely
97+
else
98+
wait_with_timeout(timeout)
99+
end
91100
end
92101

93102
# Return value or raise exception based on resolution type:
@@ -104,6 +113,39 @@ def wait
104113
end
105114
end
106115

116+
# Wait indefinitely for the promise to be resolved.
117+
private def wait_indefinitely
118+
until @resolved
119+
@condition.wait(@mutex)
120+
end
121+
end
122+
123+
# Wait for the promise to be resolved, respecting the deadline timeout.
124+
# @parameter timeout [Numeric] The timeout duration.
125+
# @raises [Async::TimeoutError] If the timeout expires before resolution.
126+
private def wait_with_timeout(timeout)
127+
# Create deadline for timeout tracking:
128+
deadline = Deadline.start(timeout)
129+
130+
# Handle immediate timeout (non-blocking):
131+
if deadline == Deadline::Zero && !@resolved
132+
raise Async::TimeoutError, "Promise wait not resolved!"
133+
end
134+
135+
# Wait with deadline tracking:
136+
until @resolved
137+
# Get remaining time for this wait iteration:
138+
remaining = deadline.remaining
139+
140+
# Check if deadline has expired before waiting:
141+
if remaining <= 0
142+
raise Async::TimeoutError, "Promise wait timed out!"
143+
end
144+
145+
@condition.wait(@mutex, remaining)
146+
end
147+
end
148+
107149
# Resolve the promise with a value.
108150
# All current and future waiters will receive this value.
109151
# Can only be called once - subsequent calls are ignored.

lib/async/task.rb

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,23 +14,13 @@
1414

1515
require_relative "node"
1616
require_relative "condition"
17+
require_relative "error"
1718
require_relative "promise"
1819
require_relative "stop"
1920

2021
Fiber.attr_accessor :async_task
2122

2223
module Async
23-
# Raised if a timeout occurs on a specific Fiber. Handled gracefully by `Task`.
24-
# @public Since *Async v1*.
25-
class TimeoutError < StandardError
26-
# Create a new timeout error.
27-
#
28-
# @parameter message [String] The error message.
29-
def initialize(message = "execution expired")
30-
super
31-
end
32-
end
33-
3424
# Represents a sequential unit of work, defined by a block, which is executed concurrently with other tasks. A task can be in one of the following states: `initialized`, `running`, `completed`, `failed`, `cancelled` or `stopped`.
3525
#
3626
# ```mermaid

test/async/promise.rb

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,130 @@
187187
expect(errors).to be(:all?){|error| error == test_error}
188188
expect(promise.waiting?).to be == false
189189
end
190+
191+
it "returns immediately when already resolved with timeout" do
192+
promise.resolve(:immediate)
193+
194+
# Should return immediately even with timeout:
195+
expect(promise.wait(timeout: 0.1)).to be == :immediate
196+
expect(promise.wait(timeout: 0)).to be == :immediate
197+
end
198+
199+
it "raises TimeoutError with timeout: 0 if not resolved" do
200+
error = nil
201+
202+
expect do
203+
promise.wait(timeout: 0)
204+
end.to raise_exception(Async::TimeoutError, message: be =~ /not resolved/)
205+
end
206+
207+
it "raises TimeoutError after timeout expires" do
208+
error = nil
209+
start_time = Time.now
210+
211+
waiter = reactor.async do
212+
begin
213+
promise.wait(timeout: 0.1)
214+
rescue Async::TimeoutError => error
215+
end
216+
end
217+
218+
# Waiter should be waiting:
219+
expect(promise).to be(:waiting?)
220+
221+
# Wait for timeout:
222+
waiter.wait
223+
elapsed = Time.now - start_time
224+
225+
expect(error).to be_a(Async::TimeoutError)
226+
expect(error.message).to be =~ /timed out/
227+
expect(elapsed).to be >= 0.1
228+
expect(elapsed).to be < 0.5
229+
expect(promise).not.to be(:waiting?)
230+
end
231+
232+
it "returns value before timeout expires" do
233+
result = nil
234+
235+
waiter = reactor.async do
236+
result = promise.wait(timeout: 1.0)
237+
end
238+
239+
# Waiter should be waiting:
240+
expect(promise).to be(:waiting?)
241+
242+
# Resolve quickly (before timeout):
243+
reactor.sleep(0.05)
244+
promise.resolve(:quick_result)
245+
waiter.wait
246+
247+
expect(result).to be == :quick_result
248+
expect(promise).not.to be(:waiting?)
249+
end
250+
251+
it "handles timeout with multiple concurrent waiters" do
252+
results = []
253+
errors = []
254+
255+
# Start multiple waiters with timeout:
256+
waiters = 3.times.map do |i|
257+
reactor.async do
258+
begin
259+
results << promise.wait(timeout: 0.1)
260+
rescue Async::TimeoutError => error
261+
errors << error
262+
end
263+
end
264+
end
265+
266+
# All should be waiting:
267+
expect(promise).to be(:waiting?)
268+
269+
# Wait for all to timeout:
270+
waiters.each(&:wait)
271+
272+
expect(results).to be(:empty?)
273+
expect(errors.size).to be == 3
274+
expect(errors).to be(:all?){|error| error.is_a?(Async::TimeoutError)}
275+
expect(promise).not.to be(:waiting?)
276+
end
277+
278+
it "handles timeout where some waiters succeed and others timeout" do
279+
results = []
280+
errors = []
281+
282+
# Start waiters with different timeouts:
283+
waiters = [
284+
reactor.async do
285+
begin
286+
results << promise.wait(timeout: 0.05)
287+
rescue Async::TimeoutError => error
288+
errors << error
289+
end
290+
end,
291+
reactor.async do
292+
begin
293+
results << promise.wait(timeout: 0.2)
294+
rescue Async::TimeoutError => error
295+
errors << error
296+
end
297+
end
298+
]
299+
300+
# All should be waiting:
301+
expect(promise).to be(:waiting?)
302+
303+
# Resolve after first timeout but before second:
304+
sleep(0.1)
305+
promise.resolve(:partial_success)
306+
waiters.each(&:wait)
307+
308+
# First waiter should have timed out, second should have succeeded:
309+
expect(errors.size).to be == 1
310+
expect(errors.first).to be_a(Async::TimeoutError)
311+
expect(results).to be == [:partial_success]
312+
expect(promise).not.to be(:waiting?)
313+
end
190314
end
191315

192316
with "warning suppression" do

0 commit comments

Comments
 (0)