From 02f2a9414291b405d8287edc2957f6a2cad35c9e Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Fri, 23 Oct 2026 16:37:13 +0300 Subject: [PATCH] Check how servers handle interrupt Every interactive client sends interrupt, and getting it wrong leaves CIDER without a prompt, Calva with a result that never comes and Conjure (which never sends interrupt-id) with an eval that keeps running. The new checks interrupt a sleep snippet, with and without interrupt-id, and look at what follows: the session, evals that were waiting for it and interrupts from another connection. nREPL 1.8.0 and Babashka drop the evals that were waiting (nrepl#503) and send the interrupted eval's done to the interrupt's connection (nrepl#498), so they fail two of them. proof serve now stops an interrupted eval the way dialtone and repartee do, and has those bugs, along with the exception nREPL sends after the done (nrepl#506), as scenarios that -like clojure and -like babashka turn on. --- CHANGELOG.md | 2 + PLAN.md | 46 +++---- README.md | 6 +- doc/design.md | 7 +- doc/hacking.md | 5 +- doc/profiles.md | 13 ++ doc/spec-changes.md | 18 ++- doc/usage.md | 7 +- internal/checks/all.go | 1 + internal/checks/checks_test.go | 27 ++++- internal/checks/fake_test.go | 101 +++++++++++++++- internal/checks/interrupt.go | 215 +++++++++++++++++++++++++++++++++ internal/checks/refs.go | 11 ++ internal/checks/stdin.go | 8 +- internal/serve/matrix_test.go | 33 +++-- internal/serve/scenarios.go | 9 ++ internal/serve/serve.go | 24 +++- internal/serve/serve_test.go | 124 +++++++++++-------- internal/serve/session.go | 14 +++ profiles/babashka.toml | 5 + profiles/clojure.toml | 5 + profiles/dialtone.toml | 5 + profiles/repartee.toml | 5 + 23 files changed, 588 insertions(+), 103 deletions(-) create mode 100644 internal/checks/interrupt.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 60b3a3b..e14ca12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,3 +28,5 @@ Initial release. - [#10](https://github.com/nrepl/proof/pull/10): Binaries for Linux, macOS and Windows on every release, and a GitHub Action for running proof in the CI of a server. +- [#11](https://github.com/nrepl/proof/pull/11): Checks for `interrupt`, and + scenarios for nREPL's interrupt bugs in `proof serve`. diff --git a/PLAN.md b/PLAN.md index 4882605..2c79761 100644 --- a/PLAN.md +++ b/PLAN.md @@ -64,27 +64,31 @@ lookups and completions, plus requests that go wrong. ### interrupt -- Checks for interrupting a running eval (the eval gets `interrupted` - and `done`, the interrupt gets a `done` of its own), interrupting an - idle session (`session-idle`), an `interrupt-id` that doesn't match - and the session working afterwards. -- An interrupt sent right after its eval. nREPL marks a session as - running only once the eval leaves the session's queue, so such an - interrupt can find the session idle - ([nrepl#504](https://github.com/nrepl/nrepl/issues/504)). The evals - waiting behind an interrupted one - ([nrepl#503](https://github.com/nrepl/nrepl/issues/503)) and the - exception of an interrupted eval, which nREPL sends after its `done` - ([nrepl#506](https://github.com/nrepl/nrepl/issues/506)), need checks - too. -- Where an interrupted eval's `done` goes when the interrupt comes from - another connection - ([nrepl#498](https://github.com/nrepl/nrepl/pull/498)). Once an nREPL - release has the fix, `profiles/clojure.toml` and `proof serve` move to - it. -- A step option for client profiles that sends a request without waiting - for its reply, so profiles can send `interrupt` and `stdin` while an - eval is running, the way CIDER, Calva and Conjure do. +- Done: checks for interrupting a running eval, with and without + `interrupt-id` (Conjure never sends one), the `interrupted` status, an + idle session, an `interrupt-id` that doesn't match and the session + working afterwards. +- Done: the evals waiting behind an interrupted one, which nREPL 1.8.0 + and Babashka drop + ([nrepl#503](https://github.com/nrepl/nrepl/issues/503)), and where an + interrupted eval's `done` goes when the interrupt comes from another + connection, which nREPL's main branch gets right now + ([nrepl#498](https://github.com/nrepl/nrepl/pull/498)). The exception + of an interrupted eval, which nREPL sends after its `done` + ([nrepl#506](https://github.com/nrepl/nrepl/issues/506)), is + `wire.after-done`'s warning. +- Done: `proof serve` interrupts like dialtone and repartee do, and has + nREPL's bugs as scenarios (`late-exception`, `drop-queued` and + `interrupt-elsewhere`), which `-like clojure` and `-like babashka` + turn on. +- Not done: an interrupt sent right after its eval, which can find + nREPL's session idle + ([nrepl#504](https://github.com/nrepl/nrepl/issues/504)). That's a + race, so a check can't count on hitting it. +- Not needed: a step option for client profiles that sends a request + without waiting for its reply. The `interrupt` requests of CIDER, + Calva and vim-fireplace are plain enough for `interrupt.running`, and + Conjure's for `interrupt.no-id`. ### load-file diff --git a/README.md b/README.md index d272d3a..62a069c 100644 --- a/README.md +++ b/README.md @@ -131,14 +131,14 @@ the replies). `-like jank` turns on all of jank's scenarios at once, and ## Status proof is still in its early days. Right now it covers the core of the -protocol (`describe`, unknown ops, `eval`, sessions, `stdin` and the wire format), +protocol (`describe`, unknown ops, `eval`, sessions, `stdin`, `interrupt` and the wire format), what CIDER, Calva, Conjure, vim-fireplace, REPLy and neorepl send while connecting and evaluating code, the requests clients send and the server differences clients have to deal with, and `proof list` will show you all the checks. -Checks for `interrupt`, `load-file`, lookups, completions and malformed -requests are coming next, along with the first release. +Checks for `load-file`, lookups, completions and malformed requests are +coming next, along with the first release. [PLAN.md](PLAN.md) has all the details. ## Why "proof"? diff --git a/doc/design.md b/doc/design.md index f362d53..148bd7e 100644 --- a/doc/design.md +++ b/doc/design.md @@ -209,8 +209,9 @@ a small piece of Clojure. That's enough for the snippets of the nREPL profile, the code CIDER and vim-fireplace send when they connect and what client tests need (output, values, errors, input, something to interrupt), and it gives the same replies as nREPL 1.8.0 for the same -code. Code in other languages wouldn't help, as no client's tests send -Erlang to a server. +code. The only exceptions are nREPL's bugs around interrupts, which +proof serve has only as scenarios (e.g. `drop-queued`). Code in other +languages wouldn't help, as no client's tests send Erlang to a server. Some rules are about what a client leaves behind - sessions that were never closed and `need-input` that was never answered. They apply only @@ -305,6 +306,6 @@ to have a single test suite that every server can be checked against. ## Future Plans At this point proof covers the core of the protocol (`describe`, unknown -ops, sessions, `eval`, `stdin` and the wire format), the requests of +ops, sessions, `eval`, `stdin`, `interrupt` and the wire format), the requests of clients and the server differences clients have to deal with. [PLAN.md](../PLAN.md) has what's planned next. diff --git a/doc/hacking.md b/doc/hacking.md index cc49d21..2b4dc6d 100644 --- a/doc/hacking.md +++ b/doc/hacking.md @@ -133,8 +133,9 @@ Here's what each of its fields means: | `Snippets`, `Needs` | The snippets and capabilities the check uses. Checks are skipped when the profile doesn't provide them. `t.Snippet` panics if a check uses a snippet that it didn't declare, which the tests catch, so the two can't get out of sync. | | `Requires` | The checks this check depends on. Everything that creates a session requires `session.clone`, and everything that looks at the response of `describe` requires `describe.reply`. | -Checks are grouped by area in `describe.go`, `op.go`, `session.go` and -`eval.go`, and they run in the order listed in `all.go`. If you're adding +Checks are grouped by area in `describe.go`, `op.go`, `session.go`, +`eval.go`, `stdin.go` and `interrupt.go`, and they run in the order +listed in `all.go`. If you're adding a new area, put its checks in a new file and add them to `all.go`. In `Run` a check talks to the server via `t`: diff --git a/doc/profiles.md b/doc/profiles.md index 7c3e941..2ec58f2 100644 --- a/doc/profiles.md +++ b/doc/profiles.md @@ -56,6 +56,15 @@ value = ":proof-marker" code = "1 2" values = ["1", "2"] +[snippets.read-line] +code = "(read-line)" +value = '"proof"' +eof = "nil" + +[snippets.sleep] +code = '(do (print "proof") (flush) (Thread/sleep 60000))' +out = "proof" + [expected-failures] "eval.multiple-forms" = "only the last value is returned, see #42" ``` @@ -129,6 +138,7 @@ snippet is missing, all the checks that need it are skipped. | `session-state` | `code`, `use`, `value` | leave some state in the session that `use` reads back as `value` | `session.across-connections`, `session.isolated` | | `multiple` | `code`, `values` | consist of several forms that evaluate to `values` (in order) | `eval.multiple-forms` | | `read-line` | `code`, `value`, `eof` | read a line from stdin, evaluating to `value` when the line is `proof` and to `eof` at the end of input | `stdin.need-input`, `stdin.roundtrip`, `stdin.eof` | +| `sleep` | `code`, `out` | print `out` and then keep running for longer than the `timeout` (e.g. a minute), so there's something to interrupt | all the `interrupt.*` checks | A few tips for writing snippets: @@ -156,6 +166,9 @@ A few tips for writing snippets: - proof evaluates the `use` of `session-state` in a new session as well, where it must not return `value` (an error is fine). +- proof interrupts `sleep` as soon as its `out` arrives, so make sure + your server sends the output right away (in Clojure that takes a + `flush`, as there's no newline). ## Expected Failures diff --git a/doc/spec-changes.md b/doc/spec-changes.md index 0cf1b1e..e308e63 100644 --- a/doc/spec-changes.md +++ b/doc/spec-changes.md @@ -60,7 +60,23 @@ dialtone respond to the interrupt request with a plain `done` (or with latter two along with `error` in nREPL's case) and add `interrupted` and `done` to the response of the interrupted eval request. The spec's fallback when there's no `interrupt-id` ("the most recent request of the -current session") also assumes sessions. +current session") also assumes sessions. Conjure depends on that +fallback, as it never sends `interrupt-id`. + +The spec doesn't say what happens around an interrupt either, and nREPL +1.8.0 gets some of it wrong: + +- The evals waiting for the interrupted one should still run. nREPL + drops them ([nrepl#503](https://github.com/nrepl/nrepl/issues/503)), so + they never get their `done`, while CIDER sends evals while another one + is running all the time. +- The `done` of the interrupted eval should go to the connection the + eval came from, even when the interrupt came from another one. nREPL + 1.8.0 sends it to the interrupt's connection + ([nrepl#498](https://github.com/nrepl/nrepl/pull/498) fixes that). +- Nothing should follow the `done` of the interrupted eval, but nREPL + sends the exception the interrupted code threw after it + ([nrepl#506](https://github.com/nrepl/nrepl/issues/506)). ### Reused Request IDs in the Examples diff --git a/doc/usage.md b/doc/usage.md index 03b5a8c..47b8baf 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -365,6 +365,9 @@ the scenarios, and every one of them is something a real server does | `ns-fallback` | An eval in a namespace that doesn't exist runs in the current one | jank | | `ns-error` | An eval in a namespace that doesn't exist fails without `namespace-not-found` | Basilisp | | `eof-error` | Reading past the end of input fails instead of returning `nil` | nREPL 1.7.0 | +| `late-exception` | An interrupted eval's exception comes after its `done`, and the rest of its code still runs | nREPL 1.8.0 and Babashka | +| `drop-queued` | Evals waiting for one that gets interrupted never run | nREPL 1.8.0 and Babashka | +| `interrupt-elsewhere` | An interrupted eval's `done` goes to the connection the interrupt came from | nREPL 1.8.0 and Babashka | | `unsorted-keys` | The keys of reply dicts aren't sorted | jank | | `byte-writes` | Replies are written a byte at a time | any server | | `batched-writes` | Replies are held back and written together until the eval waits or ends | any server | @@ -396,7 +399,9 @@ once for every scenario. proof can't evaluate real Clojure, of course. Instead it understands just enough of it for tests, and gives the same replies as nREPL 1.8.0 does -for the same code: +for the same code. The only exceptions are nREPL's bugs around +interrupts (`late-exception`, `drop-queued` and `interrupt-elsewhere`), +which you get with `-like clojure`: | Code | What it does | |---|---| diff --git a/internal/checks/all.go b/internal/checks/all.go index 606fddc..bdd4512 100644 --- a/internal/checks/all.go +++ b/internal/checks/all.go @@ -10,6 +10,7 @@ func All() []*check.Check { all = append(all, sessionChecks()...) all = append(all, evalChecks()...) all = append(all, stdinChecks()...) + all = append(all, interruptChecks()...) all = append(all, clientProfileChecks()...) return all } diff --git a/internal/checks/checks_test.go b/internal/checks/checks_test.go index d6a3ed4..a1b53a2 100644 --- a/internal/checks/checks_test.go +++ b/internal/checks/checks_test.go @@ -26,6 +26,7 @@ var fakeProfile = &profile.Profile{ "session-state": {Code: "marker", Use: "*1", Value: ":proof-marker"}, "multiple": {Code: "1 2", Values: []string{"1", "2"}}, "read-line": {Code: "read", Value: `"proof"`, Eof: "nil"}, + "sleep": {Code: "sleep", Out: "proof"}, }, } @@ -51,17 +52,22 @@ func TestChecksCatchMisbehaviour(t *testing.T) { // What every client but REPLy goes through first. connects := map[string]check.Verdict{"cider.connect": F, "calva.connect": F, "conjure.connect": F, "fireplace.connect": F, "neorepl.connect": F} + // The interrupt checks, which can't tell whether the server has the op. + noInterrupts := map[string]check.Verdict{} + for _, c := range interruptChecks() { + noInterrupts[c.ID] = S + } cases := []struct { name string q quirks want map[string]check.Verdict }{ // Conjure does without the features that need ops. - {"ops as a list", quirks{opsList: true}, checktest.Merged(connects, map[string]check.Verdict{"describe.ops-dict": F, + {"ops as a list", quirks{opsList: true}, checktest.Merged(connects, noInterrupts, map[string]check.Verdict{"describe.ops-dict": F, "describe.required-ops": S, "stdin.need-input": S, "stdin.roundtrip": S, "stdin.eof": S, "conjure.connect": check.Pass})}, {"clone not advertised", quirks{noClone: true}, map[string]check.Verdict{"describe.required-ops": F}}, {"no versions", quirks{noVersions: true}, map[string]check.Verdict{"describe.versions": W}}, - {"describe kills the connection", quirks{crashOnDescribe: true}, checktest.Merged(connects, map[string]check.Verdict{ + {"describe kills the connection", quirks{crashOnDescribe: true}, checktest.Merged(connects, noInterrupts, map[string]check.Verdict{ "describe.reply": F, "describe.ops-dict": S, "describe.required-ops": S, "describe.versions": S, "stdin.need-input": S, "stdin.roundtrip": S, "stdin.eof": S})}, {"no unknown-op", quirks{noUnknownOp: true}, map[string]check.Verdict{"op.unknown": F, "op.unknown-echo": W}}, @@ -76,7 +82,8 @@ func TestChecksCatchMisbehaviour(t *testing.T) { "neorepl.eval": F}}, {"any session accepted", quirks{acceptAnySession: true}, map[string]check.Verdict{"session.unknown": F, "session.closed": F}}, {"shared session state", quirks{sharedState: true}, map[string]check.Verdict{"session.isolated": F}}, - {"sessions tied to sockets", quirks{socketSessions: true}, map[string]check.Verdict{"session.across-connections": W}}, + {"sessions tied to sockets", quirks{socketSessions: true}, map[string]check.Verdict{"session.across-connections": W, + "interrupt.other-connection": S}}, {"no ephemeral sessions", quirks{noEphemeral: true}, map[string]check.Verdict{"session.ephemeral": F, "fireplace.connect": F}}, {"last value only", quirks{lastValueOnly: true}, map[string]check.Verdict{"eval.multiple-forms": F, "fireplace.connect": F}}, {"stderr dropped", quirks{dropErr: true}, map[string]check.Verdict{"eval.stderr": F}}, @@ -102,7 +109,7 @@ func TestChecksCatchMisbehaviour(t *testing.T) { {"integer value", quirks{intValue: true}, map[string]check.Verdict{ "wire.field-types": F, "eval.value": F, "eval.survives-error": F, "eval.multiple-forms": F, "session.ephemeral": F, "session.persistent": F, "cider.eval": F, "cider.repl": F, "calva.eval": F, "conjure.eval": F, "fireplace.eval": F, - "neorepl.eval": F}}, + "neorepl.eval": F, "interrupt.survives": F, "interrupt.queued": F}}, {"unsorted keys", quirks{unsortedKeys: true}, map[string]check.Verdict{"wire.canonical": W}}, {"invalid UTF-8", quirks{badUTF8: true}, map[string]check.Verdict{"wire.utf8": W, "eval.stdout": F}}, {"no stdin op", quirks{noStdinOp: true}, map[string]check.Verdict{ @@ -114,6 +121,18 @@ func TestChecksCatchMisbehaviour(t *testing.T) { "stdin.roundtrip": F, "stdin.eof": W}}, {"EOF is an error", quirks{eofError: true}, map[string]check.Verdict{"stdin.eof": W}}, {"input never arrives", quirks{dropStdin: true}, map[string]check.Verdict{"stdin.roundtrip": F, "stdin.eof": W}}, + {"no interrupt op", quirks{noInterruptOp: true}, noInterrupts}, + {"interrupt does nothing", quirks{interruptIgnored: true}, checktest.Merged(noInterrupts, + map[string]check.Verdict{"interrupt.running": F, "interrupt.idle": check.Pass})}, + {"interrupt needs interrupt-id", quirks{needsInterruptID: true}, map[string]check.Verdict{"interrupt.no-id": F}}, + {"no interrupted", quirks{noInterrupted: true}, map[string]check.Verdict{"interrupt.status": W}}, + {"no session-idle", quirks{noSessionIdle: true}, map[string]check.Verdict{"interrupt.idle": W}}, + {"interrupt-id ignored", quirks{anyInterruptID: true}, map[string]check.Verdict{"interrupt.id-mismatch": W}}, + {"queued evals dropped", quirks{dropQueued: true}, map[string]check.Verdict{"interrupt.queued": F}}, + // The interrupt's connection never sent the eval. + {"interrupted on the wrong connection", quirks{interruptHere: true}, map[string]check.Verdict{ + "interrupt.other-connection": F, "wire.id": W}}, + {"session dies on interrupt", quirks{diesOnInterrupt: true}, map[string]check.Verdict{"interrupt.survives": F, "interrupt.queued": F}}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { diff --git a/internal/checks/fake_test.go b/internal/checks/fake_test.go index 52beba2..bd7a7ad 100644 --- a/internal/checks/fake_test.go +++ b/internal/checks/fake_test.go @@ -54,6 +54,25 @@ type quirks struct { eofError bool // an empty stdin fails the read dropStdin bool // stdin input never reaches the read flatFields bool // a request with a dict or list in it kills the connection + noInterruptOp bool // describe doesn't list interrupt + interruptIgnored bool // interrupt says done, but the eval goes on + noInterrupted bool // an interrupted eval ends with a plain done + noSessionIdle bool // interrupting an idle session gets a plain done + anyInterruptID bool // interrupt stops the running eval, whatever its interrupt-id + needsInterruptID bool // an interrupt without interrupt-id leaves the running eval alone + dropQueued bool // evals queued behind an interrupted one never run + interruptHere bool // the interrupted eval's done goes to the interrupt's connection + diesOnInterrupt bool // an interrupted session never answers again +} + +var unknownSession = map[string]any{"status": []any{"error", "unknown-session", "done"}} + +// fakeRun is an eval that runs until it's interrupted, on the connection +// it came from. +type fakeRun struct { + c net.Conn + req nrepl.Message + local map[string]bool } type fakeSession struct { @@ -61,6 +80,11 @@ type fakeSession struct { vars map[string]string // pending is an eval waiting for stdin. pending nrepl.Message + // running is the eval that runs until it's interrupted, and queue the + // evals waiting for it to finish. + running *fakeRun + queue []fakeRun + dead bool } type fakeServer struct { @@ -187,6 +211,9 @@ func (s *fakeServer) handle(c net.Conn, req nrepl.Message, local map[string]bool if !q.noStdinOp { names = append(names, "stdin") } + if !q.noInterruptOp { + names = append(names, "interrupt") + } var ops any if q.opsList { l := []any{} @@ -212,7 +239,7 @@ func (s *fakeServer) handle(c net.Conn, req nrepl.Message, local map[string]bool session = "7:session" + strconv.Itoa(len(sess)) + ":" + sess } c.Write([]byte("d6:statusl4:donee2:id" + strconv.Itoa(len(req.Str("id"))) + ":" + req.Str("id") + - "3:opsd8:describede4:evalde5:clonede5:closede5:stdindee" + session + + "3:opsd8:describede4:evalde5:clonede5:closede5:stdinde9:interruptdee" + session + "8:versionsd4:faked14:version-string3:1.0eee")) return true } @@ -242,8 +269,7 @@ func (s *fakeServer) handle(c net.Conn, req nrepl.Message, local map[string]bool case "stdin": s.stdin(c, req, local) case "interrupt": - // Nothing runs long enough to be interrupted. - s.send(c, req, map[string]any{"status": []any{"session-idle", "done"}}) + s.interrupt(c, req, local) default: switch { case q.statusString: @@ -285,11 +311,71 @@ func (s *fakeServer) stdin(c net.Conn, req nrepl.Message, local map[string]bool) s.send(c, req, map[string]any{"status": []any{"done"}}) } +// interrupt stops the eval running in the session, like nREPL: the eval +// gets done and interrupted, then the interrupt gets its done, and the +// evals waiting for the session run after that. +func (s *fakeServer) interrupt(c net.Conn, req nrepl.Message, local map[string]bool) { + q := s.q + sess, ok := s.session(req, local) + if !ok { + s.send(c, req, unknownSession) + return + } + s.mu.Lock() + run := sess.running + // status is the reply of an interrupt that leaves the session as it is. + var status []any + switch { + case run == nil && q.noSessionIdle: + status = []any{"done"} + case run == nil: + status = []any{"done", "session-idle"} + case req.Has("interrupt-id") && req.Str("interrupt-id") != run.req.Str("id") && !q.anyInterruptID: + status = []any{"done", "interrupt-id-mismatch", "error"} + case !req.Has("interrupt-id") && q.needsInterruptID, q.interruptIgnored: + status = []any{"done"} + } + if status != nil { + s.mu.Unlock() + s.send(c, req, map[string]any{"status": status}) + return + } + sess.running = nil + queue := sess.queue + sess.queue = nil + sess.dead = q.diesOnInterrupt + s.mu.Unlock() + to, status := run.c, []any{"done", "interrupted"} + if q.interruptHere { + to = c + } + if q.noInterrupted { + status = []any{"done"} + } + s.send(to, run.req, map[string]any{"status": status}) + s.send(c, req, map[string]any{"status": []any{"done"}}) + if !q.dropQueued { + for _, r := range queue { + s.eval(r.c, r.req, r.local) + } + } +} + func (s *fakeServer) eval(c net.Conn, req nrepl.Message, local map[string]bool) { q := s.q sess, ok := s.session(req, local) if !ok { - s.send(c, req, map[string]any{"status": []any{"error", "unknown-session", "done"}}) + s.send(c, req, unknownSession) + return + } + s.mu.Lock() + busy := sess.running != nil + if busy { + sess.queue = append(sess.queue, fakeRun{c, req, local}) + } + skip := busy || sess.dead + s.mu.Unlock() + if skip { return } code, hasCode := req["code"].(string) @@ -361,6 +447,13 @@ func (s *fakeServer) eval(c net.Conn, req nrepl.Message, local map[string]bool) } finish() return + case "sleep": + // Running before it says so, so an interrupt can't miss it. + s.mu.Lock() + sess.running = &fakeRun{c, req, local} + s.mu.Unlock() + s.send(c, req, map[string]any{"out": "proof"}) + return case "define": sess.vars["answer"] = "42" value = "#'user/answer" diff --git a/internal/checks/interrupt.go b/internal/checks/interrupt.go new file mode 100644 index 0000000..5aa4148 --- /dev/null +++ b/internal/checks/interrupt.go @@ -0,0 +1,215 @@ +package checks + +import ( + "slices" + "strings" + + "github.com/nrepl/proof/internal/check" + "github.com/nrepl/proof/nrepl" +) + +func interruptChecks() []*check.Check { + return []*check.Check{ + { + ID: "interrupt.running", + Title: "interrupt stops a running eval", + Severity: check.Fail, + Why: "An interrupted eval has to end with done: CIDER shows the REPL's next prompt and stops its spinner only then, and Calva's result for it never settles without it.", + Refs: []check.Ref{ciderReplPrompt, ciderSpinner, calvaInterrupted, specInterrupt, nreplInterrupt}, + Snippets: []string{"sleep"}, + Requires: []string{"describe.reply", "describe.ops-dict", "session.clone"}, + Run: func(t *check.T) { + s := startSleep(t) + defer s.stop() + s.interrupt(t, s.id) + }, + }, + { + ID: "interrupt.no-id", + Title: "An interrupt without interrupt-id stops what's running", + Severity: check.Fail, + Why: "Conjure never sends interrupt-id, so with a server that needs one the eval goes on, while Conjure says it was interrupted, and every later eval in the session waits for it.", + Refs: []check.Ref{conjureInterrupt, specInterrupt, nreplInterrupt}, + Snippets: []string{"sleep"}, + Requires: []string{"interrupt.running"}, + Run: func(t *check.T) { + s := startSleep(t) + defer s.stop() + s.interrupt(t, "") + }, + }, + { + ID: "interrupt.status", + Title: "An interrupted eval says it was interrupted", + Severity: check.Warn, + Why: "CIDER tells the user an eval was interrupted when its done comes with interrupted; without it the eval just ends without a result.", + Refs: []check.Ref{ciderInterrupted, specInterrupt, nreplInterrupt}, + Snippets: []string{"sleep"}, + Requires: []string{"interrupt.running"}, + Run: func(t *check.T) { + s := startSleep(t) + defer s.stop() + if resp := s.interrupt(t, s.id); !resp.HasStatus("interrupted") { + t.Violatef("the interrupted eval ended with status %v, without interrupted", resp.Status()) + } + }, + }, + { + ID: "interrupt.survives", + Title: "A session keeps working after an interrupt", + Severity: check.Fail, + Why: "Clients go on evaluating in the session they interrupted (vim-fireplace blocks Vim until each eval is done), so a session that stops answering takes the REPL with it.", + Refs: []check.Ref{fireplaceInterrupt, nreplInterrupt}, + Snippets: []string{"sleep", "value"}, + Requires: []string{"interrupt.running"}, + Run: func(t *check.T) { + v := t.Snippet("value") + s := startSleep(t) + defer s.stop() + s.interrupt(t, s.id) + if vs := t.Eval(s.c, v.Code, s.session).Values(); !slices.Equal(vs, []string{v.Value}) { + t.Violatef("after the interrupt, evaluating %q gave values %q, want [%q]", v.Code, vs, v.Value) + } + }, + }, + { + ID: "interrupt.queued", + Title: "An eval waiting for an interrupted one still runs", + Severity: check.Fail, + Why: "CIDER sends evals while another one is running in the session, and keeps the spinner of each going until its done; an eval that's dropped along with the interrupted one never gets it.", + Refs: []check.Ref{ciderSpinner, ciderInterrupt, issue("nrepl/nrepl", 503)}, + Snippets: []string{"sleep", "value"}, + Requires: []string{"interrupt.running"}, + Run: func(t *check.T) { + v := t.Snippet("value") + s := startSleep(t) + defer s.stop() + next := t.Send(s.c, s.session.With(nrepl.Message{"op": "eval", "code": v.Code})) + s.interrupt(t, s.id) + // A session runs its evals in order, so once an eval sent + // after the interrupt is done, the waiting one has run too, + // unless it was dropped. + t.Eval(s.c, v.Code, s.session) + resp, err := s.c.Collect(next, 0) + if err != nil { + t.Stopf("the eval that was waiting never got its done, though an eval sent after the interrupt did") + } + if vs := resp.Values(); !slices.Equal(vs, []string{v.Value}) { + t.Violatef("evaluating %q after the interrupted eval gave values %q, want [%q]", v.Code, vs, v.Value) + } + }, + }, + { + ID: "interrupt.other-connection", + Title: "An interrupted eval ends on its own connection", + Severity: check.Fail, + 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.", + Refs: []check.Ref{neoreplInterrupt, issue("nrepl/nrepl", 498)}, + Snippets: []string{"sleep"}, + Requires: []string{"interrupt.running"}, + Run: func(t *check.T) { + s := startSleep(t) + defer s.stop() + other := t.Connect() + resp := t.Request(other, s.session.With(nrepl.Message{"op": "interrupt", "interrupt-id": s.id})) + if resp.HasStatus("unknown-session") || resp.HasStatus("session-idle") || resp.HasStatus("error") { + t.Skipf("the session can't be interrupted from another connection, which got status %v (see session.across-connections)", resp.Status()) + } + // The eval's done comes before the interrupt's, wherever it goes. + if _, err := other.Collect(s.id, 0); err == nil { + t.Stopf("the interrupted eval's done went to the connection the interrupt came from") + } + s.await(t) + }, + }, + { + ID: "interrupt.idle", + Title: "Interrupting an idle session gets session-idle", + Severity: check.Warn, + Why: "That's how nREPL says nothing was running. CIDER and Calva interrupt every eval they still know about, so they get it a lot, but they don't need it.", + Refs: []check.Ref{nreplInterrupt, ciderInterrupt}, + Requires: []string{"describe.reply", "describe.ops-dict", "session.clone"}, + Run: func(t *check.T) { + requireOp(t, "interrupt") + c := t.Connect() + session := t.Session(c) + if resp := t.Request(c, session.With(nrepl.Message{"op": "interrupt"})); !resp.HasStatus("session-idle") { + t.Violatef("interrupting an idle session gave status %v, without session-idle", resp.Status()) + } + }, + }, + { + ID: "interrupt.id-mismatch", + Title: "An interrupt for another eval gets interrupt-id-mismatch", + Severity: check.Warn, + Why: "An interrupt with the id of another eval mustn't stop the one that's running, and nREPL says so with interrupt-id-mismatch. No client looks at it, though.", + Refs: []check.Ref{nreplInterrupt}, + Snippets: []string{"sleep"}, + Requires: []string{"interrupt.running"}, + Run: func(t *check.T) { + s := startSleep(t) + defer s.stop() + resp := t.Request(s.c, s.session.With(nrepl.Message{"op": "interrupt", "interrupt-id": "proof-no-such-eval"})) + if !resp.HasStatus("interrupt-id-mismatch") { + t.Violatef("interrupting an eval with another id gave status %v, without interrupt-id-mismatch", resp.Status()) + } + s.interrupt(t, s.id) + }, + }, + } +} + +// sleep is the sleep snippet running in a session of its own. +type sleep struct { + c *nrepl.Conn + session nrepl.Message + id string + over bool +} + +// startSleep starts the sleep snippet and waits until it's running, i.e. +// until it has printed its output. It skips the check unless the server +// has interrupt. +func startSleep(t *check.T) *sleep { + requireOp(t, "interrupt") + c, session, id := startEval(t, "sleep") + want := t.Snippet("sleep").Out + var out string + m := t.Await(c, "output from the sleep snippet", func(m nrepl.Message) bool { + // Output without an id is wire.id's business. + if m.Str("id") == id || !m.Has("id") { + out += m.Str("out") + } + return strings.Contains(out, want) || m.Str("id") == id && m.HasStatus("done") + }) + if !strings.Contains(out, want) { + t.Skipf("the sleep snippet ended before it printed %q (status %v), so there was nothing to interrupt", want, m.Status()) + } + return &sleep{c: c, session: session, id: id} +} + +// interrupt sends an interrupt for the given eval id ("" for none) and +// returns what the sleep got once it's done. +func (s *sleep) interrupt(t *check.T, id string) nrepl.Response { + req := s.session.With(nrepl.Message{"op": "interrupt"}) + if id != "" { + req["interrupt-id"] = id + } + t.Request(s.c, req) + return s.await(t) +} + +// await waits for the sleep's done. +func (s *sleep) await(t *check.T) nrepl.Response { + resp := t.Collect(s.c, s.id) + s.over = true + return resp +} + +// stop interrupts the sleep if the check ended without stopping it, so +// the server isn't left running it. +func (s *sleep) stop() { + if !s.over { + s.c.Send(s.session.With(nrepl.Message{"op": "interrupt", "interrupt-id": s.id})) + } +} diff --git a/internal/checks/refs.go b/internal/checks/refs.go index 5227f61..7f5f2c7 100644 --- a/internal/checks/refs.go +++ b/internal/checks/refs.go @@ -17,6 +17,7 @@ const ( specBase = "https://github.com/nrepl/spec.nrepl.org/blob/67796e34ac34f2f28c3af685fc3ab432fe5eb03f/spec.md" replyBase = "https://github.com/trptcolin/reply/blob/2ee3e3a193e9df62d5dc965d25efd0521a289b70/src/clj/reply/eval_modes/nrepl.clj" rebelBase = "https://github.com/bhauman/rebel-readline/blob/d8573a61aad5cbbd83532e2050b6595cfb5b13bb/rebel-readline-nrepl/src/rebel_readline/nrepl/service/nrepl.clj" + neoreplBase = "https://github.com/nrepl/neorepl/blob/462a182c885ff56d65acfc73ad1fa92f02fc7181/src/neorepl/" bbNreplBase = "https://github.com/babashka/babashka.nrepl/blob/fb44874128d8cc2622f164e6a499ed4f00e5aae9/src/babashka/nrepl/" basilispBase = "https://github.com/basilisp-lang/basilisp/blob/2f993f9e53b4195316426140384c38728304341e/src/basilisp/contrib/nrepl_server.lpy" ) @@ -64,6 +65,16 @@ var ( specClone = ref("spec: clone op", specBase+"#L359-L389") specClose = ref("spec: close op", specBase+"#L390-L414") specStdin = ref("spec: stdin op", specBase+"#L186-L215") + specInterrupt = ref("spec: interrupt op", specBase+"#L222-L248") + nreplInterrupt = ref("nREPL interrupt replies", nreplBase+"middleware/session.clj#L277-L296") + ciderInterrupt = ref("CIDER interrupts every pending request", ciderBase+"cider-client.el#L625-L639") + ciderInterrupted = ref("CIDER says the eval was interrupted", ciderBase+"cider-eval.el#L265-L267") + ciderReplPrompt = ref("CIDER shows the REPL's next prompt at done", ciderBase+"cider-repl.el#L1363-L1366") + ciderSpinner = ref("CIDER stops its spinner at done", ciderBase+"cider-client.el#L105-L117") + calvaInterrupted = ref("Calva settles an eval only at its done", calvaBase+"#L1504-L1527") + conjureInterrupt = ref("Conjure interrupts without interrupt-id", "https://github.com/Olical/conjure/blob/9842bf38464df071f72546f65fa6ec44942ca7b2/fnl/conjure/client/clojure/nrepl/action.fnl#L260-L297") + fireplaceInterrupt = ref("vim-fireplace interrupts on Ctrl-C and goes on in the same session", fireplaceBase+"fireplace.vim#L1346-L1369") + neoreplInterrupt = ref("neorepl interrupt stops an eval from another process", neoreplBase+"main.clj#L374-L385") ciderNeedInput = ref("CIDER need-input handling", ciderBase+"nrepl-client.el#L948-L950") nreplStdinEOF = ref("nREPL treats an empty stdin as EOF", nreplBase+"middleware/session.clj#L386-L396") diff --git a/internal/checks/stdin.go b/internal/checks/stdin.go index 9bb04da..070ca64 100644 --- a/internal/checks/stdin.go +++ b/internal/checks/stdin.go @@ -75,9 +75,15 @@ func stdinChecks() []*check.Check { // startRead evaluates the read-line snippet in a fresh session without // waiting for it, since it blocks until it gets input. func startRead(t *check.T) (*nrepl.Conn, nrepl.Message, string) { + return startEval(t, "read-line") +} + +// startEval evaluates a snippet in a fresh session without waiting for it, +// and returns the connection, the session and the eval's id. +func startEval(t *check.T, snippet string) (*nrepl.Conn, nrepl.Message, string) { c := t.Connect() session := t.Session(c) - id := t.Send(c, session.With(nrepl.Message{"op": "eval", "code": t.Snippet("read-line").Code})) + id := t.Send(c, session.With(nrepl.Message{"op": "eval", "code": t.Snippet(snippet).Code})) return c, session, id } diff --git a/internal/serve/matrix_test.go b/internal/serve/matrix_test.go index 9ad145a..1deb8ae 100644 --- a/internal/serve/matrix_test.go +++ b/internal/serve/matrix_test.go @@ -53,6 +53,18 @@ func runChecksAs(t *testing.T, server string, scenarios ...string) map[string]ch return checktest.ByID(check.Run(env, checks.All(), checks.WireRules())) } +// noInterrupts is what the interrupt checks get from servers without the +// op: skipped. +var noInterrupts = func() map[string]check.Verdict { + v := map[string]check.Verdict{} + for _, c := range checks.All() { + if strings.HasPrefix(c.ID, "interrupt.") { + v[c.ID] = check.Skipped + } + } + return v +}() + func TestPassesEveryServerCheck(t *testing.T) { checktest.Verdicts(t, runChecks(t), nil) } @@ -75,17 +87,22 @@ func TestScenariosGetTheVerdictsOfTheirServers(t *testing.T) { {[]string{"error-with-done"}, nil}, {[]string{"no-op-echo"}, map[string]check.Verdict{"op.unknown-echo": W}}, {[]string{"no-close-op"}, map[string]check.Verdict{"describe.required-ops": F}}, - {[]string{"no-interrupt"}, nil}, + {[]string{"no-interrupt"}, noInterrupts}, {[]string{"no-stdin"}, noStdin}, {[]string{"read-line-throws"}, noStdin}, {[]string{"string-versions"}, nil}, {[]string{"no-session-closed"}, map[string]check.Verdict{"session.close": F}}, {[]string{"shared-state"}, map[string]check.Verdict{"session.isolated": F}}, - {[]string{"socket-sessions"}, map[string]check.Verdict{"session.across-connections": W}}, + {[]string{"socket-sessions"}, map[string]check.Verdict{"session.across-connections": W, + "interrupt.other-connection": S}}, {[]string{"any-session"}, map[string]check.Verdict{"session.unknown": F, "session.closed": F}}, {[]string{"ns-fallback"}, map[string]check.Verdict{"eval.unknown-ns": F}}, {[]string{"ns-error"}, map[string]check.Verdict{"eval.unknown-ns": F}}, {[]string{"eof-error"}, map[string]check.Verdict{"stdin.eof": W}}, + {[]string{"late-exception"}, map[string]check.Verdict{"wire.after-done": W}}, + {[]string{"drop-queued"}, map[string]check.Verdict{"interrupt.queued": F}}, + // The interrupt's connection never sent the eval. + {[]string{"interrupt-elsewhere"}, map[string]check.Verdict{"interrupt.other-connection": F, "wire.id": W}}, {[]string{"unsorted-keys"}, map[string]check.Verdict{"wire.canonical": W}}, {[]string{"byte-writes"}, nil}, {[]string{"batched-writes"}, nil}, @@ -106,16 +123,18 @@ func TestPresetsGetTheColumnsOfTheirServers(t *testing.T) { noJava := map[string]check.Verdict{"fireplace.connect": S, "fireplace.eval": S} noLanguage := map[string]check.Verdict{"eval.ns": S, "eval.unknown-ns": S, "cider.eval": S, "calva.eval": S, "conjure.eval": S} + nreplInterrupts := map[string]check.Verdict{"interrupt.queued": F, "interrupt.other-connection": F, "wire.id": W, + "wire.after-done": W} columns := map[string]map[string]check.Verdict{ - "clojure": nil, - "babashka": nil, - "clojure-clr": checktest.Merged(noJava, map[string]check.Verdict{"op.unknown-echo": W, "session.across-connections": W, + "clojure": nreplInterrupts, + "babashka": nreplInterrupts, + "clojure-clr": checktest.Merged(noJava, noInterrupts, map[string]check.Verdict{"op.unknown-echo": W, "session.across-connections": W, "session.isolated": F, "session.unknown": F, "session.closed": F, "stdin.need-input": S, "stdin.roundtrip": S, "stdin.eof": S}), - "basilisp": checktest.Merged(noJava, map[string]check.Verdict{"op.unknown-echo": W, "session.across-connections": W, + "basilisp": checktest.Merged(noJava, noInterrupts, map[string]check.Verdict{"op.unknown-echo": W, "session.across-connections": W, "session.isolated": F, "session.close": F, "session.unknown": F, "session.closed": F, "eval.stderr": F, "eval.multiple-forms": F, "eval.unknown-ns": F, "stdin.need-input": S, "stdin.roundtrip": S, "stdin.eof": S}), - "jank": checktest.Merged(noJava, map[string]check.Verdict{"describe.required-ops": F, "op.unknown-echo": W, + "jank": checktest.Merged(noJava, noInterrupts, map[string]check.Verdict{"describe.required-ops": F, "op.unknown-echo": W, "session.across-connections": W, "session.isolated": F, "session.close": F, "session.unknown": F, "session.closed": F, "eval.multiple-forms": F, "eval.unknown-ns": F, "stdin.need-input": S, "stdin.roundtrip": S, "stdin.eof": S, "wire.canonical": W}), diff --git a/internal/serve/scenarios.go b/internal/serve/scenarios.go index a28466f..7653f7d 100644 --- a/internal/serve/scenarios.go +++ b/internal/serve/scenarios.go @@ -23,6 +23,7 @@ type behavior struct { noOpEcho, noCloseOp, noInterrupt, stringVersions bool noSessionClosed, sharedState, socketSessions, anySession bool nsFallback, nsError, eofError bool + dropQueued, interruptElsewhere, lateException bool unsortedKeys, byteWrites, batchedWrites, hangUp bool stdin stdinMode } @@ -87,6 +88,14 @@ var catalog = []scenario{ func(b *behavior) { b.nsError = true }, []string{"basilisp"}}, {Scenario{"eof-error", "Reading past the end of input fails instead of returning nil", "nREPL 1.7.0"}, func(b *behavior) { b.eofError = true }, nil}, + {Scenario{"late-exception", "An interrupted eval's exception comes after its done, and the rest of its code still runs", + "nREPL 1.8.0 and Babashka"}, + func(b *behavior) { b.lateException = true }, []string{"clojure", "babashka"}}, + {Scenario{"drop-queued", "Evals waiting for one that gets interrupted never run", "nREPL 1.8.0 and Babashka"}, + func(b *behavior) { b.dropQueued = true }, []string{"clojure", "babashka"}}, + {Scenario{"interrupt-elsewhere", "An interrupted eval's done goes to the connection the interrupt came from", + "nREPL 1.8.0 and Babashka"}, + func(b *behavior) { b.interruptElsewhere = true }, []string{"clojure", "babashka"}}, {Scenario{"unsorted-keys", "The keys of reply dicts aren't sorted", "jank"}, func(b *behavior) { b.unsortedKeys = true }, []string{"jank"}}, {Scenario{"byte-writes", "Replies are written a byte at a time", "any server, as TCP can deliver a message in pieces"}, diff --git a/internal/serve/serve.go b/internal/serve/serve.go index 35ca836..9dc9d99 100644 --- a/internal/serve/serve.go +++ b/internal/serve/serve.go @@ -377,9 +377,16 @@ func (s *Server) interrupt(c *conn, req nrepl.Message, sess *session) { c.reply(req, map[string]any{"status": []any{"session-idle", "done"}}) return } + if s.b.dropQueued { + sess.dropJobs() + } // Like nREPL, this tells the eval it's done before telling the // interrupt, and the code finds out about it after that. - j.c.reply(j.req, map[string]any{"status": []any{"done", "interrupted"}}) + to := j.c + if s.b.interruptElsewhere { + to = c + } + to.reply(j.req, map[string]any{"status": []any{"done", "interrupted"}}) c.reply(req, map[string]any{"status": done}) signal(j.interrupts) } @@ -444,7 +451,14 @@ func (s *Server) run(sess *session) { func (s *Server) runEval(sess *session, j *job) { c, req := j.c, j.req - reply := func(fields map[string]any) { c.reply(req, fields) } + // An interrupt ends the eval, and says so itself. In late-exception the + // rest of the eval still runs and has its say, as on nREPL. + stopped := func() bool { return !s.b.lateException && sess.interrupted(j) } + reply := func(fields map[string]any) { + if !stopped() { + c.reply(req, fields) + } + } if s.b.emptyMessages { reply(nil) } @@ -492,7 +506,7 @@ func (s *Server) runEval(sess *session, j *job) { // held is the value last-value sends at the end. var held map[string]any rd := &reader{src: req.Str("code")} - for { + for !stopped() { form, err := rd.next() if err == io.EOF { break @@ -505,6 +519,10 @@ func (s *Server) runEval(sess *session, j *job) { return } if err != nil { + if stopped() { + // What the interrupt made the code throw. + break + } t := err.(*thrown) sess.b.failed(t.ex) final, held = s.fail(reply, t, e.ns), nil diff --git a/internal/serve/serve_test.go b/internal/serve/serve_test.go index dd0e4c2..7386cf6 100644 --- a/internal/serve/serve_test.go +++ b/internal/serve/serve_test.go @@ -262,61 +262,79 @@ func kinds(c *nrepl.Conn, id string) []string { return ks } +// An interrupt stops the eval, which gets nothing after its done. With +// late-exception, interrupts go the way they go on nREPL instead, where +// the rest of the eval runs after its done. func TestInterrupt(t *testing.T) { - c := connect(t, serveFor(t)) - session := clone(t, c) - if r := request(t, c, nrepl.Message{"op": "interrupt", "session": session}); !r.HasStatus("session-idle", "done") { - t.Errorf("idle session: %v", r.Status()) - } - if r := request(t, c, nrepl.Message{"op": "interrupt"}); !r.HasStatus("error", "session-ephemeral", "done") { - t.Errorf("no session: %v", r.Status()) - } - // What each of them sends once it's running. - running := map[string]string{ - `(do (print "sleeping") (Thread/sleep 60000)) :after`: "out", - "(read-line) :after": "need-input", - } - for code, started := range running { - id, err := c.Send(nrepl.Message{"op": "eval", "code": code, "session": session}) - if err != nil { - t.Fatal(err) - } - if _, err := c.WaitFor(func(m nrepl.Message) bool { return m.Str("id") == id }, timeout); err != nil { - t.Fatal(err) - } - r := request(t, c, nrepl.Message{"op": "interrupt", "session": session, "interrupt-id": "something else"}) - if !r.HasStatus("error", "interrupt-id-mismatch", "done") { - t.Errorf("%s: interrupting some other eval: %v", code, r.Status()) - } - interrupt, err := c.Send(nrepl.Message{"op": "interrupt", "session": session, "interrupt-id": id}) - if err != nil { - t.Fatal(err) - } - // Like on nREPL, the eval is done before the interrupt, and the rest - // of its code runs after that, without another done. - if _, err := c.WaitFor(func(m nrepl.Message) bool { return m.Str("id") == id && m.Has("value") }, timeout); err != nil { - t.Fatal(err) - } - c.Settle(50*time.Millisecond, time.Second) - if got, want := kinds(c, id), []string{started, "done", "err", "eval-error", "value"}; !slices.Equal(got, want) { - t.Errorf("%s: the eval got %v, want %v", code, got, want) - } - var order []string - for _, ev := range c.Transcript() { - if ev.Msg.HasStatus("done") && (ev.Msg.Str("id") == id || ev.Msg.Str("id") == interrupt) { - order = append(order, ev.Msg.Str("id")) + for name, late := range map[string]bool{"stops": false, "late-exception": true} { + t.Run(name, func(t *testing.T) { + var scenarios []string + if late { + scenarios = []string{"late-exception"} } - } - if !slices.Equal(order, []string{id, interrupt}) { - t.Errorf("%s: dones came in the order %v, want the eval's first", code, order) - } - if all := received(c, id); !all.HasStatus("done", "interrupted") || !strings.Contains(all.Err(), "(InterruptedException)") { - t.Errorf("%s: interrupted eval: %v %q", code, all.Status(), all.Err()) - } - } - // The session goes on. - if v := eval(t, c, session, "(+ 1 2)").Values(); !slices.Equal(v, []string{"3"}) { - t.Errorf("after the interrupts: %q", v) + c := connect(t, serveFor(t, scenarios...)) + session := clone(t, c) + if r := request(t, c, nrepl.Message{"op": "interrupt", "session": session}); !r.HasStatus("session-idle", "done") { + t.Errorf("idle session: %v", r.Status()) + } + if r := request(t, c, nrepl.Message{"op": "interrupt"}); !r.HasStatus("error", "session-ephemeral", "done") { + t.Errorf("no session: %v", r.Status()) + } + // What each of them sends once it's running. + running := map[string]string{ + `(do (print "sleeping") (Thread/sleep 60000)) :after`: "out", + "(read-line) :after": "need-input", + } + for code, started := range running { + id, err := c.Send(nrepl.Message{"op": "eval", "code": code, "session": session}) + if err != nil { + t.Fatal(err) + } + if _, err := c.WaitFor(func(m nrepl.Message) bool { return m.Str("id") == id }, timeout); err != nil { + t.Fatal(err) + } + r := request(t, c, nrepl.Message{"op": "interrupt", "session": session, "interrupt-id": "something else"}) + if !r.HasStatus("error", "interrupt-id-mismatch", "done") { + t.Errorf("%s: interrupting some other eval: %v", code, r.Status()) + } + interrupt, err := c.Send(nrepl.Message{"op": "interrupt", "session": session, "interrupt-id": id}) + if err != nil { + t.Fatal(err) + } + want := []string{started, "done"} + if late { + // The eval is done before the interrupt, and the rest of + // its code runs after that, without another done. + if _, err := c.WaitFor(func(m nrepl.Message) bool { return m.Str("id") == id && m.Has("value") }, timeout); err != nil { + t.Fatal(err) + } + want = append(want, "err", "eval-error", "value") + } else if _, err := c.Collect(interrupt, timeout); err != nil { + t.Fatal(err) + } + c.Settle(50*time.Millisecond, time.Second) + if got := kinds(c, id); !slices.Equal(got, want) { + t.Errorf("%s: the eval got %v, want %v", code, got, want) + } + var order []string + for _, ev := range c.Transcript() { + if ev.Msg.HasStatus("done") && (ev.Msg.Str("id") == id || ev.Msg.Str("id") == interrupt) { + order = append(order, ev.Msg.Str("id")) + } + } + if !slices.Equal(order, []string{id, interrupt}) { + t.Errorf("%s: dones came in the order %v, want the eval's first", code, order) + } + all := received(c, id) + if !all.HasStatus("done", "interrupted") || late && !strings.Contains(all.Err(), "(InterruptedException)") { + t.Errorf("%s: interrupted eval: %v %q", code, all.Status(), all.Err()) + } + } + // The session goes on. + if v := eval(t, c, session, "(+ 1 2)").Values(); !slices.Equal(v, []string{"3"}) { + t.Errorf("after the interrupts: %q", v) + } + }) } } diff --git a/internal/serve/session.go b/internal/serve/session.go index cd06189..d822164 100644 --- a/internal/serve/session.go +++ b/internal/serve/session.go @@ -124,6 +124,20 @@ func (sess *session) interrupt(id string) (j *job, mismatch bool) { return j, false } +// dropJobs forgets the jobs waiting for their turn. +func (sess *session) dropJobs() { + sess.mu.Lock() + sess.jobs = nil + sess.mu.Unlock() +} + +// interrupted reports whether interrupt stopped j. +func (sess *session) interrupted(j *job) bool { + sess.mu.Lock() + defer sess.mu.Unlock() + return j.interrupted +} + // close ends the session. Like nREPL, it interrupts the eval that's // running, which goes on to finish the rest of its code. func (sess *session) close() { diff --git a/profiles/babashka.toml b/profiles/babashka.toml index 318faeb..99eab66 100644 --- a/profiles/babashka.toml +++ b/profiles/babashka.toml @@ -47,3 +47,8 @@ values = ["1", "2"] code = "(read-line)" value = '"proof"' eof = "nil" + +# Prints something and then runs until it's interrupted. +[snippets.sleep] +code = '(do (print "proof") (flush) (Thread/sleep 60000))' +out = "proof" diff --git a/profiles/clojure.toml b/profiles/clojure.toml index eb2347e..43c4208 100644 --- a/profiles/clojure.toml +++ b/profiles/clojure.toml @@ -47,3 +47,8 @@ values = ["1", "2"] code = "(read-line)" value = '"proof"' eof = "nil" + +# Prints something and then runs until it's interrupted. +[snippets.sleep] +code = '(do (print "proof") (flush) (Thread/sleep 60000))' +out = "proof" diff --git a/profiles/dialtone.toml b/profiles/dialtone.toml index eac36be..94a26b5 100644 --- a/profiles/dialtone.toml +++ b/profiles/dialtone.toml @@ -43,3 +43,8 @@ values = ["1", "2"] code = 'io:get_line("").' value = '"proof\n"' eof = "eof" + +# Prints something and then runs until it's interrupted. +[snippets.sleep] +code = 'io:format("proof"), timer:sleep(60000).' +out = "proof" diff --git a/profiles/repartee.toml b/profiles/repartee.toml index e4032fe..2ed3be7 100644 --- a/profiles/repartee.toml +++ b/profiles/repartee.toml @@ -39,3 +39,8 @@ value = ":proof_marker" code = 'IO.gets("")' value = '"proof\n"' eof = ":eof" + +# Prints something and then runs until it's interrupted. +[snippets.sleep] +code = 'IO.write("proof"); Process.sleep(60_000)' +out = "proof"