Skip to content

Commit d07a913

Browse files
Add Pidfile to FileWatching (#44367)
Co-authored-by: Jameson Nash <[email protected]> Co-authored-by: Jameson Nash <[email protected]>
1 parent bcc0f70 commit d07a913

File tree

5 files changed

+725
-3
lines changed

5 files changed

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

0 commit comments

Comments
 (0)