Skip to content
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## main (unreleased)

### Bug fixes

- [#83](https://github.com/nrepl/neorepl/pull/83): After a reload, the aliases and refers of the namespaces it didn't reload, like the REPL's, point at the reloaded ones, so a function added since resolves through an alias, and requiring with the same alias again works.

## 0.5.1 (2026-11-05)

Spec examples with cider-nrepl.
Expand Down
1 change: 1 addition & 0 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ Runtime deps: `org.clojure/clojure`, `nrepl/nrepl`, `org.jline/jline` 4.x. That'
- Reloading what changed (2026-11-03, cider-nrepl 0.64.0, with clj-reload 1.0.0). `cider.clj-reload/reload` runs in the session it names, after what's running there, and answers with `:progress` lines, "Reloading N namespaces..." and then "Reloaded N namespaces in M ms" or " failed to load NS" with the error's first line, then `ok`, or `error` with the analyzed causes as `:error` and what `repl-caught` prints on `:err`, and `done`. It reloads the namespaces whose files changed and the ones that require them, in dependency order, leaves alone the ones nothing loaded, and keeps what `defonce` defined, through a failed reload too, which keeps the namespace that failed for the next one. In a ClojureScript session it answers `done` and `clojure-only`, with a word on `:err`.
- Gotcha: cider-nrepl loads a middleware's namespace the first time its op comes, and clj-reload notes the files' times when it loads, so on a server that's never reloaded, the first reload only starts noting what changes, and takes about half a second. A file changed before then doesn't get reloaded. neorepl sends that first reload in the background as soon as a server it starts with cider-nrepl is up, so `eval --reload` there sees everything since. On other servers, the first `--reload` or `,autoreload` is where it starts. cider-nrepl starting clj-reload with the server would do away with it.
- Gotcha: clj-reload makes the namespaces it reloads anew, and a session in one goes on in the old one, where a function just added to the file doesn't resolve. Unlike tools.namespace's `refresh`, it doesn't put the session back, so neorepl does, after a reload that reloaded something, evaluating `in-ns` there in a way that leaves `*1`, `*2` and `*3` as they were. babashka resolves names through the namespace's name, so it doesn't go stale there.
- Gotcha: the same goes for the aliases and refers of the namespaces a reload leaves alone, like the REPL's own, with clj-reload and with cider-nrepl's `refresh`, whose tools.namespace also makes namespaces anew with `remove-ns` (2026-11-06, cider-nrepl 0.64.0 and clj-reload 1.0.0, whose README says so). They stay on the old namespace and its vars, so a function added to the file since doesn't resolve through the alias, and requiring the namespace with the same alias again fails with "Alias o already exists". Calling a function that was there before works, since compiled code finds vars by their names. So after a reload that reloaded something, neorepl has every alias and refer that's on a namespace that's gone point at the one by that name now, and its var by that name, a few milliseconds with cider-nrepl's namespaces loaded. What has nothing new to point at stays as it was, since an `:as-alias` keeps working for keywords, and another client's reload can be halfway through. It goes by the var's own namespace and name, since potemkin's `import-vars` copies another var's `:ns` and `:name` into its metadata. Only Clojure servers have these reloads, so it's Clojure only. [cider-nrepl#1077](https://github.com/clojure-emacs/cider-nrepl/pull/1077) does it in cider-nrepl after both reloads, for every client, and once a release with it is the oldest one supported, neorepl's can go.
- Output from other threads (2026-11-03, nREPL 1.8.0, with piggieback 0.8.0 too, bb 1.13.227 and 1.13.229, and shadow-cljs 3.5.7, which brings nREPL 1.3.1 and can't). `forward-system-output` swaps in `System/out` and `System/err`, and root bindings of `*out*` and `*err*`, which threads no session started print to, that copy what's printed to the session that asked, as `:out` or `:err` with `:source "system"` on that request's id, after its `done`. An evaluation prints to its session's `*out*` as before, unless it writes to `System/out` itself. nREPL keeps one forwarding per session, so asking again replaces it. cider-nrepl 0.64.0's `cider/out-subscribe`, the way before it, sends nothing to a session that hasn't evaluated anything, and its `cider/out-unsubscribe` leaves the session subscribed and fails to send its answer, which carries the whole request ([cider-nrepl#1074](https://github.com/clojure-emacs/cider-nrepl/issues/1074)), so neorepl leaves it be. What comes forwarded is all the server prints, its own noise included, like the `SocketException` traces of [babashka#2227](https://github.com/babashka/babashka/issues/2227).
- Gotcha: nREPL never drops a session's forwarding, not even once the session's closed ([nrepl#513](https://github.com/nrepl/nrepl/issues/513)), so every later write goes to a connection that's gone, which fails without a word, and the request it keeps holds on to the session, with its `*1` and `*e`. So the REPL asks in a session of its own, which never evaluates anything, and closes it when it ends. And a client in the server's process gets sent what it prints, and prints it again, forever, so `forward-system-output` doesn't ask a server in neorepl's process, nor one neorepl started, whose output the REPL shows already.
- Taps (2026-11-03, cider-nrepl 0.64.0). `cider/tap-subscribe` answers with the subscription's id and `done`, then sends a response for each value tapped, on that message's id, with the status `cider/tap-value` and a `:cider/tap-value`: the value printed short, 8 items, 4 levels and 200 characters at most, as the `:summary`, its `:type`, its `:count` when it's counted, and an `:idx`. `cider/tap-inspect`, with that `:idx` as a string, starts the session's inspector on the value, which cider-nrepl keeps for the last 100 tapped, and says `tap-value-not-retained` for older ones. A subscription from a ClojureScript session polls the runtime's taps instead, which come without an `:idx`, so the REPL subscribes outside its session, for Clojure's. bb 1.13.229 has none of the tap ops. cider-nrepl keeps those 100 values in the server's memory for as long as it runs, subscribed to or not, and once for each subscription, so with CIDER connected too it's the last 50 ([cider-nrepl#1076](https://github.com/clojure-emacs/cider-nrepl/issues/1076)).
Expand Down
9 changes: 9 additions & 0 deletions integration/neorepl/real_servers_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -201,9 +201,12 @@
(let [file (io/file (project-dir :clojure) "src" "demo" "core.clj")]
(run :clojure "reload")
(try
(run :clojure "eval" "-s" "refreshing" "(require '[demo.core :as c])")
(spit file "\n(defn added [] :added)\n" :append true)
(is (= {:status 0 :out "Reloaded demo.core, demo.core-test\n" :err ""} (run :clojure "reload")))
(is (= ":added\n" (:out (run :clojure "eval" "(demo.core/added)"))))
(testing "with the aliases of a namespace it left alone on the one it made anew"
(is (= {:status 0 :out ":added\n" :err ""} (run :clojure "eval" "-s" "refreshing" "(c/added)"))))
(finally
(spit file servers/core)
(run :clojure "reload")))))
Expand Down Expand Up @@ -236,6 +239,12 @@
(is (str/ends-with? err "Reloading what changed failed\n")))
(write! ":fixed")
(is (= {:status 0 :out "[:fixed 1]\n" :err ""} (run-reloading "[(demo.reloaded/f) @demo.reloaded/state]"))))
(testing "with the REPL's aliases and refers on the namespace it made anew"
(run-reloading "(require '[demo.reloaded :as r :refer [f]])")
(save! "\n(defn more [] :more)\n" :append)
(is (= {:status 0 :out "[:more true]\n" :err ""}
(run-reloading "[(r/more) (identical? #'demo.reloaded/f (resolve 'f))]")))
(is (= {:status 0 :out "nil\n" :err ""} (run-reloading "(require '[demo.reloaded :as r])"))))
(testing "with the session back in the namespace it's in, which a reload makes anew"
(run-reloading "(in-ns 'demo.reloaded)")
(save! "\n(defn added [] :added)\n" :append)
Expand Down
59 changes: 50 additions & 9 deletions src/neorepl/features.clj
Original file line number Diff line number Diff line change
Expand Up @@ -652,11 +652,47 @@
normalize-causes
not-empty)))

(def ^:private relink-code
"Has the aliases and refers in every namespace that are on a namespace a
reload made anew point at the new one, and its var by the same name.
cider-nrepl's refresh and clj-reload make the namespaces they reload anew
with remove-ns and leave the others' on the old ones, as clj-reload's
README says, where a function added since isn't there, and the alias
can't be made again. What has nothing new to point at stays as it was,
the way an :as-alias keeps working for keywords. It goes by the var's
own namespace and name, rather than its metadata, which potemkin's
import-vars copies from another var, and a var from Var/create has no
namespace at all. Only Clojure has those reloads, so it does nothing on
babashka. cider-nrepl does the same after its reloads since
cider-nrepl#1077, so this can go once that's in the oldest one supported."
"#?(:bb nil
:clj (clojure.core/let [now #(clojure.core/some-> % clojure.core/ns-name clojure.core/find-ns)]
(clojure.core/doseq [^clojure.lang.Namespace n (clojure.core/all-ns)]
(clojure.core/reduce-kv
(clojure.core/fn [_ a target]
(clojure.core/let [current (now target)]
(clojure.core/when (clojure.core/and current (clojure.core/not (clojure.core/identical? target current)))
(.removeAlias n a)
(.addAlias n a current))))
nil (.getAliases n))
(clojure.core/reduce-kv
(clojure.core/fn [_ s v]
(clojure.core/when (clojure.core/instance? clojure.lang.Var v)
(clojure.core/let [target (.-ns ^clojure.lang.Var v)
^clojure.lang.Namespace current (now target)
^clojure.lang.Var new (clojure.core/when (clojure.core/and current (clojure.core/not (clojure.core/identical? target current)))
(.findInternedVar current (.-sym ^clojure.lang.Var v)))]
(clojure.core/when (clojure.core/and new (.isPublic new))
(.unmap n s)
(.refer n s new)))))
nil (.getMappings n)))))")

(defn reload
"Reloads code and returns {:reloaded [the namespaces]}, and the :output
loading them printed, like reflection warnings, if any. With nses, a seq
of namespace names, those namespaces get reloaded. Without, the ones that
changed since the last reload, which takes cider-nrepl's refresh. A big
changed since the last reload, which takes cider-nrepl's refresh, and the
aliases and refers on them then get relinked, as relink-code says. A big
project takes a while, so it waits as long as that takes unless opts have
a :timeout-ms."
([client nses] (reload client nses {}))
Expand Down Expand Up @@ -686,8 +722,11 @@
(do (ask client {:op "refresh-clear"} opts)
(refresh))
(throw e))
(throw e))))]
[(vec (some :reloading responses)) responses])
(throw e))))
reloaded (vec (some :reloading responses))]
(when (seq reloaded)
(nrepl/request client {:op "eval" :code relink-code}))
[reloaded responses])

