Commit8d1f6bc6Recorded24 Oct 2026Repositoryfolio
Test HTTP read progress during writer waits and committed index visibility
Changed
test/fixtures/hold-store-lock.sgl | 18 ++++++++++++++++++
test/test-http-concurrency.mjs | 127 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
test/test-http-read-index.sgl | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 204 insertions(+)Diff
test/fixtures/hold-store-lock.sgladded
@@ -0,0 +1,18 @@
+1
;;; Hold a real writer lock until the test publishes its release marker.+2
(import (sigil fs)+3
(sigil process)+4
(sigil time)+5
(folio store)+6
(folio lock))+7
+8
(let* ((args (cdr (command-line)))+9
(store (make-folio-store (car args)))+10
(ready (cadr args))+11
(release (caddr args)))+12
(call-with-store-lock store+13
(lambda ()+14
(write-file-string ready "held\n")+15
(let loop ()+16
(unless (file-exists? release)+17
(sleep 0.02)+18
(loop))))))test/test-http-concurrency.mjsadded
@@ -0,0 +1,127 @@
+1
import assert from 'node:assert/strict';+2
import test from 'node:test';+3
import { spawn } from 'node:child_process';+4
import { once } from 'node:events';+5
import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises';+6
import { createServer } from 'node:net';+7
import { tmpdir } from 'node:os';+8
import { join, resolve } from 'node:path';+9
+10
const binary = resolve(process.env.FOLIO_TEST_BINARY || 'build/dev/bin/folio');+11
const lib = resolve(process.env.FOLIO_TEST_LIB || 'build/dev/lib');+12
const pause = ms => new Promise(resolve => setTimeout(resolve, ms));+13
+14
async function stop(child) {+15
if (!child || child.exitCode !== null || child.signalCode !== null) return;+16
const exited = once(child, 'exit');+17
child.kill('SIGTERM');+18
const timer = setTimeout(() => child.kill('SIGKILL'), 3000);+19
await exited;+20
clearTimeout(timer);+21
}+22
+23
async function unusedPort() {+24
const listener = createServer();+25
listener.listen(0, '127.0.0.1');+26
await once(listener, 'listening');+27
const port = listener.address().port;+28
await new Promise(resolve => listener.close(resolve));+29
return port;+30
}+31
+32
test('a read completes while a writer is waiting for another process', { timeout: 30000 }, async t => {+33
const root = await mkdtemp(join(tmpdir(), 'folio-http-concurrent-'));+34
let server, holder, diagnostic = '';+35
const release = join(root, 'release');+36
t.after(async () => {+37
await writeFile(release, 'release\n').catch(() => {});+38
await stop(holder);+39
await stop(server);+40
await rm(root, { recursive: true, force: true });+41
});+42
await mkdir(join(root, 'notes'));+43
await writeFile(join(root, 'notes/fast.md'), '# Fast\ncommitted before the writer\n');+44
await writeFile(join(root, 'notes/writer.md'), '# Writer\n');+45
const token = 'test-only-' + 'ab12'.repeat(12);+46
const tokenFile = join(root, 'token');+47
await writeFile(tokenFile, token + '\n', { mode: 0o600 });+48
const port = await unusedPort();+49
const url = `http://127.0.0.1:${port}/mcp`;+50
server = spawn(binary, ['serve', '--http', String(port), '--token-file', tokenFile], {+51
env: { ...process.env, FOLIO_ROOT: root }, stdio: ['ignore', 'pipe', 'pipe'],+52
});+53
const record = chunk => { diagnostic = (diagnostic + chunk).slice(-12000); };+54
server.stdout.on('data', record);+55
server.stderr.on('data', record);+56
const startup = performance.now();+57
for (;;) {+58
assert.equal(server.exitCode, null, diagnostic);+59
try {+60
const response = await fetch(url, { signal: AbortSignal.timeout(1000) });+61
await response.text();+62
if (response.status === 401) break;+63
} catch {}+64
assert.ok(performance.now() - startup < 10000, `server never ready: ${diagnostic}`);+65
await pause(50);+66
}+67
let id = 0;+68
async function call(name, args) {+69
const requestId = ++id;+70
const response = await fetch(url, {+71
method: 'POST', signal: AbortSignal.timeout(10000),+72
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json',+73
'MCP-Protocol-Version': '2025-11-25' },+74
body: JSON.stringify({ jsonrpc: '2.0', id: requestId, method: 'tools/call',+75
params: { name, arguments: args } }),+76
});+77
const reply = await response.json();+78
assert.equal(response.status, 200, JSON.stringify(reply));+79
assert.equal(reply.id, requestId);+80
assert.ok(!reply.error && !reply.result?.isError, JSON.stringify(reply));+81
return reply.result.content.map(item => item.text || '').join('\n');+82
}+83
assert.match(await call('folio/note-read', { name: 'fast' }), /committed before the writer/);+84
+85
const ready = join(root, 'ready');+86
holder = spawn('sigil', ['-L', lib, resolve('test/fixtures/hold-store-lock.sgl'), root, ready, release], {+87
cwd: process.cwd(), env: process.env, stdio: ['ignore', 'pipe', 'pipe'],+88
});+89
holder.stdout.on('data', record);+90
holder.stderr.on('data', record);+91
const lockStart = performance.now();+92
for (;;) {+93
assert.equal(holder.exitCode, null, diagnostic);+94
try { if ((await readFile(ready, 'utf8')) === 'held\n') break; } catch {}+95
assert.ok(performance.now() - lockStart < 5000, `writer lock never held: ${diagnostic}`);+96
await pause(20);+97
}+98
+99
let writerFinished = false;+100
const marker = 'unique committed append';+101
const write = call('folio/note-edit', { name: 'writer', append: marker + '\n' })+102
.finally(() => { writerFinished = true; });+103
// Attach a handler immediately so a failure during the read is still reported.+104
write.catch(() => {});+105
await pause(100);+106
assert.equal(writerFinished, false, 'the writer must actually be waiting');+107
let forcedRelease = false;+108
const safety = setTimeout(() => {+109
forcedRelease = true;+110
writeFile(release, 'release\n').catch(() => {});+111
}, 2000);+112
let text, latency;+113
try {+114
const start = performance.now();+115
text = await call('folio/note-read', { name: 'fast' });+116
latency = performance.now() - start;+117
} finally {+118
clearTimeout(safety);+119
await writeFile(release, 'release\n');+120
await write;+121
}+122
assert.match(text, /committed before the writer/);+123
assert.equal(forcedRelease, false, `read waited for the writer lock (${latency} ms)`);+124
assert.ok(latency < 750, `independent read took ${latency} ms`);+125
const stored = await call('folio/note-read', { name: 'writer' });+126
assert.equal(stored.split(marker).length - 1, 1, 'the append must commit exactly once');+127
});test/test-http-read-index.sgladded
@@ -0,0 +1,59 @@
+1
;;; HTTP reads use committed index rows while a writer owns its transaction.+2
(import (sigil test)+3
(sigil fs)+4
(sigil path)+5
(sigil json)+6
(sigil string)+7
(sigil sqlite)+8
(sigil mcp server)+9
(sigil mcp protocol)+10
(folio store)+11
(folio index)+12
(folio tools))+13
+14
(define (call-tool server name args)+15
(let* ((reply+16
(json-decode+17
(response->json+18
(mcp-server-handle-message server+19
(jsonrpc-request id: 1 method: "tools/call"+20
params: (dict name: name arguments: args))))))+21
(content (dict-ref (dict-ref reply result:) content:)))+22
(dict-ref (array-ref content 0) text:)))+23
+24
(test-group "separate read index"+25
(test "tool reads see committed rows during a writer transaction and refresh after commit"+26
(call-with-temp-directory+27
(lambda (root)+28
(let* ((store (make-folio-store root))+29
(notes (store-notes-dir store)))+30
(write-file-string (path-join notes "a.md")+31
"---\nlast-updated: 2026-01-01\n---\n# A\n[[b]]\n")+32
(write-file-string (path-join notes "b.md")+33
"---\nlast-updated: 2026-01-02\n---\n# B\n")+34
(let* ((writer (make-folio-index store))+35
(_ (index-census-tick! writer))+36
(reader (make-folio-index store))+37
(server (mcp-server name: "read-index-test")))+38
(register-folio-tools! server store writer read-index: reader)+39
(assert-true (string-contains?+40
(call-tool server "folio/note-links" (dict name: "b")) "← a"))+41
(with-index-transaction writer+42
(lambda ()+43
(assert-true (sqlite-run (folio-index-db writer)+44
"UPDATE notes_meta SET last_updated = ? WHERE name = ?"+45
"2099-12-31" "a"))+46
(assert-true (sqlite-run (folio-index-db writer)+47
"DELETE FROM note_links WHERE target = ?" "b"))+48
(assert-true (string-contains?+49
(call-tool server "folio/status" (dict))+50
"## Recent Notes\n\n b\n a"))+51
(assert-true (string-contains?+52
(call-tool server "folio/note-links" (dict name: "b")) "← a"))))+53
(assert-true (string-contains?+54
(call-tool server "folio/status" (dict))+55
"## Recent Notes\n\n a\n b"))+56
(assert-false (string-contains?+57
(call-tool server "folio/note-links" (dict name: "b")) "← a"))+58
(sqlite-close (folio-index-db reader))+59
(sqlite-close (folio-index-db writer))))))))