Skip to content

Commit ddcbdb9

Browse files
authored
Backport 49868 (#20)
* Lock the atexit_hooks during execution of the hooks on shutdown. Fixes JuliaLang#49841. Follow-up to JuliaLang#49774. This PR makes two changes: 1. It locks `atexit_hooks` while iterating the hooks during execution of `_atexit()` at shutdown. - This prevents any data races if another Task is registering a new atexit hook while the hooks are being evaluated. 2. It defines semantics for what happens if another Task attempts to register another atexit hook _after all the hooks have finished_, and we've proceeded on to the rest of shutdown. - Previously, those atexit hooks would be _ignored,_ which violates the user's expectations and violates the "atexit" contract. - Now, the attempt to register the atexit hook will **throw an exception,** which ensures that we never break our promise, since the user was never able to register the atexit hook at all. - This does mean that users will need to handle the thrown exception and likely do now whatever tear down they were hoping to delay until exit. * Fix merge conflict
1 parent 07765da commit ddcbdb9

File tree

2 files changed

+129
-5
lines changed

2 files changed

+129
-5
lines changed

base/initdefs.jl

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,7 @@ const atexit_hooks = Callable[
351351
() -> Filesystem.temp_cleanup_purge(force=true)
352352
]
353353
const _atexit_hooks_lock = ReentrantLock()
354+
global _atexit_hooks_finished::Bool = false
354355

355356
"""
356357
atexit(f)
@@ -363,12 +364,40 @@ exit code `n` (instead of the original exit code). If more than one exit hook
363364
calls `exit(n)`, then Julia will exit with the exit code corresponding to the
364365
last called exit hook that calls `exit(n)`. (Because exit hooks are called in
365366
LIFO order, "last called" is equivalent to "first registered".)
367+
368+
Note: Once all exit hooks have been called, no more exit hooks can be registered,
369+
and any call to `atexit(f)` after all hooks have completed will throw an exception.
370+
This situation may occur if you are registering exit hooks from background Tasks that
371+
may still be executing concurrently during shutdown.
366372
"""
367-
atexit(f::Function) = Base.@lock _atexit_hooks_lock (pushfirst!(atexit_hooks, f); nothing)
373+
function atexit(f::Function)
374+
Base.@lock _atexit_hooks_lock begin
375+
_atexit_hooks_finished && error("cannot register new atexit hook; already exiting.")
376+
pushfirst!(atexit_hooks, f)
377+
return nothing
378+
end
379+
end
368380

369381
function _atexit()
370-
while !isempty(atexit_hooks)
371-
f = popfirst!(atexit_hooks)
382+
# Don't hold the lock around the iteration, just in case any other thread executing in
383+
# parallel tries to register a new atexit hook while this is running. We don't want to
384+
# block that thread from proceeding, and we can allow it to register its hook which we
385+
# will immediately run here.
386+
while true
387+
local f
388+
Base.@lock _atexit_hooks_lock begin
389+
# If this is the last iteration, atomically disable atexit hooks to prevent
390+
# someone from registering a hook that will never be run.
391+
# (We do this inside the loop, so that it is atomic: no one can have registered
392+
# a hook that never gets run, and we run all the hooks we know about until
393+
# the vector is empty.)
394+
if isempty(atexit_hooks)
395+
global _atexit_hooks_finished = true
396+
break
397+
end
398+
399+
f = popfirst!(atexit_hooks)
400+
end
372401
try
373402
f()
374403
catch ex

test/atexit.jl

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,19 @@ using Test
44

55
@testset "atexit.jl" begin
66
function _atexit_tests_gen_cmd_eval(expr::String)
7+
# We run the atexit tests with 2 threads, for the parallelism tests at the end.
78
cmd_eval = ```
8-
$(Base.julia_cmd()) -e $(expr)
9+
$(Base.julia_cmd()) -t2 -e $(expr)
910
```
1011
return cmd_eval
1112
end
1213
function _atexit_tests_gen_cmd_script(temp_dir::String, expr::String)
1314
script, io = mktemp(temp_dir)
1415
println(io, expr)
1516
close(io)
17+
# We run the atexit tests with 2 threads, for the parallelism tests at the end.
1618
cmd_script = ```
17-
$(Base.julia_cmd()) $(script)
19+
$(Base.julia_cmd()) -t2 $(script)
1820
```
1921
return cmd_script
2022
end
@@ -150,5 +152,98 @@ using Test
150152
@test p_script.exitcode == expected_exit_code
151153
end
152154
end
155+
@testset "test calling atexit() in parallel with running atexit hooks." begin
156+
# These tests cover 3 parallelism cases, as described by the following comments.
157+
julia_expr_list = Dict(
158+
# ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
159+
# 1. registering a hook from inside a hook
160+
"""
161+
atexit() do
162+
atexit() do
163+
exit(11)
164+
end
165+
end
166+
# This will attempt to exit 0, but the execution of the atexit hook will
167+
# register another hook, which will exit 11.
168+
exit(0)
169+
""" => 11,
170+
# ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
171+
# 2. registering a hook from another thread while hooks are running
172+
"""
173+
c = Channel()
174+
# This hook must execute _last_. (Execution is LIFO.)
175+
atexit() do
176+
put!(c, nothing)
177+
put!(c, nothing)
178+
end
179+
atexit() do
180+
# This will run in a concurrent task, testing that we can register atexit
181+
# hooks from another task while running atexit hooks.
182+
Threads.@spawn begin
183+
Core.println("INSIDE")
184+
take!(c) # block on c
185+
Core.println("go")
186+
atexit() do
187+
Core.println("exit11")
188+
exit(11)
189+
end
190+
take!(c) # keep the _atexit() loop alive until we've added another item.
191+
Core.println("done")
192+
end
193+
end
194+
exit(0)
195+
""" => 11,
196+
# ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
197+
# 3. attempting to register a hook after all hooks have finished (disallowed)
198+
"""
199+
const atexit_has_finished = Threads.Atomic{Bool}(false)
200+
atexit() do
201+
Threads.@spawn begin
202+
# Block until the atexit hooks have all finished. We use a manual "spin
203+
# lock" because task switch is disallowed inside the finalizer, below.
204+
while !atexit_has_finished[] end
205+
Core.println("done")
206+
try
207+
# By the time this runs, all the atexit hooks will be done.
208+
# So this will throw.
209+
atexit() do
210+
exit(11)
211+
end
212+
catch
213+
# Meaning we _actually_ exit 22.
214+
exit(22)
215+
end
216+
end
217+
end
218+
# Finalizers run after the atexit hooks, so this blocks exit until the spawned
219+
# task above gets a chance to run.
220+
x = []
221+
finalizer(x) do x
222+
Core.println("FINALIZER")
223+
# Allow the spawned task to finish
224+
atexit_has_finished[] = true
225+
Core.println("ready")
226+
# Then spin forever to prevent exit.
227+
while atexit_has_finished[] end
228+
Core.println("exiting")
229+
end
230+
exit(0)
231+
""" => 22,
232+
# ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
233+
)
234+
for julia_expr in keys(julia_expr_list)
235+
cmd_eval = _atexit_tests_gen_cmd_eval(julia_expr)
236+
cmd_script = _atexit_tests_gen_cmd_script(atexit_temp_dir, julia_expr)
237+
expected_exit_code = julia_expr_list[julia_expr]
238+
@test_throws(ProcessFailedException, run(cmd_eval))
239+
@test_throws(ProcessFailedException, run(cmd_script))
240+
p_eval = run(cmd_eval; wait = false)
241+
p_script = run(cmd_script; wait = false)
242+
wait(p_eval)
243+
wait(p_script)
244+
@test p_eval.exitcode == expected_exit_code
245+
@test p_script.exitcode == expected_exit_code
246+
end
247+
end
153248
rm(atexit_temp_dir; force = true, recursive = true)
154249
end

0 commit comments

Comments
 (0)