Commitd6436f26Recorded7 Jul 2026Repositoryfolio

Parse note-patch entries via direct recursion, not map

Message

A malformed note-patch (patch entry missing old/new) made the folio MCP drop its JSON-RPC error response on the native (release) binary: the client never got an answer for that request id, though the session stayed healthy. It only reproduced on native; the bytecode (dev) bundle serialized the error fine, so the earlier re-lock alone (verified on dev) missed it.

Root cause is a sigil native-backend codegen bug: an exception propagating through map's CPS frames still corrupts call-with-prompt's return context on native. That is the exact VM bug sigil-mcp server.sgl:272-279 documents and whose call/cc-escape workaround was removed as fixed in v0.16.0, but the fix only landed for bytecode, not native. make-tool-note-patch raised inside (map parse-patch-entry ...); the corrupted continuation then threw car: expected pair while building the error response (and a trailing type-error at shutdown), so the response was never written.

Fix: parse patch entries with a direct-recursion helper so the raise stays on ordinary call frames and never crosses map's CPS frames. The dispatcher guard then serializes a clean -32603 error. Verified against the release/native binary with the 4-message probe (init, note-create, malformed note-patch, status): id3 = clean JSON-RPC error, session healthy; test-mcp-error-isolation passes 4/4 on release; full suite 148 pass. All other folio raise sites go through direct frames and were already native-safe.

The underlying native codegen bug is broad (any native raise through map inside a prompt) and should be fixed in sigil; tracked separately.

Changed
 src/folio/tools.sgl | 19 +++++++++++++++++--
 1 file changed, 17 insertions(+), 2 deletions(-)
Diff
src/folio/tools.sglmodified
@@ -821,6 +821,22 @@
821
(error "Patch entry missing 'new' (or 'content') field"))
822
(else (cons old-str new-str)))))
823
+824
;; Parse a list of patch objects via direct recursion rather than `map`.
+825
;; parse-patch-entry raises on a malformed entry; `map` (a higher-order
+826
;; stdlib procedure) propagates that exception through its internal CPS
+827
;; frames, which the native backend mis-unwinds — the raised error corrupts
+828
;; the dispatch continuation so the JSON-RPC error is never serialised and
+829
;; the response is silently dropped (car: expected pair / type-error on the
+830
;; native release binary; harmless on bytecode). Direct recursion keeps the
+831
;; raise on ordinary call frames so the dispatcher's guard serialises a
+832
;; clean error. See test/test-mcp-error-isolation.sgl and
+833
;; investigations/folio-mcp-tool-error-wedge-async-log-unwind.
+834
(define (parse-patch-entries entries)
+835
(if (null? entries)
+836
'()
+837
(let ((head (parse-patch-entry (car entries))))
+838
(cons head (parse-patch-entries (cdr entries))))))
+839
840
(define (make-tool-note-patch store idx)
841
(lambda (args)
842
(let* ((name (require-arg args name: "name"))
@@ -828,8 +844,7 @@
844
(file-path (find-note-file store name)))
845
(if (not file-path)
846
(format "Note '~a' not found." name)
831
(let ((patches (map parse-patch-entry
832
(array->list patches-raw))))
+847
(let ((patches (parse-patch-entries (array->list patches-raw))))
848
(let-values (((old-body new-body) (note-patch! file-path patches)))
849
(index-note! idx file-path)
850
(string-append