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))))))))