AtlatestRepositoryfolio

folio / tree / testtest-census-bound.mjs

1// The census fast path must not delay outside edits: once a server is idle
2// (the fast path armed), an outside change must reach backlinks within one
3// 15 s tick plus a 2 s margin. Each edit below keeps the file's size, and two
4// of them also keep its mtime, so only ctime, inode or a directory time moves.
5import assert from 'node:assert/strict';
6import { spawn, execFileSync } from 'node:child_process';
7import { mkdtemp, mkdir, writeFile, rm, stat, utimes, rename } from 'node:fs/promises';
8import { tmpdir } from 'node:os';
9import { join, resolve } from 'node:path';
10import { once } from 'node:events';
11import test from 'node:test';
13const binary = resolve(process.env.FOLIO_TEST_BINARY || 'build/dev/bin/folio');
14const pause = ms => new Promise(r => setTimeout(r, ms));
15const BOUND_MS = 17000;
17function client(root) {
18 const child = spawn(binary, ['serve', '--stdio', '--log-level', 'debug'], {
19 env: { ...process.env, FOLIO_ROOT: root }, stdio: ['pipe', 'pipe', 'pipe'],
20 });
21 let sequence = 0, buffer = '', diagnostic = '';
22 const pending = new Map();
23 child.stderr.on('data', d => { diagnostic = (diagnostic + d).slice(-4000); });
24 child.stdout.on('data', d => {
25 buffer += d;
26 for (let end; (end = buffer.indexOf('\n')) >= 0;) {
27 const line = buffer.slice(0, end); buffer = buffer.slice(end + 1);
28 let m; try { m = JSON.parse(line); } catch { continue; }
29 const p = pending.get(m.id); if (!p) continue;
30 pending.delete(m.id);
31 if (m.error || m.result?.isError) p.reject(new Error(JSON.stringify(m))); else p.resolve(m.result);
32 }
33 });
34 return {
35 child,
36 call(name, args = {}) {
37 const id = ++sequence;
38 return new Promise((res, rej) => {
39 pending.set(id, { resolve: res, reject: rej });
40 child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method: 'tools/call',
41 params: { name: `folio/${name}`, arguments: args } }) + '\n');
42 }).then(r => r.content.map(c => c.text || '').join('\n'));
43 },
44 async close() {
45 const exited = once(child, 'exit');
46 child.stdin.end();
47 const timer = setTimeout(() => child.kill('SIGKILL'), 3000);
48 await exited; clearTimeout(timer);
49 },
50 diagnostic: () => diagnostic,
51 };
54// Poll note-links until `predicate` holds; return the elapsed ms or fail at the bound.
55async function within(c, name, predicate, label) {
56 const start = performance.now();
57 for (;;) {
58 if (predicate(await c.call('note-links', { name }))) return performance.now() - start;
59 const elapsed = performance.now() - start;
60 assert.ok(elapsed < BOUND_MS, `${label}: not visible after ${Math.round(elapsed)} ms`);
61 await pause(200);
62 }
65const linked = '# Source\n[[target]]\n';
66const unlinked = '# Source\n[[zzzzzz]]\n';
68test('outside edits reach backlinks within one census tick of an idle server', { timeout: 120000 }, async t => {
69 const root = await mkdtemp(join(tmpdir(), 'folio-census-bound-'));
70 t.after(() => rm(root, { recursive: true, force: true }));
71 await mkdir(join(root, 'notes')); await mkdir(join(root, 'projects'));
72 await writeFile(join(root, 'notes/target.md'), '# Target\n');
73 const source = join(root, 'notes/source.md');
74 await writeFile(source, unlinked);
75 const git = (...args) => execFileSync('git', ['-C', root, '-c', 'user.name=t', '-c', 'user.email=t@t', ...args]);
76 git('init', '-q'); git('add', '-A'); git('commit', '-qm', 'unlinked');
78 // Let the files settle (the 2 s racily-clean window) so the startup census
79 // arms the fast path and the first 15 s tick can skip.
80 await pause(2500);
81 const c = client(root);
82 t.after(() => c.close());
83 assert.doesNotMatch(await c.call('note-links', { name: 'target' }), /← source/);
84 // Let at least one idle tick pass, so the fast path is what sees the edits.
85 await pause(16000);
86 assert.match(c.diagnostic(), /Census tick.*unchanged/, 'the fast path must be armed before the edits');
87 const has = s => /← source \(note\)/.test(s);
88 const results = {};
90 // 1. Same size, same second, mtime restored: only ctime moves.
91 const before = await stat(source);
92 await writeFile(source, linked);
93 await utimes(source, before.atime, before.mtime);
94 results.restoredMtime = await within(c, 'target', has, 'same-size edit with restored mtime');
96 // 2. git checkout of the equal-size committed revision (a replacement).
97 await pause(1000);
98 git('checkout', '-q', '--', 'notes/source.md');
99 results.gitCheckout = await within(c, 'target', s => !has(s), 'git checkout');
101 // 3. Rename over the file with a same-size, same-mtime copy (new inode).
102 await pause(1000);
103 const staged = join(root, 'staged.md');
104 const now = await stat(source);
105 await writeFile(staged, linked);
106 await utimes(staged, now.atime, now.mtime);
107 await rename(staged, source);
108 results.renameOver = await within(c, 'target', has, 'rename-over replacement');
110 // 4. A new linking note in a new directory.
111 await mkdir(join(root, 'notes/deep'));
112 await writeFile(join(root, 'notes/deep/added.md'), '# Added\n[[target]]\n');
113 results.added = await within(c, 'target', s => /← deep\/added \(note\)/.test(s), 'added note');
115 console.log(JSON.stringify({ step: 'census-bound', boundMs: BOUND_MS,
116 visibleMs: Object.fromEntries(Object.entries(results).map(([k, v]) => [k, Math.round(v)])) }));
117});