AtlatestRepositoryfolio
1
// The census fast path must not delay outside edits: once a server is idle2
// (the fast path armed), an outside change must reach backlinks within one3
// 15 s tick plus a 2 s margin. Each edit below keeps the file's size, and two4
// of them also keep its mtime, so only ctime, inode or a directory time moves.5
import assert from 'node:assert/strict';6
import { spawn, execFileSync } from 'node:child_process';7
import { mkdtemp, mkdir, writeFile, rm, stat, utimes, rename } from 'node:fs/promises';8
import { tmpdir } from 'node:os';9
import { join, resolve } from 'node:path';10
import { once } from 'node:events';11
import test from 'node:test';13
const binary = resolve(process.env.FOLIO_TEST_BINARY || 'build/dev/bin/folio');14
const pause = ms => new Promise(r => setTimeout(r, ms));15
const BOUND_MS = 17000;17
function 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
};52
}54
// Poll note-links until `predicate` holds; return the elapsed ms or fail at the bound.55
async 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
}63
}65
const linked = '# Source\n[[target]]\n';66
const unlinked = '# Source\n[[zzzzzz]]\n';68
test('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 census79
// 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
});