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"