Skip to content

Commit 8b3cec1

Browse files
add Pidfile to FileWatching
Co-Authored-By: Jameson Nash <[email protected]>
1 parent 5ac0020 commit 8b3cec1

File tree

5 files changed

+743
-3
lines changed

5 files changed

+743
-3
lines changed

stdlib/FileWatching/docs/src/index.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,31 @@ FileWatching.watch_file
77
FileWatching.watch_folder
88
FileWatching.unwatch_folder
99
```
10+
11+
# Pidfile
12+
13+
```@meta
14+
CurrentModule = Pidfile
15+
```
16+
17+
A simple utility tool for creating advisory pidfiles (lock files).
18+
19+
## Primary Functions
20+
21+
```@docs
22+
mkpidlock
23+
close
24+
```
25+
26+
27+
## Helper Functions
28+
29+
```@docs
30+
Pidfile.open_exclusive
31+
Pidfile.tryopen_exclusive
32+
Pidfile.write_pidfile
33+
Pidfile.parse_pidfile
34+
Pidfile.stale_pidfile
35+
Pidfile.isvalidpid
36+
Base.touch(::Pidfile.LockMonitor)
37+
```

stdlib/FileWatching/src/FileWatching.jl

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ export
1616
FileMonitor,
1717
FolderMonitor,
1818
PollingFileWatcher,
19-
FDWatcher
19+
FDWatcher,
20+
# pidfile:
21+
mkpidlock
2022

2123
import Base: @handle_as, wait, close, eventloop, notify_error, IOError,
2224
_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
862864
end
863865
end
864866

867+
include("pidfile.jl")
868+
import .Pidfile: mkpidlock
869+
865870
end

stdlib/FileWatching/src/pidfile.jl

Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
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

Comments
 (0)