From d112edabe2461baf3cb6656c92c64dd32d4b02e6 Mon Sep 17 00:00:00 2001 From: adienes Date: Mon, 27 Jul 2026 12:24:28 -0400 Subject: [PATCH 1/4] wait-after-close no longer errors on one-shot Timers --- NEWS.md | 2 ++ base/asyncevent.jl | 12 +++++++++--- test/channels.jl | 12 ++++++++++++ test/threads_exec.jl | 2 +- 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/NEWS.md b/NEWS.md index 12c9b3a781062..bbc2bc3163e99 100644 --- a/NEWS.md +++ b/NEWS.md @@ -122,6 +122,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 ([#62538]) Standard library changes ------------------------ diff --git a/base/asyncevent.jl b/base/asyncevent.jl index fe4b8c261231a..65e7045c696c8 100644 --- a/base/asyncevent.jl +++ b/base/asyncevent.jl @@ -74,8 +74,9 @@ 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 the timer is closed (by [`close`](@ref)) 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 +106,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} @@ -195,7 +200,8 @@ 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 there are multiple waiters, an unspecified number may short-circuit past here + !(t isa Timer && iszero(t.interval_ms)) && @atomic :monotonic t.set = false return set end diff --git a/test/channels.jl b/test/channels.jl index 82de857dd4e05..44e6a4e7768cb 100644 --- a/test/channels.jl +++ b/test/channels.jl @@ -595,6 +595,18 @@ 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 +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 From 944b4a489f2bb61a10dbcb8723e141305f850cf0 Mon Sep 17 00:00:00 2001 From: Andy Dienes <51664769+adienes@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:14:18 -0400 Subject: [PATCH 2/4] Update NEWS.md Co-authored-by: Jameson Nash --- NEWS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NEWS.md b/NEWS.md index bbc2bc3163e99..18c9580ac7ab8 100644 --- a/NEWS.md +++ b/NEWS.md @@ -123,7 +123,7 @@ New library features 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 ([#62538]) + only the first `wait` returned and subsequent `wait` calls would throw ([#62539]) Standard library changes ------------------------ From 7ee39bb23482571cfcbe04b05df3be9fb8481707 Mon Sep 17 00:00:00 2001 From: adienes Date: Tue, 28 Jul 2026 12:38:12 -0400 Subject: [PATCH 3/4] apply code review --- base/asyncevent.jl | 16 ++++++++++++---- test/channels.jl | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/base/asyncevent.jl b/base/asyncevent.jl index 65e7045c696c8..04ee3aa954bf6 100644 --- a/base/asyncevent.jl +++ b/base/asyncevent.jl @@ -74,8 +74,9 @@ 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. After a -one-shot timer triggers, all subsequent calls to [`wait`](@ref) return immediately, even if it is closed. +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`. @@ -173,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 @@ -200,8 +204,10 @@ function _trywait(t::Union{Timer, AsyncCondition}) end iolock_end() end - # if there are multiple waiters, an unspecified number may short-circuit past here - !(t isa Timer && iszero(t.interval_ms)) && @atomic :monotonic t.set = false + 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 @@ -301,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 44e6a4e7768cb..5c0489ae73687 100644 --- a/test/channels.jl +++ b/test/channels.jl @@ -605,6 +605,41 @@ end @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(() -> waiter.queue === t.cond.waitq, 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 From 9a1483a82331e8f011be4008c1db40a9a3165f31 Mon Sep 17 00:00:00 2001 From: adienes Date: Sun, 2 Aug 2026 10:44:38 -0400 Subject: [PATCH 4/4] test: adapt Timer waitq probe to split wait queues Co-Authored-By: Claude Fable 5 --- test/channels.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/channels.jl b/test/channels.jl index 1102af6123b5d..098ffb7b75fd7 100644 --- a/test/channels.jl +++ b/test/channels.jl @@ -636,7 +636,7 @@ end @test wait(t) === nothing waiter = @task wait(t) yield(waiter) - @test timedwait(() -> waiter.queue === t.cond.waitq, 10) === :ok + @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