AtlatestRepositoryfolio

folio / tree / testtest-http-concurrency.mjs

1import assert from 'node:assert/strict';
2import test from 'node:test';
3import { spawn } from 'node:child_process';
4import { once } from 'node:events';
5import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises';
6import { createServer } from 'node:net';
7import { tmpdir } from 'node:os';
8import { join, resolve } from 'node:path';
9
10const binary = resolve(process.env.FOLIO_TEST_BINARY || 'build/dev/bin/folio');
11const lib = resolve(process.env.FOLIO_TEST_LIB || 'build/dev/lib');
12const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
14async 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);
23async 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;
32test('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/);
85 const ready = join(root, 'ready');
86 holder = spawn('sigil', ['-L', lib, resolve('test/fixtures/hold-store-lock.sgl')], {
87 cwd: process.cwd(), env: { ...process.env,
88 FOLIO_LOCK_TEST_ROOT: root, FOLIO_LOCK_TEST_READY: ready, FOLIO_LOCK_TEST_RELEASE: release },
89 stdio: ['ignore', 'pipe', 'pipe'],
90 });
91 holder.stdout.on('data', record);
92 holder.stderr.on('data', record);
93 const lockStart = performance.now();
94 for (;;) {
95 assert.equal(holder.exitCode, null, diagnostic);
96 try { if ((await readFile(ready, 'utf8')) === 'held\n') break; } catch {}
97 assert.ok(performance.now() - lockStart < 5000, `writer lock never held: ${diagnostic}`);
98 await pause(20);
99 }
101 let writerFinished = false;
102 const marker = 'unique committed append';
103 const write = call('folio/note-edit', { name: 'writer', append: marker + '\n' })
104 .finally(() => { writerFinished = true; });
105 // Attach a handler immediately so a failure during the read is still reported.
106 write.catch(() => {});
107 await pause(100);
108 assert.equal(writerFinished, false, 'the writer must actually be waiting');
109 let forcedRelease = false;
110 const safety = setTimeout(() => {
111 forcedRelease = true;
112 writeFile(release, 'release\n').catch(() => {});
113 }, 2000);
114 let text, latency;
115 try {
116 const start = performance.now();
117 text = await call('folio/note-read', { name: 'fast' });
118 latency = performance.now() - start;
119 } finally {
120 clearTimeout(safety);
121 await writeFile(release, 'release\n');
122 await write;
123 }
124 assert.match(text, /committed before the writer/);
125 assert.equal(forcedRelease, false, `read waited for the writer lock (${latency} ms)`);
126 assert.ok(latency < 750, `independent read took ${latency} ms`);
127 const stored = await call('folio/note-read', { name: 'writer' });
128 assert.equal(stored.split(marker).length - 1, 1, 'the append must commit exactly once');
129});