:else
(throw (ex-info "Reloading what changed takes cider-nrepl on the server; name the namespaces to reload"
Expand Down Expand Up @@ -1722,12 +1761,13 @@
"Reloads the namespaces whose files changed since the last time, the ones
that depend on them after them, with cider-nrepl's clj-reload, which
keeps what defonce defined and leaves alone namespaces nothing loaded,
and puts session back in its namespace. It runs in session, the way
evaluation/evaluate does, whose opts it takes. Returns nil once it's
done, or else what evaluate does, saying why not, with :clojurescript?
in a ClojureScript session, where there's nothing for it to reload.
The reload note-changes started, client's :noting-changes, goes first,
rather than racing it."
then puts session back in its namespace and relinks the aliases and
refers on the namespaces it made anew, as relink-code says. It runs in
session, the way evaluation/evaluate does, whose opts it takes. Returns
nil once it's done, or else what evaluate does, saying why not, with
:clojurescript? in a ClojureScript session, where there's nothing for it
to reload. The reload note-changes started, client's :noting-changes,
goes first, rather than racing it."
[client session opts]
(when-not (supports? client "cider.clj-reload/reload")
(throw (ex-info "Reloading what changed takes cider-nrepl on the server" {:type ::failed})))
Expand All @@ -1754,6 +1794,7 @@
:else (do (nrepl/request client {:op "eval" :session session :code reenter-code
;; *1 could be anything, even endless.
:nrepl.middleware.print/quota 1})
(nrepl/request client {:op "eval" :code relink-code})
nil))))

