|
| 1 | +__precompile__() |
| 2 | +module Pidfile |
| 3 | + |
| 4 | + |
| 5 | +export mkpidlock |
| 6 | + |
| 7 | +using Base: |
| 8 | + IOError, UV_EEXIST, UV_ESRCH, |
| 9 | + Process |
| 10 | + |
| 11 | +using Base.Libc: rand |
| 12 | + |
| 13 | +using Base.Filesystem: |
| 14 | + File, open, JL_O_CREAT, JL_O_RDWR, JL_O_RDONLY, JL_O_EXCL, |
| 15 | + rename, samefile, path_separator |
| 16 | + |
| 17 | +using ..FileWatching: watch_file |
| 18 | +using Base.Sys: iswindows |
| 19 | + |
| 20 | +""" |
| 21 | + mkpidlock([f::Function], at::String, [pid::Cint, proc::Process]; kwopts...) |
| 22 | +
|
| 23 | +Create a pidfile lock for the path "at" for the current process |
| 24 | +or the process identified by pid or proc. Can take a function to execute once locked, |
| 25 | +for usage in `do` blocks, after which the lock will be automatically closed. If the lock fails |
| 26 | +and `wait` is false, then an error is thrown. |
| 27 | +
|
| 28 | +The lock will be released by either `close`, a `finalizer`, or shortly after `proc` exits. |
| 29 | +Make sure the return value is live through the end of the critical section of |
| 30 | +your program, so the `finalizer` does not reclaim it early. |
| 31 | +
|
| 32 | +Optional keyword arguments: |
| 33 | + - `mode`: file access mode (modified by the process umask). Defaults to world-readable. |
| 34 | + - `poll_interval`: Specify the maximum time to between attempts (if `watch_file` doesn't work) |
| 35 | + - `stale_age`: Delete an existing pidfile (ignoring the lock) if its mtime is older than this. |
| 36 | + The file won't be deleted until 25x longer than this if the pid in the file appears that it may be valid. |
| 37 | + By default this is disabled (`stale_age` = 0), but a typical recommended value would be about 3-5x an |
| 38 | + estimated normal completion time. |
| 39 | + - `refresh`: Keeps a lock from becoming stale by updating the mtime every interval of time that passes. |
| 40 | + By default, this is set to `stale_age/2`, which is the recommended value. |
| 41 | + - `wait`: If true, block until we get the lock, if false, raise error if lock fails. |
| 42 | +""" |
| 43 | +function mkpidlock end |
| 44 | + |
| 45 | +macro constfield(ex) esc(VERSION >= v"1.8-" ? Expr(:const, ex) : ex) end |
| 46 | + |
| 47 | +# mutable only because we want to add a finalizer |
| 48 | +mutable struct LockMonitor |
| 49 | + @constfield path::String |
| 50 | + @constfield fd::File |
| 51 | + @constfield update::Union{Nothing,Timer} |
| 52 | + |
| 53 | + global function mkpidlock(at::String, pid::Cint; stale_age::Real=0, refresh::Real=stale_age/2, kwopts...) |
| 54 | + local lock |
| 55 | + atdir, atname = splitdir(at) |
| 56 | + isempty(atdir) && (atdir = pwd()) |
| 57 | + at = realpath(atdir) * path_separator * atname |
| 58 | + fd = open_exclusive(at; stale_age=stale_age, kwopts...) |
| 59 | + update = nothing |
| 60 | + try |
| 61 | + write_pidfile(fd, pid) |
| 62 | + if refresh > 0 |
| 63 | + # N.b.: to ensure our finalizer works we are careful to capture |
| 64 | + # `fd` here instead of `lock`. |
| 65 | + update = Timer(t -> isopen(t) && touch(fd), refresh; interval=refresh) |
| 66 | + end |
| 67 | + lock = new(at, fd, update) |
| 68 | + finalizer(close, lock) |
| 69 | + catch ex |
| 70 | + rm(at) |
| 71 | + close(fd) |
| 72 | + rethrow(ex) |
| 73 | + end |
| 74 | + return lock |
| 75 | + end |
| 76 | +end |
| 77 | + |
| 78 | +mkpidlock(at::String; kwopts...) = mkpidlock(at, getpid(); kwopts...) |
| 79 | +mkpidlock(f::Function, at::String; kwopts...) = mkpidlock(f, at, getpid(); kwopts...) |
| 80 | + |
| 81 | +function mkpidlock(f::Function, at::String, pid::Cint; kwopts...) |
| 82 | + lock = mkpidlock(at, pid; kwopts...) |
| 83 | + try |
| 84 | + return f() |
| 85 | + finally |
| 86 | + close(lock) |
| 87 | + end |
| 88 | +end |
| 89 | + |
| 90 | +if VERSION >= v"1.1" # getpid(::Proc) added |
| 91 | +function mkpidlock(at::String, proc::Process; kwopts...) |
| 92 | + lock = mkpidlock(at, getpid(proc); kwopts...) |
| 93 | + closer = @async begin |
| 94 | + wait(proc) |
| 95 | + close(lock) |
| 96 | + end |
| 97 | + isdefined(Base, :errormonitor) && Base.errormonitor(closer) |
| 98 | + return lock |
| 99 | +end |
| 100 | +end |
| 101 | + |
| 102 | +""" |
| 103 | + Base.touch(::Pidfile.LockMonitor) |
| 104 | +
|
| 105 | +Update the `mtime` on the lock, to indicate it is still fresh. |
| 106 | +
|
| 107 | +See also the `refresh` keyword in the [`mkpidlock`](@ref) constructor. |
| 108 | +""" |
| 109 | +Base.touch(lock::LockMonitor) = (touch(lock.fd); lock) |
| 110 | + |
| 111 | +if hasmethod(Base, Tuple{File}) |
| 112 | + # added in Julia v1.9 |
| 113 | + const touch = Base.touch |
| 114 | +else |
| 115 | + touch(f) = Base.touch(f) |
| 116 | + function touch(f::File) |
| 117 | + now = time() |
| 118 | + Base.Filesystem.futime(f, now, now) |
| 119 | + f |
| 120 | + end |
| 121 | +end |
| 122 | + |
| 123 | +""" |
| 124 | + write_pidfile(io, pid) |
| 125 | +
|
| 126 | +Write our pidfile format to an open IO descriptor. |
| 127 | +""" |
| 128 | +function write_pidfile(io::IO, pid::Cint) |
| 129 | + print(io, "$pid $(gethostname())") |
| 130 | +end |
| 131 | + |
| 132 | +""" |
| 133 | + parse_pidfile(file::Union{IO, String}) => (pid, hostname, age) |
| 134 | +
|
| 135 | +Attempt to parse our pidfile format, |
| 136 | +replaced an element with (0, "", 0.0), respectively, for any read that failed. |
| 137 | +""" |
| 138 | +function parse_pidfile(io::IO) |
| 139 | + fields = split(read(io, String), ' ', limit = 2) |
| 140 | + pid = tryparse(Cuint, fields[1]) |
| 141 | + pid === nothing && (pid = Cuint(0)) |
| 142 | + hostname = (length(fields) == 2) ? fields[2] : "" |
| 143 | + when = mtime(io) |
| 144 | + age = time() - when |
| 145 | + return (pid, hostname, age) |
| 146 | +end |
| 147 | + |
| 148 | +function parse_pidfile(path::String) |
| 149 | + try |
| 150 | + existing = open(path, JL_O_RDONLY) |
| 151 | + try |
| 152 | + return parse_pidfile(existing) |
| 153 | + finally |
| 154 | + close(existing) |
| 155 | + end |
| 156 | + catch ex |
| 157 | + isa(ex, EOFError) || isa(ex, IOError) || rethrow(ex) |
| 158 | + return (Cuint(0), "", 0.0) |
| 159 | + end |
| 160 | +end |
| 161 | + |
| 162 | +""" |
| 163 | + isvalidpid(hostname::String, pid::Cuint) :: Bool |
| 164 | +
|
| 165 | +Attempt to conservatively estimate whether pid is a valid process id. |
| 166 | +""" |
| 167 | +function isvalidpid(hostname::AbstractString, pid::Cuint) |
| 168 | + # can't inspect remote hosts |
| 169 | + (hostname == "" || hostname == gethostname()) || return true |
| 170 | + # pid < 0 is never valid (must be a parser error or different OS), |
| 171 | + # and would have a completely different meaning when passed to kill |
| 172 | + !iswindows() && pid > typemax(Cint) && return false |
| 173 | + # (similarly for pid 0) |
| 174 | + pid == 0 && return false |
| 175 | + # see if the process id exists by querying kill without sending a signal |
| 176 | + # and checking if it returned ESRCH (no such process) |
| 177 | + return ccall(:uv_kill, Cint, (Cuint, Cint), pid, 0) != UV_ESRCH |
| 178 | +end |
| 179 | + |
| 180 | +""" |
| 181 | + stale_pidfile(path::String, stale_age::Real) :: Bool |
| 182 | +
|
| 183 | +Helper function for `open_exclusive` for deciding if a pidfile is stale. |
| 184 | +""" |
| 185 | +function stale_pidfile(path::String, stale_age::Real) |
| 186 | + pid, hostname, age = parse_pidfile(path) |
| 187 | + age < -stale_age && @warn "filesystem time skew detected" path=path |
| 188 | + if age > stale_age |
| 189 | + if (age > stale_age * 25) || !isvalidpid(hostname, pid) |
| 190 | + return true |
| 191 | + end |
| 192 | + end |
| 193 | + return false |
| 194 | +end |
| 195 | + |
| 196 | +""" |
| 197 | + tryopen_exclusive(path::String, mode::Integer = 0o444) :: Union{Void, File} |
| 198 | +
|
| 199 | +Try to create a new file for read-write advisory-exclusive access, |
| 200 | +return nothing if it already exists. |
| 201 | +""" |
| 202 | +function tryopen_exclusive(path::String, mode::Integer = 0o444) |
| 203 | + try |
| 204 | + return open(path, JL_O_RDWR | JL_O_CREAT | JL_O_EXCL, mode) |
| 205 | + catch ex |
| 206 | + (isa(ex, IOError) && ex.code == UV_EEXIST) || rethrow(ex) |
| 207 | + end |
| 208 | + return nothing |
| 209 | +end |
| 210 | + |
| 211 | +""" |
| 212 | + open_exclusive(path::String; mode, poll_interval, stale_age) :: File |
| 213 | +
|
| 214 | +Create a new a file for read-write advisory-exclusive access. |
| 215 | +If `wait` is `false` then error out if the lock files exist |
| 216 | +otherwise block until we get the lock. |
| 217 | +
|
| 218 | +For a description of the keyword arguments, see [`mkpidlock`](@ref). |
| 219 | +""" |
| 220 | +function open_exclusive(path::String; |
| 221 | + mode::Integer = 0o444 #= read-only =#, |
| 222 | + poll_interval::Real = 10 #= seconds =#, |
| 223 | + wait::Bool = true #= return on failure if false =#, |
| 224 | + stale_age::Real = 0 #= disabled =#) |
| 225 | + # fast-path: just try to open it |
| 226 | + file = tryopen_exclusive(path, mode) |
| 227 | + file === nothing || return file |
| 228 | + if !wait |
| 229 | + if file === nothing && stale_age > 0 |
| 230 | + if stale_age > 0 && stale_pidfile(path, stale_age) |
| 231 | + @warn "attempting to remove probably stale pidfile" path=path |
| 232 | + tryrmopenfile(path) |
| 233 | + end |
| 234 | + file = tryopen_exclusive(path, mode) |
| 235 | + end |
| 236 | + if file === nothing |
| 237 | + error("Failed to get pidfile lock for $(repr(path)).") |
| 238 | + else |
| 239 | + return file |
| 240 | + end |
| 241 | + end |
| 242 | + # fall-back: wait for the lock |
| 243 | + |
| 244 | + while true |
| 245 | + # start the file-watcher prior to checking for the pidfile existence |
| 246 | + t = @async try |
| 247 | + watch_file(path, poll_interval) |
| 248 | + catch ex |
| 249 | + isa(ex, IOError) || rethrow(ex) |
| 250 | + sleep(poll_interval) # if the watch failed, convert to just doing a sleep |
| 251 | + end |
| 252 | + # now try again to create it |
| 253 | + file = tryopen_exclusive(path, mode) |
| 254 | + file === nothing || return file |
| 255 | + Base.wait(t) # sleep for a bit before trying again |
| 256 | + if stale_age > 0 && stale_pidfile(path, stale_age) |
| 257 | + # if the file seems stale, try to remove it before attempting again |
| 258 | + # set stale_age to zero so we won't attempt again, even if the attempt fails |
| 259 | + stale_age -= stale_age |
| 260 | + @warn "attempting to remove probably stale pidfile" path=path |
| 261 | + tryrmopenfile(path) |
| 262 | + end |
| 263 | + end |
| 264 | +end |
| 265 | + |
| 266 | +function _rand_filename(len::Int=4) # modified from Base.Libc |
| 267 | + slug = Base.StringVector(len) |
| 268 | + chars = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" |
| 269 | + for i = 1:len |
| 270 | + slug[i] = chars[(Libc.rand() % length(chars)) + 1] |
| 271 | + end |
| 272 | + return String(slug) |
| 273 | +end |
| 274 | + |
| 275 | +function tryrmopenfile(path::String) |
| 276 | + # Deleting open file on Windows is a bit hard |
| 277 | + # if we want to reuse the name immediately after: |
| 278 | + # we need to first rename it, then delete it. |
| 279 | + if Sys.iswindows() |
| 280 | + try |
| 281 | + local rmpath |
| 282 | + rmdir, rmname = splitdir(path) |
| 283 | + while true |
| 284 | + rmpath = string(rmdir, isempty(rmdir) ? "" : path_separator, |
| 285 | + "\$", _rand_filename(), rmname, ".deleted") |
| 286 | + ispath(rmpath) || break |
| 287 | + end |
| 288 | + rename(path, rmpath) |
| 289 | + path = rmpath |
| 290 | + catch ex |
| 291 | + isa(ex, IOError) || rethrow(ex) |
| 292 | + end |
| 293 | + end |
| 294 | + return try |
| 295 | + rm(path) |
| 296 | + true |
| 297 | + catch ex |
| 298 | + isa(ex, IOError) || rethrow(ex) |
| 299 | + false |
| 300 | + end |
| 301 | +end |
| 302 | + |
| 303 | +""" |
| 304 | + close(lock::LockMonitor) |
| 305 | +
|
| 306 | +Release a pidfile lock. |
| 307 | +""" |
| 308 | +function Base.close(lock::LockMonitor) |
| 309 | + update = lock.update |
| 310 | + update === nothing || close(update) |
| 311 | + isopen(lock.fd) || return false |
| 312 | + removed = false |
| 313 | + path = lock.path |
| 314 | + pathstat = try |
| 315 | + # Windows sometimes likes to return EACCES here, |
| 316 | + # if the path is in the process of being deleted |
| 317 | + stat(path) |
| 318 | + catch ex |
| 319 | + ex isa IOError || rethrow() |
| 320 | + removed = ex |
| 321 | + nothing |
| 322 | + end |
| 323 | + if pathstat !== nothing && samefile(stat(lock.fd), pathstat) |
| 324 | + # try not to delete someone else's lock |
| 325 | + try |
| 326 | + rm(path) |
| 327 | + removed = true |
| 328 | + catch ex |
| 329 | + ex isa IOError || rethrow() |
| 330 | + removed = ex |
| 331 | + end |
| 332 | + end |
| 333 | + close(lock.fd) |
| 334 | + havelock = removed === true |
| 335 | + havelock || @warn "failed to remove pidfile on close" path=path removed=removed |
| 336 | + return havelock |
| 337 | +end |
| 338 | + |
| 339 | +end # module |
0 commit comments