Skip to content

Commit 9045a98

Browse files
committed
Merge branch 'kb-impl/StanBlocks'
KB-Committer-Id: StanBlocks
2 parents 01ffa6c + 38fb9fb commit 9045a98

2 files changed

Lines changed: 82 additions & 16 deletions

File tree

‎src/slic_stan/build.jl‎

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,18 @@ _process_exists(pid) = ccall(:uv_kill, Cint, (Cint, Cint), pid, 0) != Base.UV_ES
4343
# The record of a lock whose holder died on this host, or `nothing`. A remote
4444
# host, a reused live pid, or a foreign format leaves the lock to its holder.
4545
function _dead_build_lock_record(path, dead_age)
46+
# Read through libuv, as stdlib Pidfile does: on Windows it opens with
47+
# delete sharing, so this read cannot block a reclaimer's rename. An
48+
# IOStream `open` there denies it, failing that rename with EBUSY.
4649
record, age = try
47-
open(io -> (read(io, String), time() - mtime(io)), path)
50+
file = Base.Filesystem.open(path, Base.Filesystem.JL_O_RDONLY)
51+
try
52+
(read(file, String), time() - mtime(file))
53+
finally
54+
close(file)
55+
end
4856
catch err
49-
err isa SystemError && err.errnum == Libc.ENOENT && return nothing
57+
err isa Base.IOError && err.code == Base.UV_ENOENT && return nothing
5058
rethrow()
5159
end
5260
age > dead_age || return nothing
@@ -69,30 +77,34 @@ function _reclaim_dead_build_lock(path, dead_age)
6977
try
7078
record = _dead_build_lock_record(path, dead_age)
7179
record === nothing && return false
72-
_remove_build_lock(path)
80+
_remove_build_lock(path) || return false
7381
@warn "StanBlocks reclaimed a build lock whose holder no longer exists" path record
7482
true
7583
finally
7684
close(guard)
7785
end
7886
end
7987

80-
# Only a non-cooperating process can remove the file under the guard; its
81-
# absence is then already the outcome a reclaim needs.
88+
# Whether the lock file is gone. Only a non-cooperating process can remove it
89+
# under the guard; its absence is then already the outcome a reclaim needs.
8290
function _remove_build_lock(path)
8391
# Windows reserves a deleted name while any handle is open; move it aside.
8492
if Sys.iswindows()
8593
aside = string(path, '.', getpid(), '.', time_ns(), ".deleted")
8694
try
87-
_rename_build_file(path, aside)
95+
_retry_windows_rename(path, aside, Base.UV_EBUSY)
8896
catch err
89-
err isa Base.IOError && err.code == Base.UV_ENOENT && return nothing
97+
err isa Base.IOError || rethrow()
98+
err.code == Base.UV_ENOENT && return true
99+
# A reader without delete sharing (an older StanBlocks, a virus
100+
# scanner) still has the dead file open: leave it for a later round.
101+
err.code == Base.UV_EBUSY && return false
90102
rethrow()
91103
end
92104
path = aside
93105
end
94106
rm(path; force=true)
95-
nothing
107+
true
96108
end
97109

98110
"""
@@ -177,20 +189,24 @@ function _rename_build_file(src, dst)
177189
nothing
178190
end
179191

192+
# Windows readers opened without delete sharing briefly prevent a rename: of
193+
# the source with EBUSY, of a replaced destination with EACCES. Retry only that
194+
# platform's `code` for up to 1.6 s, then rethrow the last error.
195+
function _retry_windows_rename(src, dst, code)
196+
delays = Base.ExponentialBackOff(n=10, first_delay=0.01,
197+
max_delay=0.25, factor=2.0, jitter=0.0)
198+
Base.retry(_rename_build_file; delays,
199+
check=(_, err) -> Sys.iswindows() && err isa Base.IOError && err.code == code)(src, dst)
200+
end
201+
180202
function _atomic_build_write(path, value)
181203
mkpath(dirname(path))
182204
tmp, io = mktemp(dirname(path))
183205
try
184206
write(io, value)
185207
close(io)
186-
# Windows readers opened without delete sharing temporarily prevent
187-
# replacement. Retry only that platform's access-denied error; keep
188-
# the old file visible and propagate a persistent failure unchanged.
189-
delays = Base.ExponentialBackOff(n=10, first_delay=0.01,
190-
max_delay=0.25, factor=2.0, jitter=0.0)
191-
Base.retry(_rename_build_file; delays,
192-
check=(_, err) -> Sys.iswindows() && err isa Base.IOError &&
193-
err.code == Base.UV_EACCES)(tmp, path)
208+
# Keep the old file visible and propagate a persistent failure unchanged.
209+
_retry_windows_rename(tmp, path, Base.UV_EACCES)
194210
finally
195211
isopen(io) && close(io)
196212
ispath(tmp) && rm(tmp)

‎test/build_concurrency.jl‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,56 @@ end
203203
end
204204
end
205205

206+
@testitem "build lock: a reader of a dead lock defers its reclaim, never fails it" tags=[:slic, :regression] begin
207+
using StanBlocks
208+
using Test: TestLogger
209+
using Logging: with_logger
210+
acquire(path, logger) = with_logger(logger) do
211+
Threads.@spawn StanBlocks._with_build_lock(path; dead_age=1, poll=0.25) do
212+
read(path, String)
213+
end
214+
end
215+
reclaimed(logger) = [log.kwargs[:record] for log in logger.logs
216+
if occursin("reclaimed a build lock", log.message)]
217+
own = "$(getpid()) $(gethostname())"
218+
mktempdir() do dir
219+
path = joinpath(dir, ".stanblocks-build.pid")
220+
# A holder killed between creating and writing its file leaves it
221+
# empty, dead once older than `dead_age`.
222+
write(path, "")
223+
sleep(1.5)
224+
# Waiters read the record with delete sharing, so a waiter reading it
225+
# never blocks another's reclaim, on Windows included.
226+
logger = TestLogger()
227+
file = Base.Filesystem.open(path, Base.Filesystem.JL_O_RDONLY)
228+
try
229+
@test fetch(acquire(path, logger)) == own
230+
finally
231+
close(file)
232+
end
233+
@test reclaimed(logger) == [""]
234+
235+
# A reader without delete sharing (an IOStream on Windows; an older
236+
# StanBlocks reads one) makes the reclaim's aside-rename fail with
237+
# EBUSY. The waiter keeps waiting until it can reclaim; it never fails.
238+
write(path, "")
239+
sleep(1.5)
240+
logger = TestLogger()
241+
local waiter
242+
open(path, "r") do io
243+
waiter = acquire(path, logger)
244+
if Sys.iswindows()
245+
@test timedwait(() -> istaskdone(waiter), 3) === :timed_out
246+
else
247+
@test timedwait(() -> istaskdone(waiter), 30) === :ok
248+
end
249+
end
250+
@test fetch(waiter) == own
251+
@test reclaimed(logger) == [""]
252+
@test !isfile(path)
253+
end
254+
end
255+
206256
@testitem "thread safety: shared native artifact across processes" tags=[:slic, :regression, :bridgestan] begin
207257
using StanBlocks
208258
worker = joinpath(@__DIR__, "fixtures", "native_build_worker.jl")

0 commit comments

Comments
 (0)