Skip to content

wait-after-close no longer errors on one-shot Timers - #62539

Open
adienes wants to merge 5 commits into
JuliaLang:masterfrom
adienes:fix/timer-wait-close
Open

wait-after-close no longer errors on one-shot Timers#62539
adienes wants to merge 5 commits into
JuliaLang:masterfrom
adienes:fix/timer-wait-close

Conversation

@adienes

@adienes adienes commented Jul 27, 2026

Copy link
Copy Markdown
Member

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 wait calls should fail. but close-after-trigger is a no-op, any wait calls have already successfully waited long enough for the trigger, so they return.

codex 5.6 approved the design choice

@adienes adienes added io Involving the I/O subsystem: libuv, read, write, etc. triage This should be discussed on a triage call minor change Marginal behavior change acceptable for a minor release labels Jul 27, 2026
Comment thread NEWS.md Outdated
Co-authored-by: Jameson Nash <vtjnash@gmail.com>

@vtjnash vtjnash left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_timercb stores set = true before its :release store of isopen = false, so a waiter whose :acquire read of isopen observes the trigger-initiated close is
    guaranteed to see set == true at the recheck in _trywait, and cannot fall through to the return false path;
  • the iolock serializes the timer callback against jl_close_uv, so once a user close wins the race the callback can never run afterward, and set stays 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
 ```
  1. 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.

@adienes

adienes commented Jul 28, 2026

Copy link
Copy Markdown
Member Author
  • mild clarification of docstring and other nits (human)
  • added comments where requested (AI)
  • added a test for outcome-homogeneity of concurrent waiters (AI)

@vtjnash vtjnash added the merge me PR is reviewed. Merge when all tests are passing label Jul 30, 2026
@gbaraldi

Copy link
Copy Markdown
Member

Triage thinks that closing before or after the timer has been triggered shouldn't matter and that any tasks arriving after close should error.
Multiple tasks calling wait on an already triggered timer should go through. i.e a trigger timer isn't closed

@gbaraldi gbaraldi removed merge me PR is reviewed. Merge when all tests are passing triage This should be discussed on a triage call labels Jul 30, 2026
@JeffBezanson

Copy link
Copy Markdown
Member

Multiple tasks calling wait on an already triggered timer should go through. i.e a trigger timer isn't closed

Do you mean making close a no-op on triggered timers?

@adienes

adienes commented Jul 30, 2026

Copy link
Copy Markdown
Member Author

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).

@adienes

adienes commented Jul 30, 2026

Copy link
Copy Markdown
Member Author

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 file

this 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: fetch on Channel does continue to work after the Channel is closeed. so with that analogy, making the Timer is like making a Channel that will push an item after the delay, and wait is like fetch. so I believe that analogy still supports the original implementation here.

@adienes

adienes commented Aug 2, 2026

Copy link
Copy Markdown
Member Author

just to make the channel analogy explicit, suppose we did implement a Timer via a Channel like so:

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)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

io Involving the I/O subsystem: libuv, read, write, etc. minor change Marginal behavior change acceptable for a minor release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

behavior of wait on closed and/or triggered Timers

4 participants