AtlatestRepositoryfolio

folio / tree / testtest-mcp-never-silent.sgl

1;;; A failing tool must SAY SO. Silence is the one unacceptable failure mode.
2;;;
3;;; Measured 2026-08-05: `folio/note-links` on an oversized corpus overflowed
4;;; the stack inside the note walk. The raise travelled through the internal
5;;; CPS frames of higher-order stdlib procedures, which the native backend
6;;; mis-unwinds, so the dispatcher's guard never ran and NO JSON-RPC response
7;;; was written at all. The server stayed up and answered later calls -- only
8;;; that one request vanished. The client blocked until its 1800s idle timeout.
9;;;
10;;; Why that is worse than an error: a caller cannot tell silence from "slow"
11;;; from "succeeded", so the standing advice is to retry. A `note-rename` that
12;;; went silent had ALREADY moved the file on disk; a retry of a non-idempotent
13;;; mutation double-applies. Silence turns a bug into a data-integrity risk.
14;;;
15;;; READ THIS BEFORE CHANGING `never-silent`: which failures it re-raises and
16;;; which it returns is a measured distinction, not a style choice.
17;;;
18;;; v1 returned EVERYTHING as a value. That regressed
19;;; test-mcp-error-isolation, which correctly wants ordinary handler errors to
20;;; arrive as a JSON-RPC error envelope.
21;;;
22;;; v2 re-raised everything, including the overflow. It had its own unit tests
23;;; asserting it caught a stack overflow and named the tool; they all passed,
24;;; and the real server STILL went silent when driven against an actually
25;;; overflowing corpus. (Those were different assertions from the ones below --
26;;; the tests in this file are written against v3 and would fail on v2. The
27;;; point is not that these specific tests passed, it is that a plausible,
28;;; thorough set of unit tests for the wrapper did.) A unit test of a wrapper
29;;; cannot see the unwind defect the wrapper exists for: the bug lives in the
30;;; scheduler and the response writer, and calling the wrapper directly
31;;; exercises neither.
32;;;
33;;; v3 classifies by the VM's exact message. An earlier cut used a SUBSTRING
34;;; test, which misrouted any ordinary error whose message merely mentioned a
35;;; stack overflow -- and folio quotes caller text in its error messages, so a
36;;; `note-patch` searching for the phrase got a success envelope and a false
37;;; "may be PARTIALLY applied" warning. See the misclassification group below;
38;;; that regression is why matching is exact.
40(import (sigil test)
41 (sigil string)
42 (sigil list)
43 (folio tools))
45;; Overflows the stack: fold-right is not tail recursive.
46(define (blow-the-stack n)
47 (fold-right (lambda (x acc) (cons x acc)) '() (iota n)))
49;; Run a thunk, reporting whether it returned or raised, and with what.
50(define (outcome thunk)
51 (guard (exn (else (list 'raised (if (error-object? exn)
52 (error-object-message exn)
53 exn))))
54 (list 'returned (thunk))))
56(test-group "stack exhaustion is converted into a report"
57 (test "an overflow RETURNS a report rather than raising"
58 ;; Raising is what got dropped on the wire. It must come back as a value.
59 (let ((r (outcome (lambda ()
60 ((never-silent "folio/note-links"
61 (lambda (args) (blow-the-stack 5000)))
62 (list))))))
63 (assert-equal 'returned (car r))
64 (assert-true (string-contains? (cadr r) "folio/note-links"))
65 (assert-true (string-contains? (cadr r) "FAILED"))
66 (assert-true (string-contains? (cadr r) "stack overflow"))))
68 (test "the report warns that a mutation may be partially applied"
69 ;; A caller that retries a half-applied rename can double-apply it.
70 (let ((r ((never-silent "folio/note-rename"
71 (lambda (args) (blow-the-stack 5000)))
72 (list))))
73 (assert-true (string-contains? r "PARTIALLY"))))
75 (test "an overflow does not poison later calls"
76 (let ((bad (never-silent "folio/bad" (lambda (args) (blow-the-stack 5000))))
77 (good (never-silent "folio/good" (lambda (args) "fine"))))
78 (bad (list))
79 (assert-equal "fine" (good (list)))
80 (bad (list))
81 (assert-equal "fine" (good (list))))))
83;; Ordinary failures already reach the caller correctly as a JSON-RPC error
84;; envelope (pinned end to end by test-mcp-error-isolation). Converting them to
85;; a success envelope with error text inside would be a downgrade, so the
86;; wrapper must pass them straight through.
87(test-group "ordinary failures are re-raised, not swallowed"
88 (test "a plain error is re-raised unchanged"
89 (let ((r (outcome (lambda ()
90 ((never-silent "folio/demo" (lambda (args) (error "inner boom")))
91 (list))))))
92 (assert-equal 'raised (car r))
93 (assert-true (string-contains? (cadr r) "inner boom"))))
95 (test "an error raised from inside map is re-raised too"
96 (let ((r (outcome (lambda ()
97 ((never-silent "folio/demo"
98 (lambda (args) (map (lambda (x) (error "deep boom")) '(1 2))))
99 (list))))))
100 (assert-equal 'raised (car r))
101 (assert-true (string-contains? (cadr r) "deep boom"))))
103 (test "a successful handler is passed through untouched"
104 (let ((h (never-silent "folio/demo" (lambda (args) "the real result"))))
105 (assert-equal "the real result" (h (list))))))
107;; ============================================================
108;; Misclassification: the message is not a haystack
109;; ============================================================
110;;
111;; folio quotes caller-supplied text back in its error messages -- a failed
112;; note-patch reports up to 80 characters of the caller's `old` string. A
113;; substring test for "stack overflow" therefore rerouted ordinary errors that
114;; merely MENTIONED it: the JSON-RPC error envelope was downgraded to a success
115;; envelope, and the caller was told a mutation might be partially applied when
116;; nothing had been written. Reproduced end to end against the real binary.
117;;
118;; The trigger is not exotic -- a folio used for debugging notes is full of the
119;; phrase, including every note written about this very bug.
121(test-group "only the VM's own exhaustion messages are converted"
122 (test "an ordinary error that merely mentions a stack overflow is re-raised"
123 (let ((r (outcome (lambda ()
124 ((never-silent "folio/note-patch"
125 (lambda (args)
126 (error "Patch old string not found: we hit a stack overflow here")))
127 (list))))))
128 (assert-equal 'raised (car r))))
130 (test "a message merely ENDING in the phrase is still re-raised"
131 (let ((r (outcome (lambda ()
132 ((never-silent "folio/note-patch"
133 (lambda (args) (error "not found: stack overflow")))
134 (list))))))
135 (assert-equal 'raised (car r))))
137 (test "the VM's real message is still converted"
138 ;; The guard must not be tightened into uselessness.
139 (let ((r (outcome (lambda ()
140 ((never-silent "folio/note-links"
141 (lambda (args) (blow-the-stack 5000)))
142 (list))))))
143 (assert-equal 'returned (car r)))))