Commitb00e3446Recorded10 Jul 2026Repositorysigil-nrepl

Drain streamed :out/:err frames in the nREPL client

Message

The server now emits incremental (response :status out/err ...) frames before the final eval response. Teach (sigil nrepl client) to skip those stream frames and return the terminal response, so nrepl-eval keeps returning the eval value (not the first output frame) for output-producing code. Adds a per-connection recv buffer so frames coalesced in one TCP segment are not lost. Keeps the MCP nREPL bridge (a wire client of this module) correct.

Changed
 src/sigil/nrepl/client.sgl | 103 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------------------
 1 file changed, 64 insertions(+), 39 deletions(-)
Diff
src/sigil/nrepl/client.sglmodified
@@ -44,13 +44,15 @@
44
;; Connection Management
45
;; ============================================================
46
47
;; Connection: (vector 'nrepl-client socket request-counter)
+47
;; Connection: (vector 'nrepl-client socket request-counter recv-buffer)
+48
;; recv-buffer holds bytes read past the end of one frame, so streamed
+49
;; :out/:err frames coalesced with the final response are not lost.
50
(define (make-nrepl-connection socket)
49
(vector 'nrepl-client socket 0))
+51
(vector 'nrepl-client socket 0 (make-bytevector 0)))
52
53
(define (nrepl-connection? obj)
54
(and (vector? obj)
53
(>= (vector-length obj) 3)
+55
(>= (vector-length obj) 4)
56
(eq? (vector-ref obj 0) 'nrepl-client)))
57
58
(define (conn-socket conn)
@@ -64,6 +66,12 @@
66
(vector-set! conn 2 n)
67
n))
68
+69
(define (conn-buffer conn)
+70
(vector-ref conn 3))
+71
+72
(define (conn-buffer-set! conn buf)
+73
(vector-set! conn 3 buf))
+74
75
;;; Connect to an nREPL server.
76
;;;
77
;;; Returns a connection object on success, or `#f` on failure.
@@ -107,41 +115,58 @@
115
(bytevector-u8-set! prefix 3 (remainder len 256))
116
(bytevector-append prefix msg-bytes)))
117
110
;; Read at least n bytes from socket with timeout.
111
;; Returns a bytevector or #f on timeout/error.
112
(define (read-at-least sock n acc deadline)
113
(cond
114
((<= deadline 0) #f)
115
((>= (bytevector-length acc) n) acc)
116
(else
117
(if (socket-ready? sock 100)
118
(let ((data (socket-read-bytevector sock)))
119
(cond
120
((or (not data) (eof-object? data)) #f)
121
((= (bytevector-length data) 0)
122
(read-at-least sock n acc (- deadline 1)))
123
(else
124
(read-at-least sock n (bytevector-append acc data) deadline))))
125
(read-at-least sock n acc (- deadline 1))))))
126
127
;; Read a complete response from socket.
128
;; Protocol: 4-byte big-endian length + UTF-8 S-expression
129
(define (read-response sock)
130
;; Read at least 4 bytes for the length prefix
131
(let ((header (read-at-least sock 4 (make-bytevector 0) 50))) ;; 5s timeout
132
(if (not header)
133
#f
134
(let* ((b0 (bytevector-u8-ref header 0))
135
(b1 (bytevector-u8-ref header 1))
136
(b2 (bytevector-u8-ref header 2))
137
(b3 (bytevector-u8-ref header 3))
138
(len (+ (* b0 16777216) (* b1 65536) (* b2 256) b3))
139
(total-needed (+ 4 len)))
140
;; Read the full message (header may already contain some body)
141
(let ((full (read-at-least sock total-needed header 50)))
142
(if (and full (>= (bytevector-length full) total-needed))
143
(read-expr (utf8->string (bytevector-copy full 4 total-needed)))
144
#f))))))
+118
;; Decode one framed message from a byte buffer.
+119
;; Returns (message . remaining-bytes) or #f if the buffer is incomplete.
+120
(define (try-decode-frame buf)
+121
(if (< (bytevector-length buf) 4)
+122
#f
+123
(let* ((len (+ (* (bytevector-u8-ref buf 0) 16777216)
+124
(* (bytevector-u8-ref buf 1) 65536)
+125
(* (bytevector-u8-ref buf 2) 256)
+126
(bytevector-u8-ref buf 3)))
+127
(total-needed (+ 4 len)))
+128
(if (< (bytevector-length buf) total-needed)
+129
#f
+130
(cons (read-expr (utf8->string (bytevector-copy buf 4 total-needed)))
+131
(bytevector-copy buf total-needed))))))
+132
+133
;; Read one complete framed message from a connection, buffering any bytes
+134
;; that arrive past the frame boundary (streamed :out/:err frames can be
+135
;; coalesced with the final response in a single TCP segment).
+136
;; Protocol: 4-byte big-endian length + UTF-8 S-expression.
+137
(define (read-frame conn)
+138
(let loop ((deadline 50)) ;; ~5s
+139
(let ((dec (try-decode-frame (conn-buffer conn))))
+140
(if dec
+141
(begin (conn-buffer-set! conn (cdr dec)) (car dec))
+142
(if (<= deadline 0)
+143
#f
+144
(if (socket-ready? (conn-socket conn) 100)
+145
(let ((data (socket-read-bytevector (conn-socket conn))))
+146
(cond
+147
((or (not data) (eof-object? data)) #f)
+148
((= (bytevector-length data) 0) (loop (- deadline 1)))
+149
(else
+150
(conn-buffer-set! conn
+151
(bytevector-append (conn-buffer conn) data))
+152
(loop deadline))))
+153
(loop (- deadline 1))))))))
+154
+155
;; True for streamed output frames (not the terminal eval response).
+156
(define (stream-frame? resp)
+157
(and (pair? resp) (eq? (car resp) 'response)
+158
(memq (response-ref (cdr resp) ':status #f) '(out err))))
+159
+160
;; Read frames from a connection, skipping streamed :out/:err frames, and
+161
;; return the terminal response. Non-eval ops produce no stream frames, so
+162
;; the first frame is returned immediately (backward compatible).
+163
(define (read-terminal-response conn)
+164
(let loop ()
+165
(let ((resp (read-frame conn)))
+166
(cond
+167
((not resp) #f)
+168
((stream-frame? resp) (loop))
+169
(else resp)))))
170
171
;; ============================================================
172
;; Request/Response
@@ -161,7 +186,7 @@
186
#f
187
(begin
188
(socket-write (conn-socket conn) (encode-message request))
164
(read-response (conn-socket conn)))))
+189
(read-terminal-response conn))))
190
191
;; Extract keyword argument from response plist
192
(define (response-ref resp key default)