Skip to content

Commit a1266f4

Browse files
committed
Bound nrepl-completed-requests with a FIFO cap
`nrepl--mark-id-completed' moved request handlers from `nrepl-pending-requests' to `nrepl-completed-requests' with no upper bound on the latter. Each entry retains its handler closure (often hundreds of bytes once you count its lexical environment), so a long- running session accumulated thousands of stale handlers in memory. The completed table exists so late messages -- responses arriving after their request's `done' status -- can still find a callback to dispatch them. In practice that window is sub-second. Add a FIFO cap so the table is bounded to the recently-completed entries: - New defcustom `nrepl-completed-requests-max-size' (default 1000). - New buffer-local `nrepl--completed-requests-order' tracking insertion order. - `nrepl--mark-id-completed' enqueues each id and, when the cap is exceeded, dequeues + removes the oldest. 1000 is enough to cover any plausible late-message window: requests complete in milliseconds, so this caps about a second of activity. Setting the size to 0 bypasses the eviction, restoring the previous unbounded behavior for users who have a reason to keep everything. Three Buttercup specs cover the cap, FIFO eviction, and the 0-disables case.
1 parent 0ff2841 commit a1266f4

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
@@ -22,6 +22,7 @@
2222
- `cider--resolve-command` now actually verifies command presence on remote hosts via `executable-find`'s remote search, instead of unconditionally trusting the user-supplied command. A missing tool on the remote side now surfaces immediately during jack-in instead of as a server-startup failure later.
2323
- `nrepl--ssh-tunnel-connect` no longer routes its `ssh` invocation through a shell. The tunnel command is now spawned via `start-process` with an explicit arg list, eliminating shell-quoting hazards in user/host components.
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.
25+
- 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.
2526

2627
### Changes
2728

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

@@ -562,7 +585,8 @@ client buffer. Return the newly created client process."
562585
nrepl-tunnel-buffer (when-let* ((tunnel (plist-get endpoint :tunnel)))
563586
(process-buffer tunnel))
564587
nrepl-pending-requests (make-hash-table :test 'equal)
565-
nrepl-completed-requests (make-hash-table :test 'equal)))
588+
nrepl-completed-requests (make-hash-table :test 'equal)
589+
nrepl--completed-requests-order (queue-create)))
566590

567591
(with-current-buffer client-buf
568592
(nrepl--init-client-sessions client-proc)
@@ -637,12 +661,22 @@ Used by the client to clean up session state on disconnect.")
637661

638662
(defun nrepl--mark-id-completed (id)
639663
"Move ID from `nrepl-pending-requests' to `nrepl-completed-requests'.
640-
It is safe to call this function multiple times on the same ID."
641-
;; FIXME: This should go away eventually when we get rid of
642-
;; pending-request hash table
664+
The completed table is bounded by `nrepl-completed-requests-max-size';
665+
when the cap is reached the oldest entry is evicted FIFO.
666+
667+
It is safe to call this function multiple times on the same ID -- the
668+
second call is a no-op because the entry has already been moved out of
669+
`nrepl-pending-requests'."
643670
(when-let* ((handler (gethash id nrepl-pending-requests)))
644671
(puthash id handler nrepl-completed-requests)
645-
(remhash id nrepl-pending-requests)))
672+
(remhash id nrepl-pending-requests)
673+
(when nrepl--completed-requests-order
674+
(queue-enqueue nrepl--completed-requests-order id)
675+
(when (and (> nrepl-completed-requests-max-size 0)
676+
(> (queue-length nrepl--completed-requests-order)
677+
nrepl-completed-requests-max-size))
678+
(remhash (queue-dequeue nrepl--completed-requests-order)
679+
nrepl-completed-requests)))))
646680

647681
(defun nrepl-notify (msg type)
648682
"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
@@ -267,6 +267,44 @@
267267
(expect received-body :to-equal "hello")
268268
(expect received-type :to-equal '("text/plain" ())))))
269269

270+
(describe "nrepl--mark-id-completed cap"
271+
:var (nrepl-pending-requests nrepl-completed-requests
272+
nrepl--completed-requests-order
273+
nrepl-completed-requests-max-size)
274+
(before-each
275+
(setq nrepl-pending-requests (make-hash-table :test 'equal)
276+
nrepl-completed-requests (make-hash-table :test 'equal)
277+
nrepl--completed-requests-order (queue-create)))
278+
279+
(cl-flet ((mark (id)
280+
;; A handler must exist in pending for the move to take place.
281+
(puthash id #'ignore nrepl-pending-requests)
282+
(nrepl--mark-id-completed id)))
283+
284+
(it "retains entries up to the configured cap"
285+
(let ((nrepl-completed-requests-max-size 3))
286+
(mark "1") (mark "2") (mark "3")
287+
(expect (hash-table-count nrepl-completed-requests) :to-equal 3)
288+
(dolist (id '("1" "2" "3"))
289+
(expect (gethash id nrepl-completed-requests) :not :to-be nil))))
290+
291+
(it "evicts the oldest entry FIFO when over the cap"
292+
(let ((nrepl-completed-requests-max-size 2))
293+
(mark "1") (mark "2") (mark "3")
294+
(expect (hash-table-count nrepl-completed-requests) :to-equal 2)
295+
(expect (gethash "1" nrepl-completed-requests) :to-be nil)
296+
(expect (gethash "2" nrepl-completed-requests) :not :to-be nil)
297+
(expect (gethash "3" nrepl-completed-requests) :not :to-be nil)))
298+
299+
(it "treats max-size of 0 as unbounded"
300+
;; Documented as "disable the cache"; concretely the eviction
301+
;; branch is bypassed and the table grows freely. In practice
302+
;; users would also need to clear the queue, but the queue itself
303+
;; is bounded by the producer rate, so this is fine.
304+
(let ((nrepl-completed-requests-max-size 0))
305+
(dotimes (n 5) (mark (number-to-string n)))
306+
(expect (hash-table-count nrepl-completed-requests) :to-equal 5)))))
307+
270308
(describe "nrepl-client-lifecycle"
271309
(it "start and stop nrepl client process"
272310

0 commit comments

Comments
 (0)