Commit7f613324Recorded9 Jul 2026Repositorysigil-nrepl
Harden nREPL error policy
Changed
src/sigil/nrepl.sgl | 142 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
test/test-server.sgl | 137 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 246 insertions(+), 33 deletions(-)Diff
src/sigil/nrepl.sglmodified
@@ -105,13 +105,14 @@
105
(define (nrepl-clients-set! server clients) 106
(vector-set! server 4 clients)) 107
−108
;; Client connection: (vector 'nrepl-client socket buffer session-id module debug-mode? debug-error debug-trace debug-frame)+108
;; Client connection: (vector 'nrepl-client socket buffer session-id module debug-mode? debug-error debug-trace debug-frame debug-on-error?) 109
;; debug-mode?: #t when client is in debug mode after an error 110
;; debug-error: the error message string when in debug mode 111
;; debug-trace: captured stack trace for the current error 112
;; debug-frame: current frame index for navigation (0 = innermost)+113
;; debug-on-error?: opt-in policy; default wire sessions stay in normal mode 114
(define (make-nrepl-client socket session-id)−114
(vector 'nrepl-client socket (make-bytevector 0) session-id (nrepl-get-user-module) #f #f #f 0))+115
(vector 'nrepl-client socket (make-bytevector 0) session-id (nrepl-get-user-module) #f #f #f 0 #f)) 116
117
(define (client-socket client) 118
(vector-ref client 1))@@ -155,6 +156,12 @@
156
(define (client-debug-frame-set! client frame) 157
(vector-set! client 8 frame)) 158
+159
(define (client-debug-on-error? client)+160
(vector-ref client 9))+161
+162
(define (client-debug-on-error-set! client enabled?)+163
(vector-set! client 9 enabled?))+164
165
;; ============================================================ 166
;; WIRE PROTOCOL 167
;; ============================================================@@ -239,6 +246,11 @@
246
((macroexpand) (handle-macroexpand client id args)) 247
((modules) (handle-modules client id args)) 248
((info) (handle-info client id args))+249
((debug-policy) (handle-debug-policy client id args))+250
((debug-state) (handle-debug-state client id args))+251
((debug-frames) (handle-debug-frames client id args))+252
((debug-restarts) (handle-debug-restarts client id args))+253
((debug-quit) (handle-debug-quit client id args)) 254
((ping) (make-response id 'ok ':pong #t)) 255
(else (make-error-response id "unknown-op" 256
(format "Unknown operation: ~a" op)))))))@@ -268,6 +280,34 @@
280
(define (make-error-response id code message) 281
(list 'response ':id id ':status 'error ':code code ':message message)) 282
+283
(define (trace->wire trace)+284
(if trace+285
(format "~s" trace)+286
""))+287
+288
(define (error-message exn)+289
(if (string? exn) exn (format "~s" exn)))+290
+291
(define (wire-error code exn)+292
(list ':type code+293
':message (error-message exn)+294
':repr (format "~s" exn)))+295
+296
(define (make-structured-error-response* client id code exn trace)+297
(let ((message (error-message exn)))+298
(make-response* client id 'error+299
':code code+300
':message message+301
':error (wire-error code exn)+302
':stack (trace->wire trace)+303
':in-debugger (client-debug-mode? client))))+304
+305
(define (clear-debug-mode! client)+306
(client-debug-mode-set! client #f)+307
(client-debug-error-set! client #f)+308
(client-debug-trace-set! client #f)+309
(client-debug-frame-set! client 0))+310
311
;; ============================================================ 312
;; DEBUG COMMAND HANDLING 313
;;; Uses shared functions from (sigil repl)@@ -292,18 +332,15 @@
332
(case (car result) 333
((quit) 334
;; Exit debug mode−295
(client-debug-mode-set! client #f)−296
(client-debug-error-set! client #f)−297
(client-debug-trace-set! client #f)−298
(client-debug-frame-set! client 0)−299
(make-response* client id 'ok ':value "Returning to REPL"))+335
(clear-debug-mode! client)+336
(make-response* client id 'ok ':value "Returning to REPL" ':in-debugger #f)) 337
((set-frame) 338
;; Update frame and return text 339
(client-debug-frame-set! client (cadr result))−303
(make-response* client id 'ok ':value (caddr result)))+340
(make-response* client id 'ok ':value (caddr result) ':in-debugger #t)) 341
((output error) 342
;; Return the text−306
(make-response* client id 'ok ':value (cadr result))))))+343
(make-response* client id 'ok ':value (cadr result) ':in-debugger #t))))) 344
345
;; ============================================================ 346
;; OPERATIONS@@ -325,10 +362,23 @@
362
(client-debug-trace-set! client trace) 363
(client-debug-frame-set! client 0)) 364
+365
(define (make-debug-entry-response* client id code exn trace)+366
(let ((message (error-message exn)))+367
(enter-debug-mode-with-trace! client message trace)+368
(make-response* client id 'error+369
':code code+370
':message message+371
':error (wire-error code exn)+372
':stack (trace->wire trace)+373
':in-debugger #t+374
':debugger-ops '(debug-state debug-frames debug-restarts debug-quit)+375
':debug-hint "Use debug-frames/debug-restarts/debug-quit protocol ops")))+376
377
;; eval - evaluate code in session's module context 378
;; Supports REPL commands (,m, ,use, etc.) via shared command handling 379
;; Catches both Scheme exceptions (via guard) and VM errors (via vm-error-prompt-tag)−331
;; Maintains debug mode state per-session for consistent REPL experience+380
;; Default wire sessions return structured errors and remain in normal eval mode.+381
;; Debug mode is entered only when the session's debug-on-error policy is enabled. 382
(define (handle-eval client id args) 383
(let ((code (get-arg args ':code "")) 384
(fmt (get-arg args ':format #f)))@@ -347,36 +397,26 @@
397
;; Wrap in VM error prompt to catch native function errors 398
(call-with-prompt 399
(vm-error-prompt-tag)−350
;; VM error handler - enters debug mode+400
;; VM error handler 401
(lambda (k vm-error-message) 402
(set-current-module! saved-module)−353
(if structured?−354
(let ((err (exception->error vm-error-message)))−355
(make-response* client id 'error−356
':error (inspect-error err)))−357
(begin−358
(enter-debug-mode! client vm-error-message)−359
(make-response* client id 'error−360
':code "vm-error"−361
':message vm-error-message−362
':debug-hint "(type ,help for debug commands, ,q to exit)"))))+403
(let ((trace (last-stack-trace)))+404
(if (client-debug-on-error? client)+405
(make-debug-entry-response* client id "vm-error" vm-error-message trace)+406
(begin+407
(clear-debug-mode! client)+408
(make-structured-error-response* client id "vm-error" vm-error-message trace))))) 409
;; Body with Scheme exception handling 410
(lambda () 411
(guard (exn 412
(else 413
(set-current-module! saved-module)−368
(if structured?−369
(make-response* client id 'error−370
':error (inspect-error exn))−371
(let* ((err-msg (if (string? exn)−372
exn−373
(format "~s" exn)))−374
(trace (capture-debug-trace)))−375
(enter-debug-mode-with-trace! client err-msg trace)−376
(make-response* client id 'error−377
':code "eval-error"−378
':message err-msg−379
':debug-hint "(type ,help for debug commands, ,q to exit)")))))+414
(let ((trace (capture-debug-trace)))+415
(if (client-debug-on-error? client)+416
(make-debug-entry-response* client id "eval-error" exn trace)+417
(begin+418
(clear-debug-mode! client)+419
(make-structured-error-response* client id "eval-error" exn trace)))))) 420
;; Check for REPL commands (,m, ,use, etc.) 421
(if (repl-command? code) 422
(let ((cmd-result (handle-command code)))@@ -394,6 +434,42 @@
434
(format "~s" value)))))))))) 435
result)))))) 436
+437
(define (handle-debug-policy client id args)+438
(let ((enabled? (get-arg args ':debug-on-error #f)))+439
(client-debug-on-error-set! client (and enabled? #t))+440
(when (not enabled?)+441
(clear-debug-mode! client))+442
(make-response* client id 'ok+443
':debug-on-error (client-debug-on-error? client)+444
':in-debugger (client-debug-mode? client))))+445
+446
(define (handle-debug-state client id args)+447
(make-response* client id 'ok+448
':debug-on-error (client-debug-on-error? client)+449
':in-debugger (client-debug-mode? client)+450
':message (client-debug-error client)+451
':current-frame (client-debug-frame client)+452
':has-frames (and (client-debug-trace client) #t)))+453
+454
(define (handle-debug-frames client id args)+455
(if (not (client-debug-mode? client))+456
(make-error-response* client id "not-in-debugger" "Session is not in debugger")+457
(make-response* client id 'ok+458
':in-debugger #t+459
':current-frame (client-debug-frame client)+460
':frames (trace->wire (client-debug-trace client)))))+461
+462
(define (handle-debug-restarts client id args)+463
(if (not (client-debug-mode? client))+464
(make-error-response* client id "not-in-debugger" "Session is not in debugger")+465
(make-response* client id 'ok+466
':in-debugger #t+467
':restarts '((quit "Return to normal eval mode")))))+468
+469
(define (handle-debug-quit client id args)+470
(clear-debug-mode! client)+471
(make-response* client id 'ok ':in-debugger #f ':value "Returning to REPL"))+472
473
;; complete - return completions for a prefix 474
;; Includes both value bindings and syntax/macro bindings 475
(define (handle-complete client id args)test/test-server.sgladded
@@ -0,0 +1,137 @@
+1
(clear-module-cache! '(sigil nrepl))+2
(clear-module-cache! '(sigil nrepl client))+3
+4
(import (sigil test)+5
(sigil socket)+6
(sigil io)+7
(sigil math)+8
(sigil string)+9
(sigil nrepl))+10
+11
(define *test-port* 57889)+12
+13
(define (encode-test-message sexp)+14
(let* ((str (format "~s" sexp))+15
(msg-bytes (string->utf8 str))+16
(len (bytevector-length msg-bytes))+17
(prefix (make-bytevector 4)))+18
(bytevector-u8-set! prefix 0 (quotient len 16777216))+19
(bytevector-u8-set! prefix 1 (quotient (remainder len 16777216) 65536))+20
(bytevector-u8-set! prefix 2 (quotient (remainder len 65536) 256))+21
(bytevector-u8-set! prefix 3 (remainder len 256))+22
(bytevector-append prefix msg-bytes)))+23
+24
(define (send-request sock request)+25
(socket-write sock (encode-test-message request)))+26
+27
(define (read-at-least sock n acc attempts)+28
(cond+29
((<= attempts 0) #f)+30
((>= (bytevector-length acc) n) acc)+31
((socket-ready? sock 20)+32
(let ((data (socket-read-bytevector sock)))+33
(if (and data (not (eof-object? data)) (> (bytevector-length data) 0))+34
(read-at-least sock n (bytevector-append acc data) attempts)+35
(read-at-least sock n acc (- attempts 1)))))+36
(else (read-at-least sock n acc (- attempts 1)))))+37
+38
(define (read-response sock)+39
(let ((header (read-at-least sock 4 (make-bytevector 0) 100)))+40
(if header+41
(let* ((b0 (bytevector-u8-ref header 0))+42
(b1 (bytevector-u8-ref header 1))+43
(b2 (bytevector-u8-ref header 2))+44
(b3 (bytevector-u8-ref header 3))+45
(len (+ (* b0 16777216) (* b1 65536) (* b2 256) b3))+46
(needed (+ 4 len))+47
(full (read-at-least sock needed header 100)))+48
(and full+49
(read-expr (utf8->string (bytevector-copy full 4 needed)))))+50
#f)))+51
+52
(define (response-ref resp key default)+53
(let loop ((rest (if (and (pair? resp) (eq? (car resp) 'response))+54
(cdr resp)+55
'())))+56
(cond+57
((null? rest) default)+58
((null? (cdr rest)) default)+59
((eq? (car rest) key) (cadr rest))+60
(else (loop (cddr rest))))))+61
+62
(define (pump server n)+63
(when (> n 0)+64
(nrepl-process-pending server)+65
(pump server (- n 1))))+66
+67
(define (request-response server sock request)+68
(send-request sock request)+69
(pump server 20)+70
(read-response sock))+71
+72
(define (with-server thunk)+73
(let ((server (nrepl-start *test-port*)))+74
(dynamic-wind+75
(lambda () #t)+76
(lambda () (thunk server))+77
(lambda () (nrepl-stop server)))))+78
+79
(test-group "nrepl error policy"+80
(test "default eval errors are structured and leave the session in normal mode"+81
(with-server+82
(lambda (server)+83
(let ((sock (tcp-connect "127.0.0.1" *test-port*)))+84
(pump server 5)+85
(let ((err-resp (request-response server sock+86
'(request :id "default-error" :op eval :code "(car '())"))))+87
(assert-eq (response-ref err-resp ':status #f) 'error)+88
(assert-equal (response-ref err-resp ':code #f) "eval-error")+89
(assert-false (response-ref err-resp ':in-debugger #t))+90
(assert-true (response-ref err-resp ':error #f))+91
(assert-true (string? (response-ref err-resp ':stack #f))))+92
(let ((state-resp (request-response server sock+93
'(request :id "state" :op debug-state))))+94
(assert-eq (response-ref state-resp ':status #f) 'ok)+95
(assert-false (response-ref state-resp ':in-debugger #t)))+96
(let ((ok-resp (request-response server sock+97
'(request :id "after-error" :op eval :code "(+ 20 22)"))))+98
(assert-eq (response-ref ok-resp ':status #f) 'ok)+99
(assert-equal (response-ref ok-resp ':value #f) "42"))+100
(socket-close sock))))))+101
+102
(test-group "nrepl debug protocol"+103
(test "debug-on-error sessions expose debugger state, frames, restarts, and quit"+104
(with-server+105
(lambda (server)+106
(let ((sock (tcp-connect "127.0.0.1" *test-port*)))+107
(pump server 5)+108
(let ((policy-resp (request-response server sock+109
'(request :id "policy" :op debug-policy :debug-on-error #t))))+110
(assert-eq (response-ref policy-resp ':status #f) 'ok)+111
(assert-true (response-ref policy-resp ':debug-on-error #f)))+112
(let ((err-resp (request-response server sock+113
'(request :id "debug-error" :op eval :code "(car '())"))))+114
(assert-eq (response-ref err-resp ':status #f) 'error)+115
(assert-true (response-ref err-resp ':in-debugger #f))+116
(assert-true (response-ref err-resp ':debugger-ops #f)))+117
(let ((frames-resp (request-response server sock+118
'(request :id "frames" :op debug-frames))))+119
(assert-eq (response-ref frames-resp ':status #f) 'ok)+120
(assert-true (response-ref frames-resp ':in-debugger #f)+121
)+122
(assert-true (string? (response-ref frames-resp ':frames #f))))+123
(let ((restarts-resp (request-response server sock+124
'(request :id "restarts" :op debug-restarts))))+125
(assert-eq (response-ref restarts-resp ':status #f) 'ok)+126
(assert-true (response-ref restarts-resp ':restarts #f)))+127
(let ((quit-resp (request-response server sock+128
'(request :id "quit" :op debug-quit))))+129
(assert-eq (response-ref quit-resp ':status #f) 'ok)+130
(assert-false (response-ref quit-resp ':in-debugger #t)))+131
(let ((ok-resp (request-response server sock+132
'(request :id "after-quit" :op eval :code "(+ 1 2)"))))+133
(assert-eq (response-ref ok-resp ':status #f) 'ok)+134
(assert-equal (response-ref ok-resp ':value #f) "3"))+135
(socket-close sock))))))+136
+137
(run-tests)