Skip to content

Commit 3e85a2e

Browse files
Add Async::Deadline for managing compound timeouts.
1 parent f04c2c0 commit 3e85a2e

File tree

4 files changed

+271
-0
lines changed

4 files changed

+271
-0
lines changed

lib/async/deadline.rb

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2025, by Samuel Williams.
5+
6+
require_relative "clock"
7+
8+
module Async
9+
# Manages a countdown timer for timeout operations.
10+
# Provides precise deadline tracking for compound operations to prevent timeout accumulation where multiple operations could exceed user timeouts.
11+
# @public Since *Async v2.31*.
12+
class Deadline
13+
# Singleton module for immediate timeouts (zero or negative).
14+
# Avoids object allocation for performance.
15+
module Zero
16+
def self.expired?
17+
true
18+
end
19+
20+
def self.remaining
21+
0
22+
end
23+
end
24+
25+
# Create a deadline for the given timeout.
26+
# @parameter timeout [Numeric | Nil] The timeout duration, or nil for no timeout.
27+
# @returns [Deadline | Nil] A deadline instance, Zero singleton, or nil.
28+
def self.start(timeout)
29+
if timeout.nil?
30+
nil
31+
elsif timeout <= 0
32+
Zero
33+
else
34+
self.new(timeout)
35+
end
36+
end
37+
38+
# Create a new deadline with the specified remaining time.
39+
# @parameter remaining [Numeric] The initial remaining time.
40+
def initialize(remaining)
41+
@remaining = remaining
42+
@start = Clock.now
43+
end
44+
45+
# Get the remaining time, updating internal state.
46+
# Each call to this method advances the internal clock and reduces
47+
# the remaining time by the elapsed duration since the last call.
48+
# @returns [Numeric] The remaining time (may be negative if expired).
49+
def remaining
50+
now = Clock.now
51+
delta = now - @start
52+
@start = now
53+
54+
@remaining -= delta
55+
56+
return @remaining
57+
end
58+
59+
# Check if the deadline has expired.
60+
# @returns [Boolean] True if no time remains.
61+
def expired?
62+
self.remaining <= 0
63+
end
64+
end
65+
end

releases.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Releases
22

3+
## Unreleased
4+
5+
- Introduce `Async::Deadline` for precise timeout management in compound operations.
6+
37
## v2.30.0
48

59
- Add timeout support to `Async::Queue#dequeue` and `Async::Queue#pop` methods.

test/async/clock.rb

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,4 +76,81 @@
7676
expect(clock.total).to be > 0.0
7777
end
7878
end
79+
80+
with "monotonicity" do
81+
it "produces monotonic timestamps" do
82+
first = Async::Clock.now
83+
second = Async::Clock.now
84+
third = Async::Clock.now
85+
86+
expect(second).to be >= first
87+
expect(third).to be >= second
88+
end
89+
90+
it "measures positive durations" do
91+
duration = Async::Clock.measure do
92+
# Even minimal operations should have non-negative duration
93+
end
94+
95+
expect(duration).to be >= 0
96+
end
97+
end
98+
99+
with "edge cases" do
100+
it "handles multiple start/stop cycles" do
101+
3.times do
102+
clock.start!
103+
# Calling start! again should not change the start time
104+
original_start = clock.instance_variable_get(:@started)
105+
clock.start!
106+
expect(clock.instance_variable_get(:@started)).to be == original_start
107+
clock.stop!
108+
end
109+
110+
expect(clock.total).to be >= 0
111+
end
112+
113+
it "handles stop without start" do
114+
result = clock.stop!
115+
expect(result).to be == 0
116+
expect(clock.total).to be == 0
117+
end
118+
119+
it "handles multiple stops" do
120+
clock.start!
121+
first_stop = clock.stop!
122+
second_stop = clock.stop!
123+
124+
expect(first_stop).to be == second_stop
125+
expect(clock.total).to be == first_stop
126+
end
127+
128+
it "preserves total during start/stop cycles" do
129+
# First cycle
130+
clock.start!
131+
sleep(0.001)
132+
first_total = clock.stop!
133+
134+
# Second cycle
135+
clock.start!
136+
sleep(0.001)
137+
second_total = clock.stop!
138+
139+
expect(second_total).to be > first_total
140+
expect(clock.total).to be == second_total
141+
end
142+
143+
it "includes running time in total" do
144+
base_total = clock.total
145+
expect(base_total).to be == 0
146+
147+
clock.start!
148+
sleep(0.001)
149+
running_total = clock.total
150+
151+
expect(running_total).to be > base_total
152+
expect(clock.instance_variable_get(:@started)).not.to be_nil
153+
end
154+
end
155+
79156
end

