ruby-concurrency/concurrent-ruby · error · ArgumentError
timeout must 0.0 or more
Error message
timeout must 0.0 or more
What it means
A selector's AfterClause arms a deadline at current monotonic time plus the given seconds. A negative delay would make the clause fire in the past, which the constructor rejects (the message 'timeout must 0.0 or more' is the library's own wording, typo included).
Source
Thrown at lib/concurrent-ruby-edge/concurrent/channel/selector/after_clause.rb:11
require 'concurrent/maybe'
require 'concurrent/utility/monotonic_time'
module Concurrent
class Channel
class Selector
class AfterClause
def initialize(seconds, block)
raise ArgumentError.new('timeout must 0.0 or more') if seconds.to_f < 0.0
@end = Concurrent.monotonic_time + seconds.to_f
@block = block
end
def execute
if Concurrent.monotonic_time > @end
result = @block ? @block.call : nil
Concurrent::Maybe.just(result)
else
Concurrent::Maybe.nothing
end
end
end
end
end
end
View on GitHub (pinned to 0b88d5ff75)
Solutions
- Clamp the value before registering: sel.after([remaining, 0.0].max).
- Treat negative remaining as 'already timed out' and run the timeout path immediately (or register sel.default) instead of calling after.
- Use nil for 'no timeout' rather than a negative sentinel, and check for it explicitly.
Example fix
# before
sel.after(deadline - Concurrent.monotonic_time) # negative once deadline passed
# after
remaining = [deadline - Concurrent.monotonic_time, 0.0].max
sel.after(remaining) { handle_timeout } Defensive patterns
Strategy: validation
Validate before calling
delay = Float(seconds)
sel.after([delay, 0.0].max) { handle_timeout } unless delay.nan? Try / catch
begin
sel.after(delay) { handle_timeout }
rescue ArgumentError
sel.after(0.0) { handle_timeout } # already expired: fire immediately
end Prevention
- Clamp computed remaining-time values before passing them to after/timeout.
- Use nil (checked explicitly) for 'no timeout', never a negative sentinel.
- Zero is valid and means 'already expired' - useful for immediate-fire defaults.
When it happens
Trigger: sel.after(-1) or sel.timeout(-0.5) inside a Channel.select block; computed delays like deadline - Concurrent.monotonic_time that dip below zero once the deadline has already passed.
Common situations: Timeouts derived from remaining time (remaining = total - elapsed) that go negative on retries or slow iterations; config/ENV timeout values that use -1 as a 'disabled' sentinel; arithmetic on user-supplied durations.
Understand the failure class
- Timeouts: ETIMEDOUT, deadlines, and hung requests — what actually expires when a request times out.
Related errors
- invalid action
- no block given
- only one error handler allowed
- unbuffered channels cannot have a capacity
- capacity must be at least 1 for this buffer type
AI-assisted analysis of ruby-concurrency/concurrent-ruby@0b88d5ff75 (2026-08-21).
Data as JSON: /api/errors/0c916a3d75a0399d.
Report an issue: GitHub.