wait-after-close no longer errors on one-shot Timers - #62539
Conversation
Co-authored-by: Jameson Nash <vtjnash@gmail.com>
vtjnash
left a comment
There was a problem hiding this comment.
Minor nits:
- Style: !(t isa Timer && iszero(t.interval_ms)) && @atomic :monotonic t.set = false is a dense negated condition used for a side effect; a plain if block would read better.
The relocated comment ("an unspecified number may short-circuit") now only applies to the non-one-shot branch, which the if form would also make clearer. - Test coverage: the original issue's scenario is multiple concurrent waiters on one timer; the new test only exercises sequential re-waits on one task. Worth adding
something like tasks = [@Spawn wait(t) for _ in 1:8]; foreach(fetch, tasks) — I ran exactly that against the patch and it passes. A regression guard that a repeating timer's
second wait still blocks (doesn't return instantly) would also be cheap. - NEWS placement: this is a behavior change sitting under "New library features"; NEWS.md has no behavior-changes section, so it's acceptable, and the minor change + triage
labels are appropriately applied.
Fable explains why this works, only if you really push it hard to show correctness here:
The design here is right, but I want to state the invariant it actually establishes, since it's stronger than "wait-after-close doesn't throw" and worth protecting:
For a one-shot timer, success and error are now mutually exclusive outcomes across all waiters — either every wait (concurrent or subsequent) returns, or every one
throws. A close racing the trigger resolves deterministically to one of those two, at whichever of uv_timercb or jl_close_uv the event loop serializes first.
The change works because it turns set into a monotone latch (false→true, never reset) instead of a consumable token, and "all waiters agree" is exactly monotonicity. But
note the property also rests on two pre-existing ordering facts that this PR makes load-bearing:
uv_timercbstoresset = truebefore its:releasestore ofisopen = false, so a waiter whose:acquireread ofisopenobserves the trigger-initiated close is
guaranteed to seeset == trueat the recheck in_trywait, and cannot fall through to thereturn falsepath;- the iolock serializes the timer callback against
jl_close_uv, so once a userclosewins the race the callback can never run afterward, andsetstays false for
everyone.
Please add comments pinning the two ordering facts above (store order in uv_timercb, and the acquire/recheck in _trywait) — reordering those stores would silently
reintroduce mixed outcomes and no current test would notice. Relatedly, the new test is fully sequential; please add a concurrent variant asserting agreement rather than a
specific outcome, so it's timing-robust in CI:
```julia
for _ in 1:100
t = Timer(0.001)
waiters = [Threads.@spawn begin
sleep(rand() * 0.003)
try
wait(t); wait(t)
true
catch e
e isa EOFError || rethrow()
false
end
end for _ in 1:4]
sleep(rand() * 0.003)
close(t)
rs = map(fetch, waiters)
@test all(rs) || !any(rs)
end
```
- The docstring's first sentence ("When the timer is closed ... waiting tasks are woken with an error") now only applies to close-before-trigger and to repeating timers;
worth rewording so the two adjacent sentences don't read as contradicting each other.
|
|
Triage thinks that closing before or after the timer has been triggered shouldn't matter and that any tasks arriving after |
Do you mean making |
|
that's what the PR currently does; my understanding of the discussion was that wait-after-close should always error, regardless if the timer triggered yet or not. but wait-after-trigger should return, if it was not closed (PR already does that half). |
|
worth noting that triage semantics might imply that repeating timers need to change as well... see: julia> t = Timer(0; interval=60)
Timer (open, timeout: 0.0 s, interval: 60.0 s) @0x00000001123b0160
julia> sleep(0.5)
julia> close(t)
julia> wait(t)but julia> t = Timer(2; interval=60)
Timer (open, timeout: 2.0 s, interval: 60.0 s) @0x000000011223d480
julia> sleep(0.5)
julia> close(t)
julia> wait(t)
ERROR: EOFError: read end of filethis behavior is consistent with the original version of this PR, but inconsistent with the proposed change (since the first case would need to error) another point I failed to raise during the discussion: |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
just to make the channel analogy explicit, suppose we did implement a struct ChTimer
ch::Channel{Nothing}
end
ChTimer(delay) = ChTimer(Channel{Nothing}(1) do ch
sleep(delay)
put!(ch, nothing)
end)
Base.wait(t::ChTimer) = (fetch(t.ch); nothing)
Base.close(t::ChTimer) = close(t.ch)then create-close-wait errors, but create-trigger-close-wait does not julia> t = ChTimer(10); sleep(0.01); close(t); wait(t)
ERROR: InvalidStateException: Channel is closed.
julia> t = ChTimer(0); sleep(0.01); close(t); wait(t) |
closes #34366 . behavior of repeating timers is unchanged. for one-shot timers the idea is: close-before-trigger means timer will never trigger, so all
waitcalls should fail. but close-after-trigger is a no-op, anywaitcalls have already successfully waited long enough for the trigger, so they return.codex 5.6 approved the design choice