Commit7b511d4cRecorded24 Oct 2026Repositoryfolio

Keep shared HTTP reads running while serialized writers wait

Changed
 CHANGELOG.md                       |  6 ++++++
 package.sgl                        |  8 ++++----
 src/folio/http.sgl                 |  2 +-
 src/folio/main.sgl                 |  8 ++++++--
 src/folio/tools.sgl                | 36 ++++++++++++++++++++++++++++--------
 test/test-writer-serialization.sgl | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 6 files changed, 109 insertions(+), 15 deletions(-)
Diff
CHANGELOG.mdmodified
@@ -5,6 +5,12 @@ All notable changes to **folio** are documented in this file.
5
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+8
## [0.3.4] - Unreleased
+9
+10
- Dispatch validated HTTP tool calls in separate cooperative tasks, so a writer waiting on a store lock does not hold up independent reads. Mutating handlers remain serialized through their index flush; existing cross-process file locking is preserved.
+11
- Use a separate HTTP index connection for reads, keeping uncommitted census/write rows private. Concurrent reads can finish before an in-flight write and see its previous committed state. Reads still have per-query SQLite consistency and atomic Markdown files, without a global multi-file snapshot. The 15-second outside-edit census is unchanged.
+12
- Reduce JSON response encoding CPU with sigil-json's native string escaping. Require sigil-json 0.16.2 and sigil-mcp 0.18.4. HTTP tool replies remain JSON-RPC JSON documents and use chunked responses; stdio is unchanged.
+13
14
## [0.3.3] - 2026-10-24
15
16
- `status` reuses its index work while nothing it reads has changed. The note summaries and orphan names, with the sorted recent notes and the orphan list derived from them, are kept while the native `notes/` listing, SQLite's `data_version` (another connection's commit) and `total_changes()` (this connection's own writes) are unchanged, so a note deleted outside folio still leaves `status` at once. The key also holds the connection's transaction count, so rows read inside a transaction that rolls back are not reused. A `notes/` symlink the walk follows, or a failed query, turns the reuse off.
package.sglmodified
@@ -6,7 +6,7 @@
6
7
(package
8
name: "folio"
−9
version: "0.3.3"
+9
version: "0.3.4"
10
sigil: "0.22.15"
11
description: "Markdown-native knowledge and project management via MCP"
12
url: "https://codeberg.org/sigil/folio"
@@ -66,10 +66,10 @@
66
package: "sigil-lib" version: "0.22.15")
67
(from-git url: "codeberg:sigil/sigil" package: "sigil-run" version: "0.22.15")
68
(from-git url: "codeberg:sigil/sigil" package: "sigil-repl" version: "0.22.15")
−69
(from-git url: "codeberg:sigil/sigil-json" version: "^0.16.1")
+69
(from-git url: "codeberg:sigil/sigil-json" version: "^0.16.2")
70
(from-git url: "codeberg:sigil/sigil-log" version: "^0.16.0")
−71
;; Stateless legacy HTTP follow-up requests require the 0.18.3 fix.
−72
(from-git url: "codeberg:sigil/sigil-mcp" version: "^0.18.3")
+71
;; Deferred HTTP tool dispatch requires the opt-in 0.18.4 API.
+72
(from-git url: "codeberg:sigil/sigil-mcp" version: "^0.18.4")
73
(from-git url: "codeberg:sigil/sigil-http" version: "^0.18.8")
74
(from-git url: "codeberg:sigil/sigil-crypto" version: "^0.16.0")
75
(from-git url: "codeberg:sigil/sigil-sqlite" version: "^0.16.0")
src/folio/http.sglmodified
@@ -48,7 +48,7 @@
48
token))
49
50
(define (folio-http-handler server token)
−51
(let ((handler (mcp-http-handler server))
+51
(let ((handler (mcp-http-handler server concurrent-tools?: #t))
52
(authorization (string-append "Bearer " token)))
53
(lambda (req)
54
(let ((provided (http-request-header req "Authorization")))
src/folio/main.sglmodified
@@ -257,8 +257,12 @@
257
(http-port (and http-option (string->number (cadr http-option))))
258
(token-option (member "--token-file" (command-line)))
259
(token (and http-port
−260
(read-http-token (and token-option (cadr token-option))))))
−261
(register-folio-tools! server store idx allow-rebuild?: (not http-port))
+260
(read-http-token (and token-option (cadr token-option)))))
+261
;; A separate connection keeps reads out of census/write
+262
;; transactions. Only idx runs a watcher; stdio retains one DB.
+263
(read-idx (if http-port (make-folio-index store) idx)))
+264
(register-folio-tools! server store idx
+265
allow-rebuild?: (not http-port) read-index: read-idx)
266
267
(when (member "--migrate-index" (command-line))
268
(guard (exn
src/folio/tools.sglmodified
@@ -10,6 +10,7 @@
10
(sigil json)
11
(sigil log)
12
(sigil time)
+13
(sigil channels)
14
(sigil path)
15
(sigil mcp server)
16
(folio store)
@@ -2181,12 +2182,31 @@
2182
(handler args))))
2183
2184
;;; Register all folio tools with the MCP server.
−2184
(define (register-folio-tools! server store idx (keys: (allow-rebuild? #t)))
−2185
(: mcp-server? folio-store? folio-index? (allow-rebuild?: boolean?) -> void?)
+2185
(define (register-folio-tools! server store idx
+2186
(keys: (allow-rebuild? #t) (read-index idx)))
+2187
(: mcp-server? folio-store? folio-index?
+2188
(allow-rebuild?: boolean?) (read-index: folio-index?) -> void?)
+2189
;; Serialize complete mutations, including their index flush, while
+2190
;; deferred HTTP read handlers remain free to run on another connection.
+2191
;; The store lock still coordinates writers in other processes.
+2192
(define writers (make-channel 1))
+2193
(define (serialize-writer handler)
+2194
(lambda (args)
+2195
(receive writers)
+2196
(guard (exn (else (send writers #t) (raise exn)))
+2197
(let ((result (handler args)))
+2198
(send writers #t)
+2199
result))))
2200
(define (register! name description schema handler)
−2187
(mcp-server-register-tool! server name description
−2188
(closed-schema schema)
−2189
(never-silent name (guarded-handler store idx name schema handler))))
+2201
(let ((safe-handler
+2202
(never-silent name (guarded-handler store idx name schema handler))))
+2203
(mcp-server-register-tool! server name description
+2204
(closed-schema schema)
+2205
(if (member name unlocked-tools)
+2206
safe-handler
+2207
(serialize-writer safe-handler)))))
+2208
+2209
(send writers #t)
2210
2211
;; Inbox
2212
(register!
@@ -2250,7 +2270,7 @@
2270
;; Notes
2271
(register!
2272
"folio/notes" "Search notes by text or tag"
−2253
notes-schema (make-tool-notes store idx))
+2273
notes-schema (make-tool-notes store read-index))
2274
2275
(register!
2276
"folio/note-read" "Read a note's full content"
@@ -2270,7 +2290,7 @@
2290
2291
(register!
2292
"folio/note-links" "Show outgoing links and backlinks for a note"
−2273
note-name-schema (make-tool-note-links store idx))
+2293
note-name-schema (make-tool-note-links store read-index))
2294
2295
(register!
2296
"folio/note-rename" "Rename a note and update all crosslinks"
@@ -2315,4 +2335,4 @@
2335
2336
(register!
2337
"folio/status" "Dashboard: inbox, overdue, active/stale projects, orphaned notes"
−2318
no-args-schema (make-tool-status store idx)))))
+2338
no-args-schema (make-tool-status store read-index)))))
test/test-writer-serialization.sgladded
@@ -0,0 +1,64 @@
+1
;;; A mutation remains serialized through its delayed index flush.
+2
(import (sigil test)
+3
(sigil fs)
+4
(sigil path)
+5
(sigil string)
+6
(sigil async)
+7
(sigil channels)
+8
(sigil time)
+9
(sigil sqlite)
+10
(sigil mcp server)
+11
(sigil mcp protocol)
+12
(folio store)
+13
(folio index)
+14
(folio tools))
+15
+16
(test-group "whole writer serialization"
+17
(test "a second mutation waits until the first index flush finishes"
+18
(call-with-temp-directory
+19
(lambda (root)
+20
(let* ((store (make-folio-store root))
+21
(file (path-join (store-notes-dir store) "writer.md"))
+22
(_ (write-file-string file "# Writer\n"))
+23
(idx (make-folio-index store))
+24
(_ (index-census-tick! idx))
+25
(server (mcp-server name: "writer-test"))
+26
(ready (make-channel 1))
+27
(release (make-channel 1))
+28
(done (make-channel 2))
+29
(first-visible? #f)
+30
(second-waited? #f))
+31
(define (append! id marker)
+32
(let ((reply (mcp-server-handle-message server
+33
(jsonrpc-request id: id method: "tools/call"
+34
params: (dict name: "folio/note-edit"
+35
arguments: (dict name: "writer" append: marker))))))
+36
(send done reply)))
+37
(register-folio-tools! server store idx)
+38
(with-async
+39
(go
+40
(with-index-transaction idx
+41
(lambda ()
+42
(send ready #t)
+43
(receive release))))
+44
(go
+45
(receive ready)
+46
(go (append! 1 "first-marker\n"))
+47
(let wait ((attempt 0))
+48
(unless (or (string-contains? (read-file-string file) "first-marker")
+49
(= attempt 200))
+50
(sleep 0.01)
+51
(wait (+ attempt 1))))
+52
(set! first-visible? (string-contains? (read-file-string file) "first-marker"))
+53
(go (append! 2 "second-marker\n"))
+54
(sleep 0.1)
+55
(set! second-waited? (not (string-contains? (read-file-string file) "second-marker")))
+56
(send release #t)
+57
(assert-true (jsonrpc-response? (receive done)))
+58
(assert-true (jsonrpc-response? (receive done)))))
+59
(assert-true first-visible?)
+60
(assert-true second-waited?)
+61
(let ((content (read-file-string file)))
+62
(assert-true (string-contains? content "first-marker"))
+63
(assert-true (string-contains? content "second-marker")))
+64
(sqlite-close (folio-index-db idx)))))))