diff --git a/stdlib/FileWatching/docs/src/index.md b/stdlib/FileWatching/docs/src/index.md index 3944f5d3ed1c9..6c332511f578f 100644 --- a/stdlib/FileWatching/docs/src/index.md +++ b/stdlib/FileWatching/docs/src/index.md @@ -7,3 +7,31 @@ FileWatching.watch_file FileWatching.watch_folder FileWatching.unwatch_folder ``` + +# Pidfile + +```@meta +CurrentModule = FileWatching.Pidfile +``` + +A simple utility tool for creating advisory pidfiles (lock files). + +## Primary Functions + +```@docs +mkpidlock +close(lock::LockMonitor) +``` + + +## Helper Functions + +```@docs +Pidfile.open_exclusive +Pidfile.tryopen_exclusive +Pidfile.write_pidfile +Pidfile.parse_pidfile +Pidfile.stale_pidfile +Pidfile.isvalidpid +Base.touch(::Pidfile.LockMonitor) +``` diff --git a/stdlib/FileWatching/src/FileWatching.jl b/stdlib/FileWatching/src/FileWatching.jl index fd26b62132047..04b39f1c5d067 100644 --- a/stdlib/FileWatching/src/FileWatching.jl +++ b/stdlib/FileWatching/src/FileWatching.jl @@ -16,7 +16,9 @@ export FileMonitor, FolderMonitor, PollingFileWatcher, - FDWatcher + FDWatcher, + # pidfile: + mkpidlock import Base: @handle_as, wait, close, eventloop, notify_error, IOError, _sizeof_uv_poll, _sizeof_uv_fs_poll, _sizeof_uv_fs_event, _uv_hook_close, uv_error, _UVError, @@ -862,4 +864,7 @@ function poll_file(s::AbstractString, interval_seconds::Real=5.007, timeout_s::R end end +include("pidfile.jl") +import .Pidfile: mkpidlock + end diff --git a/stdlib/FileWatching/src/pidfile.jl b/stdlib/FileWatching/src/pidfile.jl new file mode 100644 index 0000000000000..c172e1ca5be6a --- /dev/null +++ b/stdlib/FileWatching/src/pidfile.jl @@ -0,0 +1,323 @@ +module Pidfile + + +export mkpidlock + +using Base: + IOError, UV_EEXIST, UV_ESRCH, + Process + +using Base.Libc: rand + +using Base.Filesystem: + File, open, JL_O_CREAT, JL_O_RDWR, JL_O_RDONLY, JL_O_EXCL, + rename, samefile, path_separator + +using ..FileWatching: watch_file +using Base.Sys: iswindows + +""" + mkpidlock([f::Function], at::String, [pid::Cint, proc::Process]; kwopts...) + +Create a pidfile lock for the path "at" for the current process +or the process identified by pid or proc. Can take a function to execute once locked, +for usage in `do` blocks, after which the lock will be automatically closed. If the lock fails +and `wait` is false, then an error is thrown. + +The lock will be released by either `close`, a `finalizer`, or shortly after `proc` exits. +Make sure the return value is live through the end of the critical section of +your program, so the `finalizer` does not reclaim it early. + +Optional keyword arguments: + - `mode`: file access mode (modified by the process umask). Defaults to world-readable. + - `poll_interval`: Specify the maximum time to between attempts (if `watch_file` doesn't work) + - `stale_age`: Delete an existing pidfile (ignoring the lock) if its mtime is older than this. + The file won't be deleted until 25x longer than this if the pid in the file appears that it may be valid. + By default this is disabled (`stale_age` = 0), but a typical recommended value would be about 3-5x an + estimated normal completion time. + - `refresh`: Keeps a lock from becoming stale by updating the mtime every interval of time that passes. + By default, this is set to `stale_age/2`, which is the recommended value. + - `wait`: If true, block until we get the lock, if false, raise error if lock fails. +""" +function mkpidlock end + + +# mutable only because we want to add a finalizer +mutable struct LockMonitor + const path::String + const fd::File + const update::Union{Nothing,Timer} + + global function mkpidlock(at::String, pid::Cint; stale_age::Real=0, refresh::Real=stale_age/2, kwopts...) + local lock + atdir, atname = splitdir(at) + isempty(atdir) && (atdir = pwd()) + at = realpath(atdir) * path_separator * atname + fd = open_exclusive(at; stale_age=stale_age, kwopts...) + update = nothing + try + write_pidfile(fd, pid) + if refresh > 0 + # N.b.: to ensure our finalizer works we are careful to capture + # `fd` here instead of `lock`. + update = Timer(t -> isopen(t) && touch(fd), refresh; interval=refresh) + end + lock = new(at, fd, update) + finalizer(close, lock) + catch ex + rm(at) + close(fd) + rethrow(ex) + end + return lock + end +end + +mkpidlock(at::String; kwopts...) = mkpidlock(at, getpid(); kwopts...) +mkpidlock(f::Function, at::String; kwopts...) = mkpidlock(f, at, getpid(); kwopts...) + +function mkpidlock(f::Function, at::String, pid::Cint; kwopts...) + lock = mkpidlock(at, pid; kwopts...) + try + return f() + finally + close(lock) + end +end + +function mkpidlock(at::String, proc::Process; kwopts...) + lock = mkpidlock(at, getpid(proc); kwopts...) + closer = @async begin + wait(proc) + close(lock) + end + isdefined(Base, :errormonitor) && Base.errormonitor(closer) + return lock +end + +""" + Base.touch(::Pidfile.LockMonitor) + +Update the `mtime` on the lock, to indicate it is still fresh. + +See also the `refresh` keyword in the [`mkpidlock`](@ref) constructor. +""" +Base.touch(lock::LockMonitor) = (touch(lock.fd); lock) + +""" + write_pidfile(io, pid) + +Write our pidfile format to an open IO descriptor. +""" +function write_pidfile(io::IO, pid::Cint) + print(io, "$pid $(gethostname())") +end + +""" + parse_pidfile(file::Union{IO, String}) => (pid, hostname, age) + +Attempt to parse our pidfile format, +replaced an element with (0, "", 0.0), respectively, for any read that failed. +""" +function parse_pidfile(io::IO) + fields = split(read(io, String), ' ', limit = 2) + pid = tryparse(Cuint, fields[1]) + pid === nothing && (pid = Cuint(0)) + hostname = (length(fields) == 2) ? fields[2] : "" + when = mtime(io) + age = time() - when + return (pid, hostname, age) +end + +function parse_pidfile(path::String) + try + existing = open(path, JL_O_RDONLY) + try + return parse_pidfile(existing) + finally + close(existing) + end + catch ex + isa(ex, EOFError) || isa(ex, IOError) || rethrow(ex) + return (Cuint(0), "", 0.0) + end +end + +""" + isvalidpid(hostname::String, pid::Cuint) :: Bool + +Attempt to conservatively estimate whether pid is a valid process id. +""" +function isvalidpid(hostname::AbstractString, pid::Cuint) + # can't inspect remote hosts + (hostname == "" || hostname == gethostname()) || return true + # pid < 0 is never valid (must be a parser error or different OS), + # and would have a completely different meaning when passed to kill + !iswindows() && pid > typemax(Cint) && return false + # (similarly for pid 0) + pid == 0 && return false + # see if the process id exists by querying kill without sending a signal + # and checking if it returned ESRCH (no such process) + return ccall(:uv_kill, Cint, (Cuint, Cint), pid, 0) != UV_ESRCH +end + +""" + stale_pidfile(path::String, stale_age::Real) :: Bool + +Helper function for `open_exclusive` for deciding if a pidfile is stale. +""" +function stale_pidfile(path::String, stale_age::Real) + pid, hostname, age = parse_pidfile(path) + age < -stale_age && @warn "filesystem time skew detected" path=path + if age > stale_age + if (age > stale_age * 25) || !isvalidpid(hostname, pid) + return true + end + end + return false +end + +""" + tryopen_exclusive(path::String, mode::Integer = 0o444) :: Union{Void, File} + +Try to create a new file for read-write advisory-exclusive access, +return nothing if it already exists. +""" +function tryopen_exclusive(path::String, mode::Integer = 0o444) + try + return open(path, JL_O_RDWR | JL_O_CREAT | JL_O_EXCL, mode) + catch ex + (isa(ex, IOError) && ex.code == UV_EEXIST) || rethrow(ex) + end + return nothing +end + +""" + open_exclusive(path::String; mode, poll_interval, stale_age) :: File + +Create a new a file for read-write advisory-exclusive access. +If `wait` is `false` then error out if the lock files exist +otherwise block until we get the lock. + +For a description of the keyword arguments, see [`mkpidlock`](@ref). +""" +function open_exclusive(path::String; + mode::Integer = 0o444 #= read-only =#, + poll_interval::Real = 10 #= seconds =#, + wait::Bool = true #= return on failure if false =#, + stale_age::Real = 0 #= disabled =#) + # fast-path: just try to open it + file = tryopen_exclusive(path, mode) + file === nothing || return file + if !wait + if file === nothing && stale_age > 0 + if stale_age > 0 && stale_pidfile(path, stale_age) + @warn "attempting to remove probably stale pidfile" path=path + tryrmopenfile(path) + end + file = tryopen_exclusive(path, mode) + end + if file === nothing + error("Failed to get pidfile lock for $(repr(path)).") + else + return file + end + end + # fall-back: wait for the lock + + while true + # start the file-watcher prior to checking for the pidfile existence + t = @async try + watch_file(path, poll_interval) + catch ex + isa(ex, IOError) || rethrow(ex) + sleep(poll_interval) # if the watch failed, convert to just doing a sleep + end + # now try again to create it + file = tryopen_exclusive(path, mode) + file === nothing || return file + Base.wait(t) # sleep for a bit before trying again + if stale_age > 0 && stale_pidfile(path, stale_age) + # if the file seems stale, try to remove it before attempting again + # set stale_age to zero so we won't attempt again, even if the attempt fails + stale_age -= stale_age + @warn "attempting to remove probably stale pidfile" path=path + tryrmopenfile(path) + end + end +end + +function _rand_filename(len::Int=4) # modified from Base.Libc + slug = Base.StringVector(len) + chars = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" + for i = 1:len + slug[i] = chars[(Libc.rand() % length(chars)) + 1] + end + return String(slug) +end + +function tryrmopenfile(path::String) + # Deleting open file on Windows is a bit hard + # if we want to reuse the name immediately after: + # we need to first rename it, then delete it. + if Sys.iswindows() + try + local rmpath + rmdir, rmname = splitdir(path) + while true + rmpath = string(rmdir, isempty(rmdir) ? "" : path_separator, + "\$", _rand_filename(), rmname, ".deleted") + ispath(rmpath) || break + end + rename(path, rmpath) + path = rmpath + catch ex + isa(ex, IOError) || rethrow(ex) + end + end + return try + rm(path) + true + catch ex + isa(ex, IOError) || rethrow(ex) + false + end +end + +""" + close(lock::LockMonitor) + +Release a pidfile lock. +""" +function Base.close(lock::LockMonitor) + update = lock.update + update === nothing || close(update) + isopen(lock.fd) || return false + removed = false + path = lock.path + pathstat = try + # Windows sometimes likes to return EACCES here, + # if the path is in the process of being deleted + stat(path) + catch ex + ex isa IOError || rethrow() + removed = ex + nothing + end + if pathstat !== nothing && samefile(stat(lock.fd), pathstat) + # try not to delete someone else's lock + try + rm(path) + removed = true + catch ex + ex isa IOError || rethrow() + removed = ex + end + end + close(lock.fd) + havelock = removed === true + havelock || @warn "failed to remove pidfile on close" path=path removed=removed + return havelock +end + +end # module diff --git a/stdlib/FileWatching/test/pidfile.jl b/stdlib/FileWatching/test/pidfile.jl new file mode 100644 index 0000000000000..757b0b20bdfb7 --- /dev/null +++ b/stdlib/FileWatching/test/pidfile.jl @@ -0,0 +1,358 @@ +using FileWatching.Pidfile + +using Test + +using Base.Filesystem: File +using FileWatching.Pidfile: iswindows, + write_pidfile, parse_pidfile, + isvalidpid, stale_pidfile, + tryopen_exclusive, open_exclusive + +# helper utilities +struct MemoryFile <: Base.AbstractPipe + io::IOBuffer + mtime::Float64 +end +Base.pipe_reader(io::MemoryFile) = io.io +Base.Filesystem.mtime(io::MemoryFile) = io.mtime + +# set the process umask so we can test the behavior of +# open mask without interference from parent's state +# and create a test environment temp directory +umask(new_mask) = ccall((@static iswindows() ? :_umask : :umask), Cint, (Cint,), new_mask) +@testset "Pidfile.jl" begin +old_umask = umask(0o002) +try + mktempdir() do dir + cd(dir) do + +# now start tests definitions: + +@testset "validpid" begin + mypid = getpid() % Cuint + @test isvalidpid(gethostname(), mypid) + @test isvalidpid("", mypid) + @test !isvalidpid("", 0 % Cuint) + @test isvalidpid("NOT" * gethostname(), mypid) + @test isvalidpid("NOT" * gethostname(), 0 % Cuint) + @test isvalidpid("NOT" * gethostname(), -1 % Cuint) + if !iswindows() + @test isvalidpid("", 1 % Cuint) + @test !isvalidpid("", -1 % Cuint) + @test !isvalidpid("", -mypid) + end +end + +@testset "write_pidfile" begin + buf = IOBuffer() + pid, host, age = 0, "", 123 + pid2, host2, age2 = parse_pidfile(MemoryFile(seekstart(buf), time() - age)) + @test pid == pid2 + @test host == host2 + @test age ≈ age2 atol=5 + + host = " host\r\n" + write(buf, "-1 $host") + pid2, host2, age2 = parse_pidfile(MemoryFile(seekstart(buf), time() - age)) + @test pid == pid2 + @test host == host2 + @test age ≈ age2 atol=5 + truncate(seekstart(buf), 0) + + pid, host = getpid(), gethostname() + write_pidfile(buf, pid) + @test read(seekstart(buf), String) == "$pid $host" + pid2, host2, age2 = parse_pidfile(MemoryFile(seekstart(buf), time() - age)) + @test pid == pid2 + @test host == host2 + @test age ≈ age2 atol=5 + truncate(seekstart(buf), 0) + + @testset "parse_pidfile" begin + age = 0 + @test parse_pidfile("nonexist") === (Cuint(0), "", 0.0) + open(io -> write_pidfile(io, pid), "pidfile", "w") + pid2, host2, age2 = parse_pidfile("pidfile") + @test pid == pid2 + @test host == host2 + @test age ≈ age2 atol=10 + rm("pidfile") + end +end + +@assert !ispath("pidfile") +@testset "open_exclusive" begin + f = open_exclusive("pidfile")::File + try + # check that f is open and read-writable + @test isfile("pidfile") + @test filemode("pidfile") & 0o777 == 0o444 + @test filemode(f) & 0o777 == 0o444 + @test filesize(f) == 0 + @test write(f, "a") == 1 + @test filesize(f) == 1 + @test read(seekstart(f), String) == "a" + chmod("pidfile", 0o600) + @test filemode(f) & 0o777 == (iswindows() ? 0o666 : 0o600) + finally + close(f) + end + + # release the pidfile after a short delay + deleted = false + rmtask = @async begin + sleep(3) + rm("pidfile") + deleted = true + end + isdefined(Base, :errormonitor) && Base.errormonitor(rmtask) + @test isfile("pidfile") + @test !deleted + + # open the pidfile again (should wait for it to disappear first) + t = @elapsed f2 = open_exclusive(joinpath(dir, "pidfile"))::File + try + @test deleted + @test isfile("pidfile") + @test t > 2 + if t > 6 + println("INFO: watch_file optimization appears to have NOT succeeded") + end + @test filemode(f2) & 0o777 == 0o444 + @test filesize(f2) == 0 + @test write(f2, "bc") == 2 + @test read(seekstart(f2), String) == "bc" + @test filesize(f2) == 2 + finally + close(f2) + end + rm("pidfile") + wait(rmtask) + + # now test with a long delay and other non-default options + f = open_exclusive("pidfile", mode = 0o000)::File + try + @test filemode(f) & 0o777 == (iswindows() ? 0o444 : 0o000) + finally + close(f) + end + deleted = false + rmtask = @async begin + sleep(8) + rm("pidfile") + deleted = true + end + isdefined(Base, :errormonitor) && Base.errormonitor(rmtask) + @test isfile("pidfile") + @test !deleted + # open the pidfile again (should wait for it to disappear first) + t = @elapsed f2 = open_exclusive("pidfile", mode = 0o777, poll_interval = 1.0)::File + try + @test deleted + @test isfile("pidfile") + @test filemode(f2) & 0o777 == (iswindows() ? 0o666 : 0o775) + @test write(f2, "def") == 3 + @test read(seekstart(f2), String) == "def" + @test t > 7 + finally + close(f2) + end + rm("pidfile") + wait(rmtask) + + @testset "test for wait == false cases" begin + f = open_exclusive("pidfile", wait=false) + @test isfile("pidfile") + close(f) + rm("pidfile") + + f = open_exclusive("pidfile")::File + deleted = false + rmtask = @async begin + sleep(2) + @test Pidfile.tryrmopenfile("pidfile") + deleted = true + end + isdefined(Base, :errormonitor) && Base.errormonitor(rmtask) + + t1 = time() + @test_throws ErrorException open_exclusive("pidfile", wait=false) + @test time()-t1 ≈ 0 atol=1 + + sleep(1) + @test !deleted + + t1 = time() + @test_throws ErrorException open_exclusive("pidfile", wait=false) + @test time()-t1 ≈ 0 atol=1 + + wait(rmtask) + @test deleted + t = @elapsed f2 = open_exclusive("pidfile", wait=false)::File + @test isfile("pidfile") + @test t ≈ 0 atol=1 + close(f) + close(f2) + rm("pidfile") + end +end + +@assert !ispath("pidfile") +@testset "open_exclusive: break lock" begin + # test for stale_age + t = @elapsed f = open_exclusive("pidfile", poll_interval=3, stale_age=10)::File + try + write_pidfile(f, getpid()) + finally + close(f) + end + @test t < 2 + t = @elapsed f = open_exclusive("pidfile", poll_interval=3, stale_age=1)::File + close(f) + @test 20 < t < 50 + rm("pidfile") + + t = @elapsed f = open_exclusive("pidfile", poll_interval=3, stale_age=10)::File + close(f) + @test t < 2 + t = @elapsed f = open_exclusive("pidfile", poll_interval=3, stale_age=10)::File + close(f) + @test 8 < t < 20 + rm("pidfile") +end + +@testset "open_exclusive: other errors" begin + error = @test_throws(Base.IOError, open_exclusive("nonexist/folder")) + @test error.value.code == Base.UV_ENOENT + + error = @test_throws(Base.IOError, open_exclusive("")) + @test error.value.code == Base.UV_ENOENT +end + +@assert !ispath("pidfile") +@testset "mkpidlock" begin + lockf = mkpidlock("pidfile") + @test lockf.update === nothing + waittask = @async begin + sleep(3) + cd(homedir()) do + return close(lockf) + end + end + isdefined(Base, :errormonitor) && Base.errormonitor(waittask) + + # mkpidlock with no waiting + t = @elapsed @test_throws ErrorException mkpidlock("pidfile", wait=false) + @test t ≈ 0 atol=1 + + t = @elapsed lockf1 = mkpidlock(joinpath(dir, "pidfile")) + @test t > 2 + @test istaskdone(waittask) && fetch(waittask) + @test !close(lockf) + finalize(lockf1) + t = @elapsed lockf2 = mkpidlock("pidfile") + @test t < 2 + @test !close(lockf1) + + # test manual breakage of the lock + # is correctly handled + @test Pidfile.tryrmopenfile("pidfile") + t = @elapsed lockf3 = mkpidlock("pidfile") + @test t < 2 + @test isopen(lockf2.fd) + @test !close(lockf2) + @test !isopen(lockf2.fd) + @test isfile("pidfile") + @test close(lockf3) + @test !isfile("pidfile") + + # Just for coverage's sake, run a test with do-block syntax + lock_times = Float64[] + t_loop = @async begin + for idx in 1:100 + t = @elapsed mkpidlock("do_block_pidfile") do + # nothing + end + sleep(0.01) + push!(lock_times, t) + end + end + isdefined(Base, :errormonitor) && Base.errormonitor(t_loop) + mkpidlock("do_block_pidfile") do + sleep(3) + end + wait(t_loop) + @test maximum(lock_times) > 2 + @test minimum(lock_times) < 1 +end + +@assert !ispath("pidfile") +@testset "mkpidlock update" begin + lockf = mkpidlock("pidfile") + @test lockf.update === nothing + new = mtime(lockf.fd) + @test new ≈ time() atol=1 + sleep(1) + @test mtime(lockf.fd) == new + touch(lockf) + old, new = new, mtime(lockf.fd) + @test new != old + @test new ≈ time() atol=1 + close(lockf) + + lockf = mkpidlock("pidfile"; refresh=0.2) + new = mtime(lockf.fd) + @test new ≈ time() atol=1 + for i = 1:10 + sleep(0.5) + old, new = new, mtime(lockf.fd) + @test new != old + @test new ≈ time() atol=1 + end + @test isopen(lockf.update::Timer) + close(lockf) + @test !isopen(lockf.update::Timer) + + lockf = mkpidlock("pidfile"; stale_age=10) + @test lockf.update isa Timer + close(lockf.update) # simulate a finalizer running in an undefined order + close(lockf) +end + +@assert !ispath("pidfile") +@testset "mkpidlock for child" begin + proc = open(`cat`, "w", devnull) + lock = mkpidlock("pidfile", proc) + @test isopen(lock.fd) + @test isfile("pidfile") + close(proc) + @test success(proc) + sleep(1) # give some time for the other task to finish releasing the lock resources + @test !isopen(lock.fd) + @test !isfile("pidfile") + + error = @test_throws Base.IOError mkpidlock("pidfile", proc) + @test error.value.code == Base.UV_ESRCH +end + +@assert !ispath("pidfile-2") +@testset "mkpidlock non-blocking stale lock break" begin + # mkpidlock with no waiting + lockf = mkpidlock("pidfile-2", wait=false) + @test lockf.update === nothing + + sleep(1) + t = @elapsed @test_throws ErrorException mkpidlock("pidfile-2", wait=false, stale_age=1, poll_interval=1, refresh=0) + @test t ≈ 0 atol=1 + + sleep(5) + t = @elapsed (lockf2 = mkpidlock("pidfile-2", wait=false, stale_age=.1, poll_interval=1, refresh=0)) + @test t ≈ 0 atol=1 + close(lockf) + close(lockf2) +end + +end; end # cd(tempdir) +finally + umask(old_umask) +end; end # testset diff --git a/stdlib/FileWatching/test/runtests.jl b/stdlib/FileWatching/test/runtests.jl index f302f28295a01..419ae48dd0a75 100644 --- a/stdlib/FileWatching/test/runtests.jl +++ b/stdlib/FileWatching/test/runtests.jl @@ -3,6 +3,8 @@ using Test, FileWatching using Base: uv_error, Experimental +@testset "FileWatching" begin + # This script does the following # Sets up N unix pipes (or WSA sockets) # For the odd pipes, a byte is written to the write end at intervals specified in intvls @@ -157,8 +159,8 @@ test2_12992() ####################################################################### # This section tests file watchers. # ####################################################################### -const F_GETPATH = Sys.islinux() || Sys.iswindows() || Sys.isapple() # platforms where F_GETPATH is available -const F_PATH = F_GETPATH ? "afile.txt" : "" +F_GETPATH = Sys.islinux() || Sys.iswindows() || Sys.isapple() # platforms where F_GETPATH is available +F_PATH = F_GETPATH ? "afile.txt" : "" dir = mktempdir() file = joinpath(dir, "afile.txt") @@ -431,3 +433,9 @@ unwatch_folder(dir) @test isempty(FileWatching.watched_folders) rm(file) rm(dir) + +@testset "Pidfile" begin + include("pidfile.jl") +end + +end # testset