test/async/deadline.rb

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2018-2025, by Samuel Williams.
5+
6+
require "async/deadline"
7+
8+
describe Async::Deadline do
9+
with ".start" do
10+
it "returns nil for nil timeout" do
11+
deadline = subject.start(nil)
12+
expect(deadline).to be_nil
13+
end
14+
15+
it "returns Zero module for zero timeout" do
16+
deadline = subject.start(0)
17+
expect(deadline).to be == Async::Deadline::Zero
18+
end
19+
20+
it "returns Zero module for negative timeout" do
21+
deadline = subject.start(-1)
22+
expect(deadline).to be == Async::Deadline::Zero
23+
end
24+
25+
it "returns new instance for positive timeout" do
26+
deadline = subject.start(5.0)
27+
expect(deadline).to be_a(Async::Deadline)
28+
end
29+
end
30+
31+
describe Async::Deadline::Zero do
32+
let(:zero) {subject}
33+
34+
it "is always expired" do
35+
expect(zero.expired?).to be == true
36+
end
37+
38+
it "has zero remaining time" do
39+
expect(zero.remaining).to be == 0
40+
end
41+
end
42+
43+
with "#remaining" do
44+
it "initializes with reasonable remaining time" do
45+
deadline = subject.new(5.0)
46+
expect(deadline.remaining).to be <= 5.0
47+
end
48+
49+
it "decreases remaining time as time passes" do
50+
deadline = subject.new(1.0)
51+
52+
# Get initial remaining time
53+
first_remaining = deadline.remaining
54+
expect(first_remaining).to be <= 1.0
55+
56+
# Wait a tiny bit and check again
57+
sleep(0.001)
58+
59+
second_remaining = deadline.remaining
60+
expect(second_remaining).to be < first_remaining
61+
end
62+
63+
it "can return negative remaining time when expired" do
64+
# Create a deadline with very short timeout
65+
deadline = subject.new(0.001) # 1 millisecond
66+
67+
# Wait longer than the timeout
68+
sleep(0.002)
69+
70+
remaining = deadline.remaining
71+
expect(remaining).to be < 0 # Should be negative
72+
end
73+
end
74+
75+
with "#expired?" do
76+
it "returns false for fresh deadline" do
77+
deadline = subject.new(2.0)
78+
expect(deadline.expired?).to be == false
79+
end
80+
81+
it "returns true when deadline has expired" do
82+
# Create very short deadline that will expire quickly
83+
deadline = subject.new(0.001)
84+
85+
# Wait for it to expire
86+
sleep(0.01)
87+
88+
expect(deadline.expired?).to be == true
89+
end
90+
91+
it "updates remaining time when checked" do
92+
deadline = subject.new(1.0)
93+
94+
# Get initial remaining time
95+
first_remaining = deadline.remaining
96+
97+
# Check if expired (which calls remaining internally)
98+
expired = deadline.expired?
99+
expect(expired).to be == false
100+
101+
# Get remaining time again - should be less
102+
second_remaining = deadline.remaining
103+
expect(second_remaining).to be < first_remaining
104+
end
105+
end
106+
107+
it "handles sequential operations correctly" do
108+
deadline = subject.new(1.0) # 1 second timeout
109+
110+
# First check - should have close to full time
111+
first_remaining = deadline.remaining
112+
expect(first_remaining).to be <= 1.0
113+
expect(first_remaining).to be > 0.5 # Should still have most of the time
114+
115+
# Short delay
116+
sleep(0.001)
117+
118+
# Second check - should be less
119+
second_remaining = deadline.remaining
120+
expect(second_remaining).to be < first_remaining
121+
122+
# Should still not be expired
123+
expect(deadline.expired?).to be == false
124+
end
125+
end

0 commit comments

Comments
 (0)