Commit59c43c35Recorded7 Jul 2026Repositoryfolio

Re-lock to sigil 0.17.3 to fix note-patch handler-error session wedge

Message

A folio MCP tool handler that raises (e.g. malformed note-patch: a patch entry missing 'old'/'new') wedged the entire MCP session with no response, requiring an /mcp reconnect. Root cause is a sigil runtime bug, not folio logic: during exception unwinding, a log/response write to an async port (stdin/stdout/stderr pipes) aborts to async-prompt-tag after the task's async prompt has been unwound past. The abort finds no matching prompt, raises, that raise is logged, re-enters the write path, and recurses, corrupting the continuation (car: expected pair) so no response is ever written.

Fixed in sigil ecd318755 (prompt-tag-available? gate), shipped in 0.17.3. Re-lock the sigil monorepo deps (stdlib/lib/run/test/test-runner) 0.17.1 -> 0.17.3 (c333fbde). No folio source change required.

note-read with a wrong key never triggered this because it is rejected at schema validation and never reaches the handler; only a tool that actually raises enters the unwind-during-async-write path.

Extend test-mcp-error-isolation.sgl with a raising-handler probe: create a note, send a malformed note-patch (clean JSON-RPC error, no #<object>, no hang), then a valid follow-up (session stays healthy).

Changed
 sigil.lock                        | 20 ++++++++++----------
 test/test-mcp-error-isolation.sgl | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 100 insertions(+), 10 deletions(-)
Diff
sigil.lockmodified
@@ -3,21 +3,21 @@
3
(package name: "sigil-stdlib"
4
url: "codeberg:sigil/sigil"
5
ref: "^0.17"
6
sha: "61ba6501f4ff5d1fd534c13c4a3494b69cc88412"
+6
sha: "c333fbde5ec2d805eddff982952c9993cd441715"
7
package-selector: "sigil-stdlib"
8
version: "0.17.1")
+8
version: "0.17.3")
9
(package name: "sigil-run"
10
url: "codeberg:sigil/sigil"
11
ref: "^0.17.0"
12
sha: "61ba6501f4ff5d1fd534c13c4a3494b69cc88412"
+12
sha: "c333fbde5ec2d805eddff982952c9993cd441715"
13
package-selector: "sigil-run"
14
version: "0.17.1")
+14
version: "0.17.3")
15
(package name: "sigil-lib"
16
url: "codeberg:sigil/sigil"
17
ref: "^0.17"
18
sha: "61ba6501f4ff5d1fd534c13c4a3494b69cc88412"
+18
sha: "c333fbde5ec2d805eddff982952c9993cd441715"
19
package-selector: "sigil-lib"
20
version: "0.17.1")
+20
version: "0.17.3")
21
(package name: "sigil-json"
22
url: "codeberg:sigil/sigil-json"
23
ref: "^0.16.0"
@@ -66,15 +66,15 @@
66
(package name: "sigil-test"
67
url: "codeberg:sigil/sigil"
68
ref: "^0.17"
69
sha: "61ba6501f4ff5d1fd534c13c4a3494b69cc88412"
+69
sha: "c333fbde5ec2d805eddff982952c9993cd441715"
70
package-selector: "sigil-test"
71
version: "0.17.1")
+71
version: "0.17.3")
72
(package name: "sigil-test-runner"
73
url: "codeberg:sigil/sigil"
74
ref: "^0.17"
75
sha: "61ba6501f4ff5d1fd534c13c4a3494b69cc88412"
+75
sha: "c333fbde5ec2d805eddff982952c9993cd441715"
76
package-selector: "sigil-test-runner"
77
version: "0.17.1")
+77
version: "0.17.3")
78
(package name: "sigil-socket"
79
url: "codeberg:sigil/sigil-socket"
80
ref: "^0.16.0"
test/test-mcp-error-isolation.sglmodified
@@ -8,6 +8,19 @@
8
;;; (wrong argument key), then a valid tool/call. It asserts that both
9
;;; responses are well-formed JSON — NOT the literal "#<object>" text.
10
;;;
+11
;;; A SECOND regression is guarded here (see the "malformed note-patch"
+12
;;; group below): a tool whose handler actually RAISES (not merely a schema
+13
;;; rejection) once wedged the whole session with no response at all. The
+14
;;; malformed note-read case above is rejected at schema validation and never
+15
;;; reaches the handler, so it did NOT exercise the raising path. note-patch
+16
;;; on an existing note passes schema validation, then raises inside `map`
+17
;;; (parse-patch-entry, missing 'old'/'new'). On sigil 0.17.1 the exception
+18
;;; unwound through the async scheduler; a log/response write during unwind
+19
;;; aborted to an async prompt already unwound past, recursed, corrupted the
+20
;;; continuation ("car: expected pair"), and NO response was ever written —
+21
;;; the session hung until the client cancelled. Fixed in sigil 0.17.3
+22
;;; (ecd318755, prompt-tag-available? gate); folio must stay locked to >=0.17.3.
+23
;;;
24
;;; Requires: build/release/bin/folio (or build/dev/bin/folio) to exist.
25
26
(import (sigil test)
@@ -106,3 +119,80 @@
119
(assert-false (poisoned-response? followup)))
120
(test "follow-up valid call has Folio Status content"
121
(assert-true (string-contains? followup "Folio Status"))))))))))
+122
+123
;; Drive the binary through the RAISING-handler path: initialize, create a note,
+124
;; then send a malformed note-patch (patch entry missing 'old'/'new') so the
+125
;; handler raises inside `map`, then a valid follow-up. Returns the four response
+126
;; lines: initialize, note-create, note-patch (error), status. If the server
+127
;; wedges, read-line blocks and the test harness surfaces it as a hang rather
+128
;; than a pass.
+129
(define (patch-poison-and-probe folio-binary)
+130
(let* ((root (make-tmp-folio-root))
+131
(_ (setenv! "FOLIO_ROOT" root))
+132
(p (process-spawn folio-binary)))
+133
(send-line! (process-stdin p)
+134
(string-append
+135
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\","
+136
"\"params\":{\"protocolVersion\":\"2025-11-25\","
+137
"\"capabilities\":{}}}"))
+138
(send-line! (process-stdin p)
+139
"{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}")
+140
;; Seed a real note so note-patch reaches its handler (find-note-file hits).
+141
(send-line! (process-stdin p)
+142
(string-append
+143
"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\","
+144
"\"params\":{\"name\":\"folio/note-create\","
+145
"\"arguments\":{\"name\":\"probe-note\","
+146
"\"content\":\"Body line one. Body line two.\"}}}"))
+147
;; Malformed note-patch: patch entry has 'content' but no 'old'/'match',
+148
;; so parse-patch-entry raises. This is the RAISING-handler path.
+149
(send-line! (process-stdin p)
+150
(string-append
+151
"{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\","
+152
"\"params\":{\"name\":\"folio/note-patch\","
+153
"\"arguments\":{\"name\":\"probe-note\","
+154
"\"patches\":[{\"op\":\"append\",\"content\":\"x\"}]}}}"))
+155
;; Follow-up valid call: must succeed if the session stayed healthy.
+156
(send-line! (process-stdin p)
+157
(string-append
+158
"{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\","
+159
"\"params\":{\"name\":\"folio/status\",\"arguments\":{}}}"))
+160
(close-output-port (process-stdin p))
+161
(let ((lines (read-lines-until (process-stdout p) 4)))
+162
(process-wait p)
+163
lines)))
+164
+165
(test-group "mcp malformed note-patch does not wedge the session"
+166
(let ((folio (existing-folio-binary)))
+167
(cond
+168
((not folio)
+169
(test "skipped: folio binary not built"
+170
(assert-true #t)))
+171
(else
+172
(let ((lines (patch-poison-and-probe folio)))
+173
;; Four responses (init, create, patch-error, status) prove the
+174
;; malformed note-patch neither hung nor swallowed later responses.
+175
(test "got four response lines (no wedge/hang)"
+176
(assert-equal 4 (length lines)))
+177
(when (= (length lines) 4)
+178
(let ((init-resp (list-ref lines 0))
+179
(create-resp (list-ref lines 1))
+180
(patch-resp (list-ref lines 2))
+181
(status-resp (list-ref lines 3)))
+182
(test "init response is well-formed"
+183
(assert-true (string-contains? init-resp "\"protocolVersion\"")))
+184
(test "note-create succeeded"
+185
(assert-true (string-contains? create-resp "Created note")))
+186
;; The raising handler must serialise to a well-formed JSON-RPC
+187
;; error — never "#<object>", never a missing response.
+188
(test "malformed note-patch returns a JSON-RPC error"
+189
(assert-true (string-contains? patch-resp "\"error\"")))
+190
(test "malformed note-patch error names the missing field"
+191
(assert-true (string-contains? patch-resp "missing 'old'")))
+192
(test "malformed note-patch does not return #<object>"
+193
(assert-false (poisoned-response? patch-resp)))
+194
;; The session must remain fully healthy for the next call.
+195
(test "follow-up valid call does not return #<object>"
+196
(assert-false (poisoned-response? status-resp)))
+197
(test "follow-up valid call has Folio Status content"
+198
(assert-true (string-contains? status-resp "Folio Status"))))))))))