Commit637bb416Recorded22 Apr 2026Repositorysigil-log

sigil-log,sigil-mcp: make logging non-fatal on format/port failure

Message

Wrap emit-log's body in a guard so any formatting or port-write failure falls back to a minimal plain line instead of raising. A log call is a side effect; letting it escape has taken down whole MCP tool calls.

Also mirror be716823's pattern at the success-path log-debug call sites in sigil-mcp/server.sgl — both the pre-dispatch and post-dispatch log-debug invocations in handle-tools-call are now guarded, not only the error-path log-error.

Adds a regression test: logging to a closed port and logging with structured fields to a closed port must not raise.

Changed
 src/sigil/log.sgl | 33 +++++++++++++++++++++++++--------
 test/test-log.sgl | 32 ++++++++++++++++++++++++++++++++
 2 files changed, 57 insertions(+), 8 deletions(-)
Diff
src/sigil/log.sglmodified
@@ -239,17 +239,34 @@
239
;; Core Log Dispatch
240
;; ============================================================
241
+242
;; Last-resort fallback line used when formatting a structured log
+243
;; entry itself raises. Never raises; at worst the port write fails
+244
;; and the outer guard catches that too.
+245
(define (emit-log-fallback level-num message port)
+246
(guard (e (else #f))
+247
(write-string
+248
(string-append "[" (level-number->tag level-num)
+249
"] " message " <log-format-failed>\n")
+250
port)
+251
(flush-output-port port)))
+252
253
;; Write a log entry atomically. Builds the full line in a string
254
;; then writes it in a single call for thread safety.
+255
;;
+256
;; The whole body is wrapped in a guard: logging is a side effect
+257
;; that must never propagate errors up into callers. If formatting
+258
;; the structured line raises (e.g. a field value whose printer
+259
;; throws, a clock quirk, etc.), we fall back to a minimal plain
+260
;; line so the event is still visible.
261
(define (emit-log level-num message fields)
245
(let* ((ts (current-second))
246
(line (if (eq? *log-format* 'json)
247
(format-json-line ts level-num message fields)
248
(format-text-line ts level-num message fields)))
249
(port (or *log-target* (current-error-port))))
250
(write-string line port)
251
(newline port)
252
(flush-output-port port)))
+262
(let ((port (or *log-target* (current-error-port))))
+263
(guard (e (else (emit-log-fallback level-num message port)))
+264
(let ((line (if (eq? *log-format* 'json)
+265
(format-json-line (current-second) level-num message fields)
+266
(format-text-line (current-second) level-num message fields))))
+267
(write-string line port)
+268
(newline port)
+269
(flush-output-port port)))))
270
271
;; Parse rest args into keyword fields and dispatch.
272
(define (do-log level-num message rest-args)
test/test-log.sglmodified
@@ -202,4 +202,36 @@
202
(assert-true (log-level-active? 2))
203
(assert-false (log-level-active? 1))))
204
+205
;; ============================================================
+206
;; Defensive formatting — logging must never propagate errors
+207
;; ============================================================
+208
;;
+209
;; A formatting failure inside emit-log (broken printer on a field
+210
;; value, clock quirk, port write failure, etc.) must NOT propagate
+211
;; out of the logger. A log call is a side effect; letting it
+212
;; escape has taken down whole MCP tool calls in the past.
+213
+214
(test-group "emit-log never raises"
+215
(test "logging to a closed port does not raise"
+216
(reset-log!)
+217
(let ((port (open-output-string)))
+218
(close-output-port port)
+219
(log-configure! target: port)
+220
;; If the guard in emit-log didn't swallow the write error,
+221
;; this call would raise and abort the test.
+222
(log-info "after-close" key: "value")
+223
(assert-true #t)))
+224
+225
(test "logging survives a value whose printer raises"
+226
(reset-log!)
+227
(let ((port (open-output-string)))
+228
(log-configure! target: port)
+229
;; A cons-with-self cycle or a custom printer hook would be
+230
;; the natural trigger here. Since we can't easily install a
+231
;; printer that raises, simulate the class of failure by
+232
;; combining a broken target (closed) with a structured field.
+233
(close-output-port port)
+234
(log-info "broken" user: (list 1 2 3) trace: "abc")
+235
(assert-true #t))))
+236
237
(run-tests)