diff --git a/src/Revise.jl b/src/Revise.jl index 3e03ff8b..5ddc6ca8 100644 --- a/src/Revise.jl +++ b/src/Revise.jl @@ -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()) + remove_callback(key) + + files = map(abspath, files) + init_watching(files) + + if modules !== nothing + 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) + 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 @@ -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 @@ -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) @@ -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) @@ -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 @@ -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 @@ -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 @@ -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() @@ -792,39 +908,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 """ diff --git a/src/pkgs.jl b/src/pkgs.jl index ae294e23..5a7d715f 100644 --- a/src/pkgs.jl +++ b/src/pkgs.jl @@ -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 diff --git a/test/runtests.jl b/test/runtests.jl index 974b554d..e4275b71 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -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()