Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support user-specified callbacks (v2) #488

Merged
merged 3 commits into from
May 24, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 157 additions & 34 deletions src/Revise.jl
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,117 @@ This list gets populated by callbacks that watch directories for updates.
"""
const revision_queue = Set{Tuple{PkgData,String}}()

"""
Revise.revision_event

This `Condition` is used to notify `entr` that one of the watched files has changed.
"""
const revision_event = Condition()

"""
Revise.user_callbacks_queue

Global variable, `user_callbacks_queue` holds `key` values for which the
file has changed but the user hooks have not yet been called.
"""
const user_callbacks_queue = Set{Any}()

"""
Revise.user_callbacks_by_file

Global variable, maps files (identified by their absolute path) to the set of
callback keys registered for them.
"""
const user_callbacks_by_file = Dict{String, Set{Any}}()

"""
Revise.user_callbacks_by_key

Global variable, maps callback keys to user hooks.
"""
const user_callbacks_by_key = Dict{Any, Any}()

"""
key = Revise.add_callback(f, files, modules=nothing; key=gensym())

Add a user-specified callback, to be executed during the first run of
`revise()` after a file in `files` or a module in `modules` is changed on the
file system. In an interactive session like the REPL, Juno or Jupyter, this
means the callback executes immediately before executing a new command / cell.

You can use the return value `key` to remove the callback later
(`Revise.remove_callback`) or to update it using another call
to `Revise.add_callback` with `key=key`.
"""
function add_callback(f, files, modules=nothing; key=gensym())
remove_callback(key)

files = map(abspath, files)
init_watching(files)

if modules !== nothing
for mod in modules
track(mod) # Potentially needed for modules like e.g. Base
id = PkgId(mod)
pkgdata = pkgdatas[id]
for file in srcfiles(pkgdata)
absname = joinpath(basedir(pkgdata), file)
push!(files, absname)
end
end
end

for file in files
cb = get!(Set, user_callbacks_by_file, file)
push!(cb, key)
user_callbacks_by_key[key] = f
end

return key
end

"""
Revise.remove_callback(key)

Remove a callback previously installed by a call to `Revise.add_callback(...)`.
See its docstring for details.
"""
function remove_callback(key)
for cbs in values(user_callbacks_by_file)
delete!(cbs, key)
end
delete!(user_callbacks_by_key, key)

# possible future work: we may stop watching (some of) these files
# now. But we don't really keep track of what background tasks are running
# and Julia doesn't have an ergonomic way of task cancellation yet (see
# e.g.
# https://github.com/JuliaLang/Juleps/blob/master/StructuredConcurrency.md
# so we'll omit this for now. The downside is that in pathological cases,
# this may exhaust inotify resources.

nothing
end

function process_user_callbacks!(keys = user_callbacks_queue; throw=false)
try
# use (a)sync so any exceptions get nicely collected into CompositeException
@sync for key in keys
f = user_callbacks_by_key[key]
@async Base.invokelatest(f)
end
catch err
if throw
rethrow(err)
else
@warn "[Revise] Ignoring callback errors" err
end
finally
empty!(keys)
end
end


"""
Revise.queue_errors

Expand All @@ -108,6 +219,15 @@ Global variable, maps `(pkgdata, filename)` pairs that errored upon last revisio
"""
const queue_errors = Dict{Tuple{PkgData,String},Tuple{Exception, Any}}()

"""
Revise.NOPACKAGE

Global variable; default `PkgId` used for files which do not belong to any
package, but still have to be watched because user callbacks have been
registered for them.
"""
const NOPACKAGE = PkgId(nothing, "")

"""
Revise.pkgdatas

Expand All @@ -116,7 +236,7 @@ and julia objects, and allows re-evaluation of code in the proper module scope.
It is a dictionary indexed by PkgId:
`pkgdatas[id]` returns a value of type [`Revise.PkgData`](@ref).
"""
const pkgdatas = Dict{PkgId,PkgData}()
const pkgdatas = Dict{PkgId,PkgData}(NOPACKAGE => PkgData(NOPACKAGE))

const moduledeps = Dict{Module,DepDict}()
function get_depdict(mod::Module)
Expand Down Expand Up @@ -476,6 +596,7 @@ function init_watching(pkgdata::PkgData, files)
end
return nothing
end
init_watching(files) = init_watching(pkgdatas[NOPACKAGE], files)

"""
revise_dir_queued(dirname)
Expand All @@ -497,12 +618,20 @@ This is generally called via a [`Revise.TaskThunk`](@ref).
end
break
end