(defn note-changes
Expand Down
30 changes: 29 additions & 1 deletion test/neorepl/features_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,10 @@
(transport/respond-to msg :status "error" :error-ns "broken.ns")
(transport/respond-to msg :status "done"))
:else
(do (transport/respond-to msg :reloading ["changed.ns"])
;; Made anew, as tools.namespace does.
(do (remove-ns 'changed.ns)
(intern (create-ns 'changed.ns) 'kept true)
(transport/respond-to msg :reloading ["changed.ns"])
(transport/respond-to msg :err "Reflection warning, changed/ns.clj:3:1\n")
(transport/respond-to msg :status ["ok" "done"])))
"inspect-last-exception" (do (swap! test-requests conj (select-keys msg [:op :index :ex-data :page-size]))
Expand Down Expand Up @@ -571,6 +574,23 @@
noting (doto (Thread. #(do (Thread/sleep 300) (deliver noted true))) .start)]
(features/reload-changed (assoc client :noting-changes noting) session {})
(is (realized? noted))))
(testing "with the aliases and refers of a namespace it left alone on the one it made anew"
(let [before @test-server/reloads]
(values (str "(ns neorepl.reloading) (alias 'r 'neorepl.reloaded)"
" (refer 'neorepl.reloaded :only '[kept reloaded-" before "] :rename '{kept k})"
" (def imported 1) (alter-meta! #'imported assoc :ns (the-ns 'neorepl.reloaded) :name 'kept)"))
(reload)
(is (= ["true"] (values (str "r/reloaded-" @test-server/reloads))))
;; babashka has no reload that makes namespaces anew, so nothing
;; relinks the refers there.
(when-not (System/getProperty "babashka.version")
(is (= ["true"] (values "(identical? #'neorepl.reloaded/kept (resolve 'k))")))
(testing "but not a refer with nothing new to point at, nor a var whose metadata says it's another's, as potemkin's import-vars makes them"
(is (= ["[true 1]"] (values (str "[(some? (resolve 'reloaded-" before ")) @(resolve 'imported)]")))))
(testing "nor a var with no namespace, from Var/create"
(values "(.refer *ns* 'anonymous (clojure.lang.Var/create :anonymous))")
(is (not-any? :ex (nrepl/request client {:op "eval" :code @#'features/relink-code})))
(is (= [":anonymous"] (values "@(resolve 'anonymous)")))))))
(testing "with the session back in its namespace, made anew, and *1, *2 and *3 as they were"
(values "(in-ns 'neorepl.reloaded)")
(values "1 2 3")
Expand Down Expand Up @@ -767,6 +787,14 @@
(is (str/starts-with? (features/source client "mapv" user) "(defn mapv")))
(is (= {:reloaded ["changed.ns"] :output "Reflection warning, changed/ns.clj:3:1\n"}
(features/reload client [])))
(testing "with the aliases and refers of a namespace it left alone on the one it made anew"
(nrepl/request client {:op "eval" :code "(ns neorepl.refreshing) (alias 'c 'changed.ns) (refer 'changed.ns :only '[kept])"})
(features/reload client [])
(let [values #(keep :value (nrepl/request client {:op "eval" :ns "neorepl.refreshing" :code %}))]
(is (= ["true"] (values "(identical? (find-ns 'changed.ns) (get (ns-aliases *ns*) 'c))")))
;; As in reload-changed-test.
(when-not (System/getProperty "babashka.version")
(is (= ["true"] (values "(identical? #'changed.ns/kept (resolve 'kept))"))))))
(testing "after forgetting a namespace whose file is gone, which cider-nrepl keeps after"
(reset! refresh-lost? :gone)
(is (= {:reloaded ["changed.ns"] :output "Reflection warning, changed/ns.clj:3:1\n"}
Expand Down
3 changes: 2 additions & 1 deletion test/neorepl/test_server.clj
Original file line number Diff line number Diff line change
Expand Up @@ -425,12 +425,13 @@
(defn- remake-reloaded!
"Makes the namespace neorepl.reloaded anew, as clj-reload does the ones it
reloads, with a var it didn't have before, named after what reloads
holds, as reloaded-1."
holds, as reloaded-1, and one it always has, kept."
[]
(remove-ns 'neorepl.reloaded)
(let [n (create-ns 'neorepl.reloaded)]
(binding [*ns* n]
(refer 'clojure.core))
(intern n 'kept true)
(intern n (symbol (str "reloaded-" @reloads)) true)))

(defn wrap-fake-reload
Expand Down
Loading