Skip to content

Commit 61802e2

Browse files
authoredOct 1, 2024··
fix FileWatching designs and add workaround for a stat bug on Apple (#55877)
What started as an innocent fix for a stat bug on Apple (#48667) turned into a full blown investigation into the design problems with the libuv backend for PollingFileWatcher, and writing my own implementation of it instead which could avoid those singled-threaded concurrency bugs.
2 parents cf8df9a + f8d17e7 commit 61802e2

File tree

9 files changed

+494
-355
lines changed

9 files changed

+494
-355
lines changed
 

‎base/libuv.jl

+4-4
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,10 @@ for r in uv_req_types
2626
@eval const $(Symbol("_sizeof_", lowercase(string(r)))) = uv_sizeof_req($r)
2727
end
2828

29-
uv_handle_data(handle) = ccall(:jl_uv_handle_data, Ptr{Cvoid}, (Ptr{Cvoid},), handle)
30-
uv_req_data(handle) = ccall(:jl_uv_req_data, Ptr{Cvoid}, (Ptr{Cvoid},), handle)
31-
uv_req_set_data(req, data) = ccall(:jl_uv_req_set_data, Cvoid, (Ptr{Cvoid}, Any), req, data)
32-
uv_req_set_data(req, data::Ptr{Cvoid}) = ccall(:jl_uv_req_set_data, Cvoid, (Ptr{Cvoid}, Ptr{Cvoid}), req, data)
29+
uv_handle_data(handle) = ccall(:uv_handle_get_data, Ptr{Cvoid}, (Ptr{Cvoid},), handle)
30+
uv_req_data(handle) = ccall(:uv_req_get_data, Ptr{Cvoid}, (Ptr{Cvoid},), handle)
31+
uv_req_set_data(req, data) = ccall(:uv_req_set_data, Cvoid, (Ptr{Cvoid}, Any), req, data)
32+
uv_req_set_data(req, data::Ptr{Cvoid}) = ccall(:uv_handle_set_data, Cvoid, (Ptr{Cvoid}, Ptr{Cvoid}), req, data)
3333

3434
macro handle_as(hand, typ)
3535
return quote

‎base/reflection.jl

+2-1
Original file line numberDiff line numberDiff line change
@@ -964,7 +964,7 @@ use it in the following manner to summarize information about a struct:
964964
julia> structinfo(T) = [(fieldoffset(T,i), fieldname(T,i), fieldtype(T,i)) for i = 1:fieldcount(T)];
965965
966966
julia> structinfo(Base.Filesystem.StatStruct)
967-
13-element Vector{Tuple{UInt64, Symbol, Type}}:
967+
14-element Vector{Tuple{UInt64, Symbol, Type}}:
968968
(0x0000000000000000, :desc, Union{RawFD, String})
969969
(0x0000000000000008, :device, UInt64)
970970
(0x0000000000000010, :inode, UInt64)
@@ -978,6 +978,7 @@ julia> structinfo(Base.Filesystem.StatStruct)
978978
(0x0000000000000050, :blocks, Int64)
979979
(0x0000000000000058, :mtime, Float64)
980980
(0x0000000000000060, :ctime, Float64)
981+
(0x0000000000000068, :ioerrno, Int32)
981982
```
982983
"""
983984
fieldoffset(x::DataType, idx::Integer) = (@_foldable_meta; ccall(:jl_get_field_offset, Csize_t, (Any, Cint), x, idx))

‎base/stat.jl

+57-54
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ struct StatStruct
6363
blocks :: Int64
6464
mtime :: Float64
6565
ctime :: Float64
66+
ioerrno :: Int32
6667
end
6768

6869
@eval function Base.:(==)(x::StatStruct, y::StatStruct) # do not include `desc` in equality or hash
@@ -80,22 +81,23 @@ end
8081
end)
8182
end
8283

83-
StatStruct() = StatStruct("", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
84-
StatStruct(buf::Union{Vector{UInt8},Ptr{UInt8}}) = StatStruct("", buf)
85-
StatStruct(desc::Union{AbstractString, OS_HANDLE}, buf::Union{Vector{UInt8},Ptr{UInt8}}) = StatStruct(
84+
StatStruct() = StatStruct("", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, Base.UV_ENOENT)
85+
StatStruct(buf::Union{Memory{UInt8},Vector{UInt8},Ptr{UInt8}}, ioerrno::Int32) = StatStruct("", buf, ioerrno)
86+
StatStruct(desc::Union{AbstractString, OS_HANDLE}, buf::Union{Memory{UInt8},Vector{UInt8},Ptr{UInt8}}, ioerrno::Int32) = StatStruct(
8687
desc isa OS_HANDLE ? desc : String(desc),
87-
ccall(:jl_stat_dev, UInt32, (Ptr{UInt8},), buf),
88-
ccall(:jl_stat_ino, UInt32, (Ptr{UInt8},), buf),
89-
ccall(:jl_stat_mode, UInt32, (Ptr{UInt8},), buf),
90-
ccall(:jl_stat_nlink, UInt32, (Ptr{UInt8},), buf),
91-
ccall(:jl_stat_uid, UInt32, (Ptr{UInt8},), buf),
92-
ccall(:jl_stat_gid, UInt32, (Ptr{UInt8},), buf),
93-
ccall(:jl_stat_rdev, UInt32, (Ptr{UInt8},), buf),
94-
ccall(:jl_stat_size, UInt64, (Ptr{UInt8},), buf),
95-
ccall(:jl_stat_blksize, UInt64, (Ptr{UInt8},), buf),
96-
ccall(:jl_stat_blocks, UInt64, (Ptr{UInt8},), buf),
97-
ccall(:jl_stat_mtime, Float64, (Ptr{UInt8},), buf),
98-
ccall(:jl_stat_ctime, Float64, (Ptr{UInt8},), buf),
88+
ioerrno != 0 ? zero(UInt32) : ccall(:jl_stat_dev, UInt32, (Ptr{UInt8},), buf),
89+
ioerrno != 0 ? zero(UInt32) : ccall(:jl_stat_ino, UInt32, (Ptr{UInt8},), buf),
90+
ioerrno != 0 ? zero(UInt32) : ccall(:jl_stat_mode, UInt32, (Ptr{UInt8},), buf),
91+
ioerrno != 0 ? zero(UInt32) : ccall(:jl_stat_nlink, UInt32, (Ptr{UInt8},), buf),
92+
ioerrno != 0 ? zero(UInt32) : ccall(:jl_stat_uid, UInt32, (Ptr{UInt8},), buf),
93+
ioerrno != 0 ? zero(UInt32) : ccall(:jl_stat_gid, UInt32, (Ptr{UInt8},), buf),
94+
ioerrno != 0 ? zero(UInt32) : ccall(:jl_stat_rdev, UInt32, (Ptr{UInt8},), buf),
95+
ioerrno != 0 ? zero(UInt64) : ccall(:jl_stat_size, UInt64, (Ptr{UInt8},), buf),
96+
ioerrno != 0 ? zero(UInt64) : ccall(:jl_stat_blksize, UInt64, (Ptr{UInt8},), buf),
97+
ioerrno != 0 ? zero(UInt64) : ccall(:jl_stat_blocks, UInt64, (Ptr{UInt8},), buf),
98+
ioerrno != 0 ? zero(Float64) : ccall(:jl_stat_mtime, Float64, (Ptr{UInt8},), buf),
99+
ioerrno != 0 ? zero(Float64) : ccall(:jl_stat_ctime, Float64, (Ptr{UInt8},), buf),
100+
ioerrno
99101
)
100102

101103
function iso_datetime_with_relative(t, tnow)
@@ -130,35 +132,41 @@ end
130132
function show_statstruct(io::IO, st::StatStruct, oneline::Bool)
131133
print(io, oneline ? "StatStruct(" : "StatStruct for ")
132134
show(io, st.desc)
133-
oneline || print(io, "\n ")
134-
print(io, " size: ", st.size, " bytes")
135-
oneline || print(io, "\n")
136-
print(io, " device: ", st.device)
137-
oneline || print(io, "\n ")
138-
print(io, " inode: ", st.inode)
139-
oneline || print(io, "\n ")
140-
print(io, " mode: 0o", string(filemode(st), base = 8, pad = 6), " (", filemode_string(st), ")")
141-
oneline || print(io, "\n ")
142-
print(io, " nlink: ", st.nlink)
143-
oneline || print(io, "\n ")
144-
print(io, " uid: $(st.uid)")
145-
username = getusername(st.uid)
146-
username === nothing || print(io, " (", username, ")")
147-
oneline || print(io, "\n ")
148-
print(io, " gid: ", st.gid)
149-
groupname = getgroupname(st.gid)
150-
groupname === nothing || print(io, " (", groupname, ")")
151-
oneline || print(io, "\n ")
152-
print(io, " rdev: ", st.rdev)
153-
oneline || print(io, "\n ")
154-
print(io, " blksz: ", st.blksize)
155-
oneline || print(io, "\n")
156-
print(io, " blocks: ", st.blocks)
157-
tnow = round(UInt, time())
158-
oneline || print(io, "\n ")
159-
print(io, " mtime: ", iso_datetime_with_relative(st.mtime, tnow))
160-
oneline || print(io, "\n ")
161-
print(io, " ctime: ", iso_datetime_with_relative(st.ctime, tnow))
135+
code = st.ioerrno
136+
if code != 0
137+
print(io, oneline ? " " : "\n ")
138+
print(io, Base.uverrorname(code), ": ", Base.struverror(code))
139+
else
140+
oneline || print(io, "\n ")
141+
print(io, " size: ", st.size, " bytes")
142+
oneline || print(io, "\n")
143+
print(io, " device: ", st.device)
144+
oneline || print(io, "\n ")
145+
print(io, " inode: ", st.inode)
146+
oneline || print(io, "\n ")
147+
print(io, " mode: 0o", string(filemode(st), base = 8, pad = 6), " (", filemode_string(st), ")")
148+
oneline || print(io, "\n ")
149+
print(io, " nlink: ", st.nlink)
150+
oneline || print(io, "\n ")
151+
print(io, " uid: $(st.uid)")
152+
username = getusername(st.uid)
153+
username === nothing || print(io, " (", username, ")")
154+
oneline || print(io, "\n ")
155+
print(io, " gid: ", st.gid)
156+
groupname = getgroupname(st.gid)
157+
groupname === nothing || print(io, " (", groupname, ")")
158+
oneline || print(io, "\n ")
159+
print(io, " rdev: ", st.rdev)
160+
oneline || print(io, "\n ")
161+
print(io, " blksz: ", st.blksize)
162+
oneline || print(io, "\n")
163+
print(io, " blocks: ", st.blocks)
164+
tnow = round(UInt, time())
165+
oneline || print(io, "\n ")
166+
print(io, " mtime: ", iso_datetime_with_relative(st.mtime, tnow))
167+
oneline || print(io, "\n ")
168+
print(io, " ctime: ", iso_datetime_with_relative(st.ctime, tnow))
169+
end
162170
oneline && print(io, ")")
163171
return nothing
164172
end
@@ -168,18 +176,13 @@ show(io::IO, ::MIME"text/plain", st::StatStruct) = show_statstruct(io, st, false
168176

169177
# stat & lstat functions
170178

179+
checkstat(s::StatStruct) = Int(s.ioerrno) in (0, Base.UV_ENOENT, Base.UV_ENOTDIR, Base.UV_EINVAL) ? s : uv_error(string("stat(", repr(s.desc), ")"), s.ioerrno)
180+
171181
macro stat_call(sym, arg1type, arg)
172182
return quote
173-
stat_buf = zeros(UInt8, Int(ccall(:jl_sizeof_stat, Int32, ())))
183+
stat_buf = fill!(Memory{UInt8}(undef, Int(ccall(:jl_sizeof_stat, Int32, ()))), 0x00)
174184
r = ccall($(Expr(:quote, sym)), Int32, ($(esc(arg1type)), Ptr{UInt8}), $(esc(arg)), stat_buf)
175-
if !(r in (0, Base.UV_ENOENT, Base.UV_ENOTDIR, Base.UV_EINVAL))
176-
uv_error(string("stat(", repr($(esc(arg))), ")"), r)
177-
end
178-
st = StatStruct($(esc(arg)), stat_buf)
179-
if ispath(st) != (r == 0)
180-
error("stat returned zero type for a valid path")
181-
end
182-
return st
185+
return checkstat(StatStruct($(esc(arg)), stat_buf, r))
183186
end
184187
end
185188

@@ -334,7 +337,7 @@ Return `true` if a valid filesystem entity exists at `path`,
334337
otherwise returns `false`.
335338
This is the generalization of [`isfile`](@ref), [`isdir`](@ref) etc.
336339
"""
337-
ispath(st::StatStruct) = filemode(st) & 0xf000 != 0x0000
340+
ispath(st::StatStruct) = st.ioerrno == 0
338341
function ispath(path::String)
339342
# We use `access()` and `F_OK` to determine if a given path exists. `F_OK` comes from `unistd.h`.
340343
F_OK = 0x00

‎src/sys.c

-1
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,6 @@ JL_DLLEXPORT int32_t jl_nb_available(ios_t *s)
102102

103103
// --- dir/file stuff ---
104104

105-
JL_DLLEXPORT int jl_sizeof_uv_fs_t(void) { return sizeof(uv_fs_t); }
106105
JL_DLLEXPORT char *jl_uv_fs_t_ptr(uv_fs_t *req) { return (char*)req->ptr; }
107106
JL_DLLEXPORT char *jl_uv_fs_t_path(uv_fs_t *req) { return (char*)req->path; }
108107

‎stdlib/FileWatching/docs/src/index.md

+11-5
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,17 @@ EditURL = "https://github.com/JuliaLang/julia/blob/master/stdlib/FileWatching/do
55
# [File Events](@id lib-filewatching)
66

77
```@docs
8-
FileWatching.poll_fd
9-
FileWatching.poll_file
10-
FileWatching.watch_file
11-
FileWatching.watch_folder
12-
FileWatching.unwatch_folder
8+
poll_fd
9+
poll_file
10+
watch_file
11+
watch_folder
12+
unwatch_folder
13+
```
14+
```@docs
15+
FileMonitor
16+
FolderMonitor
17+
PollingFileWatcher
18+
FDWatcher
1319
```
1420

1521
# Pidfile

‎stdlib/FileWatching/src/FileWatching.jl

+363-265
Large diffs are not rendered by default.

‎stdlib/FileWatching/src/pidfile.jl

+35-11
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@ module Pidfile
44
export mkpidlock, trymkpidlock
55

66
using Base:
7-
IOError, UV_EEXIST, UV_ESRCH,
7+
IOError, UV_EEXIST, UV_ESRCH, UV_ENOENT,
88
Process
99

1010
using Base.Filesystem:
1111
File, open, JL_O_CREAT, JL_O_RDWR, JL_O_RDONLY, JL_O_EXCL,
1212
rename, samefile, path_separator
1313

14-
using ..FileWatching: watch_file
14+
using ..FileWatching: FileMonitor
1515
using Base.Sys: iswindows
1616

1717
"""
@@ -256,19 +256,43 @@ function open_exclusive(path::String;
256256
end
257257
end
258258
# fall-back: wait for the lock
259-
259+
watch = Lockable(Core.Box(nothing))
260260
while true
261-
# start the file-watcher prior to checking for the pidfile existence
262-
t = @async try
263-
watch_file(path, poll_interval)
261+
# now try again to create it
262+
# try to start the file-watcher prior to checking for the pidfile existence
263+
watch = try
264+
FileMonitor(path)
264265
catch ex
265266
isa(ex, IOError) || rethrow(ex)
266-
sleep(poll_interval) # if the watch failed, convert to just doing a sleep
267+
ex.code != UV_ENOENT # if the file was deleted in the meantime, don't sleep at all, even if the lock fails
268+
end
269+
timeout = nothing
270+
if watch isa FileMonitor && stale_age > 0
271+
let watch = watch
272+
timeout = Timer(stale_age) do t
273+
close(watch)
274+
end
275+
end
276+
end
277+
try
278+
file = tryopen_exclusive(path, mode)
279+
file === nothing || return file
280+
if watch isa FileMonitor
281+
try
282+
Base.wait(watch) # will time-out after stale_age passes
283+
catch ex
284+
isa(ex, EOFError) || isa(ex, IOError) || rethrow(ex)
285+
end
286+
end
287+
if watch === true # if the watch failed, convert to just doing a sleep
288+
sleep(poll_interval)
289+
end
290+
finally
291+
# something changed about the path, so watch is now possibly monitoring the wrong file handle
292+
# it will need to be recreated just before the next tryopen_exclusive attempt
293+
timeout isa Timer && close(timeout)
294+
watch isa FileMonitor && close(watch)
267295
end
268-
# now try again to create it
269-
file = tryopen_exclusive(path, mode)
270-
file === nothing || return file
271-
Base.wait(t) # sleep for a bit before trying again
272296
if stale_age > 0 && stale_pidfile(path, stale_age, refresh)
273297
# if the file seems stale, try to remove it before attempting again
274298
# set stale_age to zero so we won't attempt again, even if the attempt fails

‎stdlib/FileWatching/test/runtests.jl

+12-14
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
using Test, FileWatching
44
using Base: uv_error, Experimental
5+
using Base.Filesystem: StatStruct
56

67
@testset "FileWatching" begin
78

@@ -168,12 +169,13 @@ file = joinpath(dir, "afile.txt")
168169

169170
# initialize a watch_folder instance and create afile.txt
170171
function test_init_afile()
171-
@test isempty(FileWatching.watched_folders)
172+
watched_folders = FileWatching.watched_folders
173+
@test @lock watched_folders isempty(watched_folders[])
172174
@test(watch_folder(dir, 0) == ("" => FileWatching.FileEvent()))
173175
@test @elapsed(@test(watch_folder(dir, 0) == ("" => FileWatching.FileEvent()))) <= 0.5
174-
@test length(FileWatching.watched_folders) == 1
176+
@test @lock(watched_folders, length(FileWatching.watched_folders[])) == 1
175177
@test unwatch_folder(dir) === nothing
176-
@test isempty(FileWatching.watched_folders)
178+
@test @lock watched_folders isempty(watched_folders[])
177179
@test 0.002 <= @elapsed(@test(watch_folder(dir, 0.004) == ("" => FileWatching.FileEvent())))
178180
@test 0.002 <= @elapsed(@test(watch_folder(dir, 0.004) == ("" => FileWatching.FileEvent()))) <= 0.5
179181
@test unwatch_folder(dir) === nothing
@@ -203,7 +205,7 @@ function test_init_afile()
203205
@test unwatch_folder(dir) === nothing
204206
@test(watch_folder(dir, 0) == ("" => FileWatching.FileEvent()))
205207
@test 0.9 <= @elapsed(@test(watch_folder(dir, 1) == ("" => FileWatching.FileEvent())))
206-
@test length(FileWatching.watched_folders) == 1
208+
@test @lock(watched_folders, length(FileWatching.watched_folders[])) == 1
207209
nothing
208210
end
209211

@@ -218,7 +220,7 @@ function test_timeout(tval)
218220
@async test_file_poll(channel, 10, tval)
219221
tr = take!(channel)
220222
end
221-
@test tr[1] === Base.Filesystem.StatStruct() && tr[2] === EOFError()
223+
@test ispath(tr[1]::StatStruct) && tr[2] === EOFError()
222224
@test tval <= t_elapsed
223225
end
224226

@@ -231,7 +233,7 @@ function test_touch(slval)
231233
write(f, "Hello World\n")
232234
close(f)
233235
tr = take!(channel)
234-
@test ispath(tr[1]) && ispath(tr[2])
236+
@test ispath(tr[1]::StatStruct) && ispath(tr[2]::StatStruct)
235237
fetch(t)
236238
end
237239

@@ -435,11 +437,11 @@ end
435437
@test_throws(Base._UVError("FolderMonitor (start)", Base.UV_ENOENT),
436438
watch_folder("____nonexistent_file", 10))
437439
@test(@elapsed(
438-
@test(poll_file("____nonexistent_file", 1, 3.1) ===
439-
(Base.Filesystem.StatStruct(), EOFError()))) > 3)
440+
@test(poll_file("____nonexistent_file", 1, 3.1) ==
441+
(StatStruct(), EOFError()))) > 3)
440442

441443
unwatch_folder(dir)
442-
@test isempty(FileWatching.watched_folders)
444+
@test @lock FileWatching.watched_folders isempty(FileWatching.watched_folders[])
443445
rm(file)
444446
rm(dir)
445447

@@ -450,10 +452,6 @@ rm(dir)
450452
include("pidfile.jl")
451453
end
452454

453-
@testset "Docstrings" begin
454-
undoc = Docs.undocumented_names(FileWatching)
455-
@test_broken isempty(undoc)
456-
@test undoc == [:FDWatcher, :FileMonitor, :FolderMonitor, :PollingFileWatcher]
457-
end
455+
@test isempty(Docs.undocumented_names(FileWatching))
458456

459457
end # testset

‎test/file.jl

+10
Original file line numberDiff line numberDiff line change
@@ -2128,6 +2128,16 @@ Base.joinpath(x::URI50890) = URI50890(x.f)
21282128
@test !isnothing(Base.Filesystem.getusername(s.uid))
21292129
@test !isnothing(Base.Filesystem.getgroupname(s.gid))
21302130
end
2131+
s = Base.Filesystem.StatStruct()
2132+
stat_show_str = sprint(show, s)
2133+
stat_show_str_multi = sprint(show, MIME("text/plain"), s)
2134+
@test startswith(stat_show_str, "StatStruct(\"\" ENOENT: ") && endswith(stat_show_str, ")")
2135+
@test startswith(stat_show_str_multi, "StatStruct for \"\"\n ENOENT: ") && !endswith(stat_show_str_multi, r"\s")
2136+
s = Base.Filesystem.StatStruct("my/test", Ptr{UInt8}(0), Int32(Base.UV_ENOTDIR))
2137+
stat_show_str = sprint(show, s)
2138+
stat_show_str_multi = sprint(show, MIME("text/plain"), s)
2139+
@test startswith(stat_show_str, "StatStruct(\"my/test\" ENOTDIR: ") && endswith(stat_show_str, ")")
2140+
@test startswith(stat_show_str_multi, "StatStruct for \"my/test\"\n ENOTDIR: ") && !endswith(stat_show_str_multi, r"\s")
21312141
end
21322142

21332143
@testset "diskstat() works" begin

0 commit comments

Comments
 (0)
Please sign in to comment.