Skip to content

Commit 91d2eb0

Browse files
Add Async::Deadline for managing compound timeouts. (#421)
1 parent f04c2c0 commit 91d2eb0

4 files changed

Lines changed: 268 additions & 0 deletions

File tree

lib/async/deadline.rb

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
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+
# @namespace
9+
module Async
10+
# Represents a deadline timeout with decrementing remaining time.
11+
# Includes an efficient representation for zero (non-blocking) timeouts.
12+
# @public Since *Async v2.31*.
13+
class Deadline
14+
# Singleton module for immediate timeouts (zero or negative).
15+
# Avoids object allocation for fast path (non-blocking) timeouts.
16+
module Zero
17+
# Check if the deadline has expired.
18+
# @returns [Boolean] Always returns true since zero timeouts are immediately expired.
19+
def self.expired?
20+
true
21+
end
22+
23+
# Get the remaining time.
24+
# @returns [Integer] Always returns 0 since zero timeouts have no remaining time.
25+
def self.remaining
26+
0
27+
end
28+
end
29+
30+
# Create a deadline for the given timeout.
31+
# @parameter timeout [Numeric | Nil] The timeout duration, or nil for no timeout.
32+
# @returns [Deadline | Nil] A deadline instance, Zero singleton, or nil.
33+
def self.start(timeout)
34+
if timeout.nil?
35+
nil
36+
elsif timeout <= 0
37+
Zero
38+
else
39+
self.new(timeout)
40+
end
41+
end
42+
43+
# Create a new deadline with the specified remaining time.
44+
# @parameter remaining [Numeric] The initial remaining time.
45+
def initialize(remaining)
46+
@remaining = remaining
47+
@start = Clock.now
48+
end
49+
50+
# Get the remaining time, updating internal state.
51+
# Each call to this method advances the internal clock and reduces
52+
# the remaining time by the elapsed duration since the last call.
53+
# @returns [Numeric] The remaining time (may be negative if expired).
54+
def remaining
55+
now = Clock.now
56+
delta = now - @start
57+
@start = now
58+
59+
@remaining -= delta
60+
61+
return @remaining
62+
end
63+
64+
# Check if the deadline has expired.
65+
# @returns [Boolean] True if no time remains.
66+
def expired?
67+
self.remaining <= 0
68+
end
69+
end
70+
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: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,41 @@
3131
expect(clock.total).to be_within(2 * Sus::Fixtures::Time::QUANTUM).of(0.02)
3232
end
3333

34+
with "#start" do
35+
it "handles multiple start/stop cycles" do
36+
3.times do
37+
clock.start!
38+
# Calling start! again should be idempotent - no time should be added
39+
first_total = clock.total
40+
clock.start!
41+
second_total = clock.total
42+
43+
# The total should not jump significantly just from calling start! again
44+
expect(second_total - first_total).to be < 0.001
45+
clock.stop!
46+
end
47+
48+
expect(clock.total).to be >= 0
49+
end
50+
end
51+
52+
with "#stop" do
53+
it "handles stop without start" do
54+
result = clock.stop!
55+
expect(result).to be == 0
56+
expect(clock.total).to be == 0
57+
end
58+
59+
it "handles multiple stops" do
60+
clock.start!
61+
first_stop = clock.stop!
62+
second_stop = clock.stop!
63+
64+
expect(first_stop).to be == second_stop
65+
expect(clock.total).to be == first_stop
66+
end
67+
end
68+
3469
with "#total" do
3570
with "initial duration" do
3671
let(:clock) {subject.new(1.5)}
@@ -48,6 +83,21 @@
4883
sleep(0.0001)
4984
expect(clock.total).to be >= total
5085
end
86+
87+
it "preserves total during start/stop cycles" do
88+
# First cycle
89+
clock.start!
90+
sleep(0.001)
91+
first_total = clock.stop!
92+
93+
# Second cycle
94+
clock.start!
95+
sleep(0.001)
96+
second_total = clock.stop!
97+
98+
expect(second_total).to be > first_total
99+
expect(clock.total).to be == second_total
100+
end
51101
end
52102

53103
with ".start" do
@@ -76,4 +126,23 @@
76126
expect(clock.total).to be > 0.0
77127
end
78128
end
129+
130+
with ".now" do
131+
it "produces monotonic timestamps" do
132+
first = Async::Clock.now
133+
second = Async::Clock.now
134+
third = Async::Clock.now
135+
136+
expect(second).to be >= first
137+
expect(third).to be >= second
138+
end
139+
140+
it "measures positive durations" do
141+
duration = Async::Clock.measure do
142+
# Even minimal operations should have non-negative duration
143+
end
144+
145+
expect(duration).to be >= 0
146+
end
147+
end
79148
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)