latestfiles, stillwatching = watch_files_via_dir(dirname) # will block here until file(s) change
for (file, id) in latestfiles
key = joinpath(dirname, file)
pkgdata = pkgdatas[id]
if hasfile(pkgdata, key) # issue #228
push!(revision_queue, (pkgdata, relpath(key, pkgdata)))
if key in keys(user_callbacks_by_file)
union!(user_callbacks_queue, user_callbacks_by_file[key])
notify(revision_event)
end
if id != NOPACKAGE
pkgdata = pkgdatas[id]
if hasfile(pkgdata, key) # issue #228
push!(revision_queue, (pkgdata, relpath(key, pkgdata)))
notify(revision_event)
end
end
end
end
Expand Down Expand Up @@ -534,6 +663,7 @@ function revise_file_queued(pkgdata::PkgData, file)
with_logger(SimpleLogger(stderr)) do
@warn "$file is not an existing file, Revise is not watching"
end
notify(revision_event)
break
end
try
Expand All @@ -542,6 +672,12 @@ function revise_file_queued(pkgdata::PkgData, file)
# issue #459
(isa(e, InterruptException) && throwto_repl(e)) || throw(e)
end

if file in keys(user_callbacks_by_file)
union!(user_callbacks_queue, user_callbacks_by_file[file])
notify(revision_event)
end

# Check to see if we're still watching this file
stillwatching = haskey(watched_files, dirfull)
push!(revision_queue, (pkgdata, file0))
Expand Down Expand Up @@ -608,11 +744,13 @@ function errors(revision_errors=keys(queue_errors))
end

"""
revise()
revise(; throw=false)

`eval` any changes in the revision queue. See [`Revise.revision_queue`](@ref).
If `throw` is `true`, throw any errors that occur during revision or callback;
otherwise these are only logged.
"""
function revise()
function revise(; throw=false)
sleep(0.01) # in case the file system isn't quite done writing out the new files

# Do all the deletion first. This ensures that a method that moved from one file to another
Expand Down Expand Up @@ -665,6 +803,9 @@ function revise()
Use Revise.errors() to report errors again."""
end
tracking_Main_includes[] && queue_includes(Main)

process_user_callbacks!(throw=throw)

nothing
end
revise(backend::REPL.REPLBackend) = revise()
Expand Down Expand Up @@ -808,39 +949,21 @@ This will print "update" every time `"/tmp/watched.txt"` or any of the code defi
"""
function entr(f::Function, files, modules=nothing; postpone=false, pause=0.02)
yield()
files = collect(files) # because we may add to this list
if modules !== nothing
for mod in modules
id = PkgId(mod)
pkgdata = pkgdatas[id]
for file in srcfiles(pkgdata)
push!(files, joinpath(basedir(pkgdata), file))
end
end
postpone || f()
key = add_callback(files, modules) do
sleep(pause)
f()
end
active = true
try
@sync begin
postpone || f()
for file in files
waitfor = isdir(file) ? watch_folder : watch_file
@async while active
ret = waitfor(file, 1)
if active && (ret.changed || ret.renamed)
sleep(pause)
revise()
Base.invokelatest(f)
end
end
end
while true
wait(revision_event)
revise(throw=true)
end
catch err
if isa(err, InterruptException)
active = false
else
rethrow(err)
end
isa(err, InterruptException) || rethrow(err)
end
remove_callback(key)
nothing
end

"""
Expand Down
1 change: 1 addition & 0 deletions src/pkgs.jl
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,7 @@ function watch_manifest(mfile)
maybe_parse_from_cache!(pkgdata, file)
push!(revision_queue, (pkgdata, file))
push!(files, file)
notify(revision_event)
end
# Update the directory
pkgdata.info.basedir = pkgdir
Expand Down
71 changes: 71 additions & 0 deletions test/runtests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -2957,6 +2957,77 @@ do_test("entr with modules") && @testset "entr with modules" begin

end

do_test("callbacks") && @testset "callbacks" begin

append(path, x...) = open(path, append=true) do io
write(io, x...)
end

mktemp() do path, _
contents = Ref("")
key = Revise.add_callback([path]) do
contents[] = read(path, String)
end

sleep(mtimedelay)

append(path, "abc")
sleep(mtimedelay)
revise()
@test contents[] == "abc"

sleep(mtimedelay)

append(path, "def")
sleep(mtimedelay)
revise()
@test contents[] == "abcdef"

Revise.remove_callback(key)
sleep(mtimedelay)

append(path, "ghi")
sleep(mtimedelay)
revise()
@test contents[] == "abcdef"
end

testdir = newtestdir()
modname = "A355"
srcfile = joinpath(testdir, modname * ".jl")

function setvalue(x)
open(srcfile, "w") do io
print(io, "module $modname test() = $x end")
end
end

setvalue(1)

sleep(mtimedelay)
@eval using A355
sleep(mtimedelay)

A355_result = Ref(0)

Revise.add_callback([], [A355]) do
A355_result[] = A355.test()
end

sleep(mtimedelay)
setvalue(2)
# belt and suspenders -- make sure we trigger entr:
sleep(mtimedelay)
touch(srcfile)

yry()

@test A355_result[] == 2

rm_precompile(modname)

end

println("beginning cleanup")
GC.gc(); GC.gc()

Expand Down