AtlatestRepositoryfolio
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 overflowed4
;;; the stack inside the note walk. The raise travelled through the internal5
;;; CPS frames of higher-order stdlib procedures, which the native backend6
;;; mis-unwinds, so the dispatcher's guard never ran and NO JSON-RPC response7
;;; was written at all. The server stayed up and answered later calls -- only8
;;; 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` that12
;;; went silent had ALREADY moved the file on disk; a retry of a non-idempotent13
;;; 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 and16
;;; which it returns is a measured distinction, not a style choice.17
;;;18
;;; v1 returned EVERYTHING as a value. That regressed19
;;; test-mcp-error-isolation, which correctly wants ordinary handler errors to20
;;; arrive as a JSON-RPC error envelope.21
;;;22
;;; v2 re-raised everything, including the overflow. It had its own unit tests23
;;; 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 actually25
;;; 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. The27
;;; 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 wrapper29
;;; cannot see the unwind defect the wrapper exists for: the bug lives in the30
;;; scheduler and the response writer, and calling the wrapper directly31
;;; exercises neither.32
;;;33
;;; v3 classifies by the VM's exact message. An earlier cut used a SUBSTRING34
;;; test, which misrouted any ordinary error whose message merely mentioned a35
;;; stack overflow -- and folio quotes caller text in its error messages, so a36
;;; `note-patch` searching for the phrase got a success envelope and a false37
;;; "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 error84
;; envelope (pinned end to end by test-mcp-error-isolation). Converting them to85
;; a success envelope with error text inside would be a downgrade, so the86
;; 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 haystack109
;; ============================================================110
;;111
;; folio quotes caller-supplied text back in its error messages -- a failed112
;; note-patch reports up to 80 characters of the caller's `old` string. A113
;; substring test for "stack overflow" therefore rerouted ordinary errors that114
;; merely MENTIONED it: the JSON-RPC error envelope was downgraded to a success115
;; envelope, and the caller was told a mutation might be partially applied when116
;; 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 the119
;; 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)))))