Skip to content

Commit e711107

Browse files
authored
Merge pull request #3898 from clojure-emacs/bound-completed-requests
Bound nrepl-completed-requests with a FIFO cap
2 parents fd2dfce + 4b5711f commit e711107

3 files changed

Lines changed: 78 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
- Plug five request-id leaks in raw nREPL response handlers (`cider/test-stacktrace`, `cider/test-var-query`, `cider/analyze-last-stacktrace`, `cider/ns-reload`, `cider/get-state`). They never called `nrepl--mark-id-completed`, so their ids accumulated in the connection's `nrepl-pending-requests` for the lifetime of the session.
2525
- A response-handler error or an unrecognized response id no longer aborts the nREPL response queue. `nrepl-client-filter` wraps the dispatch call in `with-demoted-errors`, and the no-callback case in `nrepl--dispatch-response` is now a `message` instead of a hard error -- so a single misbehaving callback can no longer drop later responses on the floor.
2626
- `nrepl-client-sentinel` now tears down the SSH tunnel buffer/process when the client connection closes. Previously only the orderly `cider-quit` path killed the tunnel, so an abnormal disconnect (server crash, network drop) left the `ssh` subprocess as a zombie until Emacs exited.
27+
- Bound `nrepl-completed-requests` with a FIFO cap (`nrepl-completed-requests-max-size`, default 1000). The completed-request handler table previously grew unbounded for the lifetime of a connection; long-running sessions accumulated thousands of stale handler closures.
2728

2829
### Changes
2930

