proof: How Strong Is Your nREPL?
For as long as I’ve been involved with nREPL, the question I’ve heard most often from people building servers and clients has been some variation of “Will this work with CIDER?”. And for just as long the honest answer was “Try it and see”. Today I have a better answer for you - proof, a compatibility suite for nREPL servers (and clients). I’ve been working on it since the end of September and proof 0.2 came out a couple of days ago, so I guess it’s high time I told you a bit about it.
Try It and See
The nREPL protocol is tiny. A handful of ops, requests and responses that are
simple maps, bencode on the wire. You can write a basic nREPL server in a
couple of hours, and lots of people have done exactly that. Making it play nice with
CIDER, Calva, Conjure and the rest of the clients out there is a very
different matter, though. What clients actually expect from a server is
spread across the reference implementation, a draft spec, many years of
client code and lots of GitHub issues. Which status values mean what? Is
ops in describe a map or a list? Where does the end of an interrupted
evaluation go? What happens to a session when its client goes away in the
middle of an eval? Server authors usually find out one bug report at a time.
Client authors don’t have it any easier, as every server has its own quirks, and for many years the only way to find them was to try your client with every server you could get your hands on. I’ve been on both sides of this conversation for well over a decade (CIDER is a client, nREPL is a server), so believe me - I know the pain.
Bigger Than Clojure
Why bother? Because I’ve always seen nREPL as something bigger than Clojure.
Back in 2019 I wrote that nREPL is similar in nature to
LSP, only much simpler, and I
still believe that. For any language that can evaluate code at runtime, nREPL
is a much simpler alternative to LSP. Implement eval, interrupt and
load-file, throw in completions and lookups if you feel like it, and every
nREPL client out there becomes an editor integration for your language.
That’s not just a dream anymore. These days there are nREPL servers for Clojure dialects running on Python (Basilisp), C++ (jank), .NET (ClojureCLR), Go (let-go) and Node.js (nbb), for Janet and Steel Scheme, and even for Erlang and Elixir (more on those later). Still, “language-agnostic” doesn’t mean much if everyone who builds a server has to check it against CIDER by hand.
Better Late Than Never
The idea of a compatibility suite is not new. When nREPL 1.0 came out in 2022 I wrote that improving the protocol spec and the ability to test against it was where our focus should go next, and a year later the lack of a compatibility test suite made my list of things people hate about nREPL. I even asked if one of you was looking for a fun small OSS project. Nobody volunteered, and the idea spent a few more years on the backburner. This year, thanks to the support of Clojurists Together, I finally made a very focused push on it.
It started with the spec. Late last year Phil Hagelberg wrote a draft of the nREPL protocol spec, which landed in May. A spec is great, but you can’t run a spec against your server, so I started proof. The two feed each other - every time proof finds a place where the spec and the clients disagree, or something the spec doesn’t cover at all, it ends up in a running list, and from there in the spec itself. (A few of those are already pull requests against it.)
When the spec and the clients disagree, proof sides with the clients. For
instance, the spec recommends returning the ops in describe as a list, but
CIDER and Calva both expect a map. A server that follows the spec to the
letter, but that CIDER can’t connect to, is not much use to anyone. That’s
also why proof is a compatibility suite and not a conformance suite. You
can only conform to an official spec, and nREPL doesn’t have one yet.
Checking Servers
Checking servers is proof’s main job. It’s a single binary1 that talks to your server over a socket, the same way any client would. It knows nothing about the server’s internals and it doesn’t care what language the server is written in. What it does need is a profile - a small TOML file that tells proof how to start your server and gives it a few snippets of code in the server’s language (an idea I borrowed from jupyter_kernel_test):
name = "My server"
[launch]
command = ["my-server", "--port", "0"]
port-pattern = 'nREPL server started on port (\d+)'
[snippets.value]
code = "(+ 1 2)"
value = "3"
Then you install proof and run it:
$ go install github.com/nrepl/proof/cmd/proof@latest
$ proof run my-server.toml
proof starts your server, runs about 80 checks against it and shuts it down
afterwards. (You can also grab a binary from the
releases, if you don’t have Go
around.) Profiles for nREPL, Babashka, nbb, Basilisp, ClojureCLR, jank,
let-go, dialtone and repartee come with proof, so proof run babashka just
works if you have Babashka installed.
The most important decision in proof’s design is how it grades the checks. A check fails only when some client won’t work properly with your server (or when users will lose data), and the report tells you which client it is and links to the client code in question, pinned to a specific commit. If your server simply does something differently from the reference implementation, you get a warning instead. Here’s a failure from a run against Babashka 1.13.226:
FAIL interrupt.other-connection An interrupted eval ends on its own connection
the interrupted eval's done went to the connection the interrupt came from
why: neorepl interrupt stops an eval that another neorepl process
started, and that process waits for the eval's done on its own
connection forever if it goes anywhere else.
see: neorepl interrupt stops an eval from another process
https://github.com/nrepl/neorepl/blob/462a182c.../src/neorepl/main.clj#L374-L385
see: nrepl/nrepl#498 https://github.com/nrepl/nrepl/issues/498
The idea is that a failure should always be worth fixing and never be a matter of taste. If you think some failure isn’t justified, the links show you exactly what you need to argue with.
Some checks go a step further and send exactly what a particular client
sends, the way it sends it. cider.connect sends the requests CIDER sends
while connecting, calva.eval evaluates code the way Calva does, and so on
for Conjure, vim-fireplace, REPLy, neorepl, clojure-cli.repl and
bb repl --connect. That way the report tells you directly whether those clients will
work with your server.
Once your server passes everything it can pass, you can list the failures you can’t fix yet as expected failures in its profile and run proof in your CI with its GitHub Action. When an expected failure starts passing, proof fails the build and asks you to remove it from the list, so the list can’t go stale.
Last, but not least, the bundled profiles run every day and their reports end up in a compatibility matrix, which shows where the different servers agree, where they don’t and where the spec needs some work.
Checking Clients
Servers were always proof’s primary focus, but once the foundation was in place people started asking me how they could check their clients with it. That made a lot of sense to me - after all, the same knowledge cuts both ways. proof has two tools for client authors.
proof proxy sits between a client and a server, passes everything along as
is and records it on the way. You use your client for a while (or run its
test suite through the proxy), press Ctrl-C and proof grades every request
the client sent. Here’s a quick neorepl eval going through it:
$ proof proxy -listen 127.0.0.1:7888 babashka
$ neorepl eval --port 7888 '(println "hi") (+ 1 2)'
client
PASS client.bencode Every frame is valid bencode
PASS client.dict Every request is a dict
PASS client.canonical Frames use canonical bencode
PASS client.id Every request has an id
PASS client.active-id An id isn't reused while its request is active
PASS client.field-types Request fields have the right types
PASS client.required-fields Requests carry the fields their op needs
PASS client.need-input need-input is answered with stdin in the same session
WARN client.close Sessions are closed before disconnecting
PASS client.unknown-op Requests use ops the server supports
9 passed, 0 failed, 1 warnings, 0 skipped
The grading rule is the same, just turned around. A client check fails only
when some server breaks, and it links to the server code in question. (That
warning is fine, by the way. neorepl keeps its session around on purpose, so
the next neorepl eval can use it.)
A proxy sees only what goes over the wire, though, so it can’t tell you how
your client copes with the replies of different servers. That’s what
proof serve is for. It’s a small nREPL server for client test suites, which
behaves like nREPL unless you ask it to behave like some other server in some
particular way - send only the value of the last form (like Basilisp, jank and
dialtone), drop what the code prints to stderr, have no interrupt op,
deliver its replies a byte at a time, and so on. None of those scenarios are
made up. Every one of them is something a real server does (or something TCP
can do to any server’s replies). -like turns on all the quirks of a server
at once, so you can run your client’s tests against “jank” without installing
jank:
$ proof serve -listen 127.0.0.1:7888 -like jank
proof in Practice
So far, so good. But does it actually find anything? You bet! Here are some of the things proof turned up in the past couple of months:
- nREPL itself sent the end of an interrupted eval to the connection the
interrupt came from (fixed on master), drops the evals waiting behind an
interrupted one (#503) and
can leave a session that stops sending
doneafter its client goes away in the middle of an eval (#509). Oops! - Babashka, nbb, Basilisp, jank and ClojureCLR all had their share of rough
edges, e.g. errors without an
eval-errorstatus, sessions sharing*1and the current namespace, and a request that throws taking its whole connection down. - CIDER skipped part of its setup when connecting to a server without cider-nrepl, Calva and Conjure didn’t sort the keys of the dicts they sent, vim-fireplace and REPLy never close their sessions, and Conjure’s interrupts don’t say which eval to stop.
Michiel Borkent fixed the Babashka and nbb issues the same day I reported them (or the next one), Conjure fixed its encoder the next day and Calva within a week. That’s the Clojure community for you!
I was also super happy to see other people picking up proof before I even got
around to announcing it. Tom Waddington has been running it against his nREPL
implementations for Janet and
Steel Scheme since early October
(nrepl-janet even tests its client with proof serve), Jeaye Wilkerson
suggested using it for
jank’s nREPL server, and Kyle Cesare is already
fixing what it found there.
That’s exactly what I was hoping for!
The projects that benefited from proof the most were my own, though. This year I built a few toy nREPL implementations, mostly for fun and to see how well the protocol holds up outside of Clojure:
- nrepl-beam has nREPL servers for
Erlang (dialtone) and Elixir (repartee). proof found that they didn’t answer
closewithsession-closed, didn’t echo unknown ops and returned only the last value of several forms, and fixing those uncovered a race aftercloseand temporary sessions that never stopped. - mezcaml, my little OCaml client, read replies as if only one request was ever in flight, so late output from one eval could end the next one early.
- neat, my language-agnostic client for
Emacs, got late output, errors without
errand unknown sessions wrong. - nrepl-sci, the nREPL server behind the “Try it” section of nrepl.org, was the first server I wrote with proof around from day one, and working on it mostly came down to running proof after every change.
And then there’s
neorepl,
the CIDER-inspired terminal REPL I’ve been working on lately, which is just as
happy to be used by coding agents as by humans. neorepl and proof pretty much
grew up together. I ran neorepl through proof proxy and every scenario of
proof serve, which caught a few bugs before neorepl 0.1 was out, and proof
checks every server with the exact requests neorepl sends. It’s also how I
found the interrupt bugs in nREPL above, which hit neorepl harder than most
clients. Its commands are separate processes sharing a session, so
neorepl interrupt stops an eval that some neorepl eval started, and that
neorepl eval used to wait for its done forever.
A Tool for Tool Authors
I guess I should spell out who proof is for. If you’re using nREPL through CIDER or Calva you’ll probably never run proof yourself (although hopefully you’ll feel its effects). proof is a tool for tool authors - people writing nREPL servers, clients and middleware.
I really hope that it will make it a lot easier to grow the nREPL ecosystem. These days anyone can put together an nREPL server or client in no time with the help of tools like Claude. Writing the code is no longer the hard part - knowing whether the result actually works with CIDER, Calva and friends is. proof gives you (and your coding agent) a fast feedback loop and failures that explain themselves: what went wrong, why it matters and which client code depends on it. That’s usually all an agent needs to fix the problem.
Why “proof”?
Naming is hard! Luckily, the nREPL world has a tradition that makes it a bit easier, as many nREPL clients are named after drinks (CIDER, neat, mezcaml, chaser and so on). proof is not a client, so it bends the rule a bit, but it still belongs at the bar. The proof of a spirit is a measure of its strength, which is more or less what this tool measures for nREPL servers. And running it gives you proof that your server works with the existing clients (or a list of the reasons why it doesn’t). Two meanings for the price of one!2
Epilogue
proof is still in its early days, but it already covers the core of the protocol, the exact requests of the most popular clients and the server quirks clients have to deal with. Now it needs more users.
So, if you’re working on an nREPL server or client, please take proof out for a spin and let me know how it went! I’d love to see your server in the compatibility matrix, and if you think some failure isn’t fair, open an issue and let’s argue about it. And if you’re a casual Clojure hacker who’d just like a nice REPL in the terminal, give neorepl a try:
$ bbin install io.github.nrepl/neorepl
Big thanks to Clojurists Together for supporting my work on nREPL, to Phil Hagelberg for getting the spec going, and to the maintainers of all the servers and clients who were so quick to fix the problems proof found. You rock! In the (n)REPL we trust!
That’s all I have for you today. Keep hacking!
-
Written in Go, of all things. A Clojure tool would have been the obvious choice, but proof shouldn’t favour any of the languages it checks, and a single binary without a runtime is easy to drop into any CI. ↩
-
“hangover” would have been a good name for what you get when a server forgets to send
done, but it’s a hard sell for a project. ↩