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 #443

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
Changes from 7 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
169 changes: 134 additions & 35 deletions src/Revise.jl
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,100 @@ This list gets populated by callbacks that watch directories for updates.
"""
const revision_queue = Set{Tuple{PkgData,String}}()

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}()

const user_callbacks_by_file = Dict{String, Set{Any}}()
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())
tkluck marked this conversation as resolved.
Show resolved Hide resolved
remove_callback(key)
timholy marked this conversation as resolved.
Show resolved Hide resolved

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

if modules !== nothing
tkluck marked this conversation as resolved.
Show resolved Hide resolved
for mod in modules
id = PkgId(mod)
pkgdata = pkgdatas[id]
for file in srcfiles(pkgdata)
absname = joinpath(basedir(pkgdata), file)
push!(files, absname)
track(mod, absname)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It only recently became safe to call track more than once on the same file, so this seems OK. But, under what circumstances would these not already be tracked?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't actually see any reason why they would already be tracked? What if someone does Revise.add_callback([], Base) do .... ? I'm probably missing something here.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess I'm reacting to the fact that you're pulling pre-existing files out of the pkgdata and adding them to files. Tracking would be on if you'd said using MyPkg and then called this later with add_callback(..., modules=(MyPkg,),...). Stated differently, if they're already in pkgdata how are they not tracked?

Perhaps I don't understand the purpose, and a comment would be helpful.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, now I see what you mean! Yes, this is faulty code then -- I'm trying to guard for the fact that the module may not be under tracking at all yet. Under that logic, we should first call track(mod) and only then read the data from pkgdata, right?

If that's the case, I should also add a doctest that tests for this correct behaviour.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any time you've said using MyPkg it should be tracked. Though perhaps the watch_package callback (see how it gets registered in __init__) has to fire first. Is that the issue?

I think I've make track safe for re-entry (see #433), but I tend to be jittery (probably, needlessly) about things getting done twice and messing up the internal accounting. Maybe poke at this a bit first and try to define more clearly what circumstances require this?

Copy link
Contributor Author

@tkluck tkluck Apr 10, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any time you've said using MyPkg it should be tracked.

The two things that I have in mind are:

  • packages loaded before Revise was loaded, e.g.
using MyPackage
using Revise
Revise.add_callback([], [MyPackage]) do ....
  • packages in the system image (e.g. Base or anything the user added in PackageCompiler).

The first one could potentially be tested by spawning another Julia process. The second one seems almost impossible to test. But in both cases, calling Revise.track(<module>) before reading pkgdata seems like it's the right thing to do -- what do you think?

I wasn't thinking about a race condition related to when watch_package is being called; do you think we should?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

packages loaded before Revise was loaded

If package precompilation were required for all modules, I could support that, but the existence (even in principle) of non-precompiled packages makes robust support for such a feature impossible. The problematic use case is loading the package, making some changes to the source, and then tracking with Revise: in this case Revise has no way of knowing that what's in the files does not match the code in your running session. Conversely, if you load Revise first, it "defensively" parses the entire package when it's loaded and then it has a baseline to compare any file changes. This is not necessary for precompied packages because I added a source-cache to *.ji files in JuliaLang/julia#23898, and did the same for Julia itself in JuliaLang/julia#24120. This means that you can always recover the state of the source at the time at which the *.ji file was built. But there is no such fix available for non-precompiled packages.

So I plan to support this feature by saying "load Revise first, otherwise you're screwed" 😄. More seriously, I could contemplate supporting this specifically for precompiled packages, but we'd need to enforce it.

packages in the system image (e.g. Base or anything the user added in PackageCompiler).

That's a much more important use-case. Do such files record their source text? If not, we're screwed for now but should fix that ASAP.

But in both cases, calling Revise.track() before reading pkgdata seems like it's the right thing to do -- what do you think?

Yeah, track populates the structure and then you can do stuff with it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ffevotte this is the thing that needs resolving: in add_callback we need to move the call to Revise.track(mod) to the place before reading pkgdatas[id]. We don't have to call Revise.track(mod, absname) after that.

After that change, hopefully this is done.

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 @@ -102,6 +196,8 @@ Global variable, maps `(pkgdata, filename)` pairs that errored upon last revisio
"""
const queue_errors = Dict{Tuple{PkgData,String},Tuple{Exception, Any}}()

const NOPACKAGE = PkgId(nothing, "")

"""
Revise.pkgdatas

Expand All @@ -110,7 +206,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 @@ -471,7 +567,7 @@ function init_watching(pkgdata::PkgData, files)
end
return nothing
end
init_watching(files) = init_watching(PkgId(Main), files)
init_watching(files) = init_watching(pkgdatas[NOPACKAGE], files)

"""
revise_dir_queued(dirname)
Expand All @@ -494,9 +590,16 @@ This is generally called via a [`Revise.Rescheduler`](@ref).
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
return stillwatching
Expand All @@ -520,15 +623,23 @@ function revise_file_queued(pkgdata::PkgData, file)
sleep(0.1) # in case git has done a delete/replace cycle
if !file_exists(file)
push!(revision_queue, (pkgdata, file0)) # process file deletions
notify(revision_event)
return false
end
end

wait_changed(file) # will block here until the file changes

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
dirfull, basename = splitdir(file)
if haskey(watched_files, dirfull)
push!(revision_queue, (pkgdata, file0))
notify(revision_event)
return true
end
return false
Expand Down Expand Up @@ -592,11 +703,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 @@ -649,6 +762,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 @@ -792,39 +908,22 @@ 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
mycallbacks = [key]
tkluck marked this conversation as resolved.
Show resolved Hide resolved
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 @@ -491,6 +491,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 @@ -2780,6 +2780,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