lisp/nrepl-client.el

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
(require 'seq)
7272
(require 'subr-x)
7373
(require 'cl-lib)
74+
(require 'queue)
7475
(require 'nrepl-dict)
7576
(require 'nrepl-bencode)
7677
(require 'tramp)
@@ -110,6 +111,24 @@ Setting this to nil disables the timeout functionality."
110111
When true some special buffers like the server buffer will be hidden."
111112
:type 'boolean)
112113

114+
(defcustom nrepl-completed-requests-max-size 1000
115+
"Maximum number of completed-request handlers retained per connection.
116+
117+
nREPL servers occasionally send late messages for requests whose
118+
\"done\" status has already arrived (typically a sub-second window).
119+
We keep the recently-completed handlers around so those messages still
120+
get dispatched. Without a bound, the table grew unbounded for the
121+
lifetime of the connection.
122+
123+
Eviction is FIFO: when the table is full, the oldest entry is dropped
124+
to make room for the newest. 1000 is ample in practice -- requests
125+
complete in milliseconds, so this caps roughly a second of activity.
126+
Set to 0 to disable the cache entirely; late messages will then become
127+
log warnings."
128+
:type 'integer
129+
:safe #'integerp
130+
:package-version '(cider . "1.22.0"))
131+
113132
;;; Buffer Local Declarations
114133

115134
;; These variables are used to track the state of nREPL connections
@@ -138,6 +157,10 @@ To be used for tooling calls (i.e. completion, eldoc, etc)")
138157

139158
(defvar-local nrepl-completed-requests nil)
140159

160+
(defvar-local nrepl--completed-requests-order nil
161+
"FIFO of ids in `nrepl-completed-requests', used for bounded eviction.
162+
See `nrepl-completed-requests-max-size'.")
163+
141164
(defvar-local nrepl-last-sync-response nil
142165
"Result of the last sync request.")
143166

@@ -583,7 +606,8 @@ client buffer. Return the newly created client process."
583606
nrepl-tunnel-buffer (when-let* ((tunnel (plist-get endpoint :tunnel)))
584607
(process-buffer tunnel))
585608
nrepl-pending-requests (make-hash-table :test 'equal)
586-
nrepl-completed-requests (make-hash-table :test 'equal)))
609+
nrepl-completed-requests (make-hash-table :test 'equal)
610+
nrepl--completed-requests-order (queue-create)))
587611

588612
(with-current-buffer client-buf
589613
(nrepl--init-client-sessions client-proc)
@@ -658,12 +682,22 @@ Used by the client to clean up session state on disconnect.")
658682

659683
(defun nrepl--mark-id-completed (id)
660684
"Move ID from `nrepl-pending-requests' to `nrepl-completed-requests'.
661-
It is safe to call this function multiple times on the same ID."
662-
;; FIXME: This should go away eventually when we get rid of
663-
;; pending-request hash table
685+
The completed table is bounded by `nrepl-completed-requests-max-size';
686+
when the cap is reached the oldest entry is evicted FIFO.
687+
688+
It is safe to call this function multiple times on the same ID -- the
689+
second call is a no-op because the entry has already been moved out of
690+
`nrepl-pending-requests'."
664691
(when-let* ((handler (gethash id nrepl-pending-requests)))
665692
(puthash id handler nrepl-completed-requests)
666-
(remhash id nrepl-pending-requests)))
693+
(remhash id nrepl-pending-requests)
694+
(when nrepl--completed-requests-order
695+
(queue-enqueue nrepl--completed-requests-order id)
696+
(when (and (> nrepl-completed-requests-max-size 0)
697+
(> (queue-length nrepl--completed-requests-order)
698+
nrepl-completed-requests-max-size))
699+
(remhash (queue-dequeue nrepl--completed-requests-order)
700+
nrepl-completed-requests)))))
667701

668702
(defun nrepl-notify (msg type)
669703
"Handle a server notification with MSG and TYPE.

test/nrepl-client-tests.el

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,44 @@
287287
(expect (nrepl--dispatch-response '(dict "id" "1" "value" "v"))
288288
:to-throw 'error)))
289289

290+
(describe "nrepl--mark-id-completed cap"
291+
:var (nrepl-pending-requests nrepl-completed-requests
292+
nrepl--completed-requests-order
293+
nrepl-completed-requests-max-size)
294+
(before-each
295+
(setq nrepl-pending-requests (make-hash-table :test 'equal)
296+
nrepl-completed-requests (make-hash-table :test 'equal)
297+
nrepl--completed-requests-order (queue-create)))
298+
299+
(cl-flet ((mark (id)
300+
;; A handler must exist in pending for the move to take place.
301+
(puthash id #'ignore nrepl-pending-requests)
302+
(nrepl--mark-id-completed id)))
303+
304+
(it "retains entries up to the configured cap"
305+
(let ((nrepl-completed-requests-max-size 3))
306+
(mark "1") (mark "2") (mark "3")
307+
(expect (hash-table-count nrepl-completed-requests) :to-equal 3)
308+
(dolist (id '("1" "2" "3"))
309+
(expect (gethash id nrepl-completed-requests) :not :to-be nil))))
310+
311+
(it "evicts the oldest entry FIFO when over the cap"
312+
(let ((nrepl-completed-requests-max-size 2))
313+
(mark "1") (mark "2") (mark "3")
314+
(expect (hash-table-count nrepl-completed-requests) :to-equal 2)
315+
(expect (gethash "1" nrepl-completed-requests) :to-be nil)
316+
(expect (gethash "2" nrepl-completed-requests) :not :to-be nil)
317+
(expect (gethash "3" nrepl-completed-requests) :not :to-be nil)))
318+
319+
(it "treats max-size of 0 as unbounded"
320+
;; Documented as "disable the cache"; concretely the eviction
321+
;; branch is bypassed and the table grows freely. In practice
322+
;; users would also need to clear the queue, but the queue itself
323+
;; is bounded by the producer rate, so this is fine.
324+
(let ((nrepl-completed-requests-max-size 0))
325+
(dotimes (n 5) (mark (number-to-string n)))
326+
(expect (hash-table-count nrepl-completed-requests) :to-equal 5)))))
327+
290328
(describe "nrepl-client-lifecycle"
291329
(it "start and stop nrepl client process"
292330

0 commit comments

Comments
 (0)