diff --git a/NEWS.md b/NEWS.md index ff50e161fd4e5..1813cced9d100 100644 --- a/NEWS.md +++ b/NEWS.md @@ -124,6 +124,8 @@ New library features along with the type of the entries in a vector of new `DirEntry` objects to provide more efficient `isfile` etc. checks. `readdir(::DirEntry)` accepts a `DirEntry` as input and, like `readdir(::AbstractString)`, returns a `Vector{String}` of names. `DirEntry` is exported from `Base` ([#55358]). +* Calls to `wait` on one-shot `Timer`s that have already triggered no longer throw `EOFError`. Previously + only the first `wait` returned and subsequent `wait` calls would throw ([#62539]) * When the display height is too small to show any array entries, the `text/plain` array display (used e.g. by the REPL and when logging values with `@info` etc.) now shows as many entries as fit on a single line, truncated to the display width, instead of showing no data at all ([#62543]). diff --git a/base/asyncevent.jl b/base/asyncevent.jl index fe4b8c261231a..04ee3aa954bf6 100644 --- a/base/asyncevent.jl +++ b/base/asyncevent.jl @@ -74,8 +74,10 @@ Create a timer that wakes up tasks waiting for it (by calling [`wait`](@ref) on Waiting tasks are woken after an initial delay of at least `delay` seconds, and then repeating after at least `interval` seconds again elapse. If `interval` is equal to `0`, the timer is only triggered -once. When the timer is closed (by [`close`](@ref)) waiting tasks are woken with an error. Use -[`isopen`](@ref) to check whether a timer is still active. An inactive timer will not fire. +once. When closing (by [`close`](@ref)) either a repeating timer or a one-shot timer before it has +triggered, waiting tasks are woken with an error. After a one-shot timer triggers, all subsequent calls +to [`wait`](@ref) return immediately, even if it is closed. +Use [`isopen`](@ref) to check whether a timer is still active. An inactive timer will not fire. Use `t.timeout` and `t.interval` to read the setup conditions of a `Timer` `t`. ```julia-repl @@ -105,6 +107,10 @@ false !!! compat "Julia 1.12" The `timeout` and `interval` readable properties were added in Julia 1.12. +!!! compat "Julia 1.14" + Prior to Julia 1.14, only the first call to `wait` on a triggered one-shot timer returned, + and subsequent calls threw an `EOFError`. + """ mutable struct Timer @atomic handle::Ptr{Cvoid} @@ -168,6 +174,9 @@ function _trywait(t::Union{Timer, AsyncCondition}) t isa Timer || Core.Intrinsics.atomic_fence(:acquire_release, :system) else if !isopen(t) + # the :acquire read of isopen pairs with the :release store in uv_timercb, which + # sets `set` beforehand: a waiter observing the trigger-initiated close of a + # one-shot timer cannot miss the trigger on this recheck set = t.set if !set close(t) # wait for the close to complete @@ -195,7 +204,10 @@ function _trywait(t::Union{Timer, AsyncCondition}) end iolock_end() end - @atomic :monotonic t.set = false # if there are multiple waiters, an unspecified number may short-circuit past here + if !(t isa Timer && iszero(t.interval_ms)) + # if there are multiple waiters, an unspecified number may short-circuit past here + @atomic :monotonic t.set = false + end return set end @@ -295,6 +307,8 @@ function uv_timercb(handle::Ptr{Cvoid}) t = @handle_as handle Timer lock(t.cond) try + # this store must stay ordered before the :release store of isopen below, so that + # a waiter observing the close in _trywait is guaranteed to also observe `set` @atomic :monotonic t.set = true if ccall(:uv_timer_get_repeat, UInt64, (Ptr{Cvoid},), t) == 0 # timer is stopped now diff --git a/test/channels.jl b/test/channels.jl index a04a0f8d70569..098ffb7b75fd7 100644 --- a/test/channels.jl +++ b/test/channels.jl @@ -595,6 +595,53 @@ let a = [] @test timedwait(() -> a == [1], 10) === :ok end +@testset "wait after closing a one-shot Timer (#34366)" begin + t = Timer(600) + close(t) # test assumes that thread won't get preempted for 10 minutes... + @test_throws EOFError wait(t) + + t = Timer(0) + @test wait(t) === nothing + @test wait(t) === nothing + close(t) + @test wait(t) === nothing + + waiters = [Threads.@spawn wait(t) for _ in 1:8] + @test all(w -> fetch(w) === nothing, waiters) + + for i in 1:100 + t = Timer(isodd(i) ? 0 : 0.001) + racers = [Threads.@spawn begin + for _ in 1:rand(0:8) + yield() + end + try + wait(t) + wait(t) + true + catch e + e isa EOFError || rethrow() + false + end + end for _ in 1:4] + for _ in 1:rand(0:8) + yield() + end + close(t) + outcomes = map(fetch, racers) + @test all(outcomes) || !any(outcomes) + end + + t = Timer(0, interval=600) + @test wait(t) === nothing + waiter = @task wait(t) + yield(waiter) + @test timedwait(() -> !isempty(t.cond.waitq) && first(t.cond.waitq).task === waiter, 10) === :ok + close(t) + @test_throws TaskFailedException wait(waiter) + @test waiter.result isa EOFError +end + # make sure that we don't accidentally create a one-shot timer let t = Timer(Returns(nothing), 10, interval=0.00001) diff --git a/test/threads_exec.jl b/test/threads_exec.jl index f4676fdb2ada7..d9fbb33fc5e8b 100644 --- a/test/threads_exec.jl +++ b/test/threads_exec.jl @@ -629,7 +629,7 @@ for period in (0.06, Dates.Millisecond(60)) close(async) @test_throws EOFError wait(async) @test !isopen(async) - @test_throws EOFError wait(t) + @test wait(t) === nothing @test_throws EOFError wait(async) end end