diff --git a/scripts/tests/test-qmd-search.mjs b/scripts/tests/test-qmd-search.mjs index 63466ea..03d234a 100644 --- a/scripts/tests/test-qmd-search.mjs +++ b/scripts/tests/test-qmd-search.mjs @@ -1,6 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { runSearch, toCliResults } from '../../shared/qmd/qmd-search.mjs'; +import { EventEmitter } from 'node:events'; +import { runSearch, startDaemon, toCliResults } from '../../shared/qmd/qmd-search.mjs'; import { collectionName } from '../../shared/qmd/qmd-lib.mjs'; const hit = [{ path: 'src/a.ts', line: 3, score: 0.9, snippet: 's' }]; @@ -15,7 +16,7 @@ test('uses the daemon when it answers', async () => { test('starts the daemon once when the first call fails, then retries', async () => { let calls = 0; let started = 0; const results = await runSearch({ root: '/r', query: 'q', topK: 5, - daemon: async () => { calls += 1; if (calls === 1) throw new Error('ECONNREFUSED'); return { results: hit }; }, + daemon: async () => { calls += 1; if (calls === 1) throw Object.assign(new Error('ECONNREFUSED'), { notRunning: true }); return { results: hit }; }, startDaemon: async () => { started += 1; }, cli: async () => assert.fail('must not fall back') }); assert.deepEqual(results, hit); assert.equal(started, 1); @@ -23,7 +24,7 @@ test('starts the daemon once when the first call fails, then retries', async () test('falls back to the CLI when the daemon cannot start', async () => { const results = await runSearch({ root: '/r', query: 'q', topK: 2, - daemon: async () => { throw new Error('stale'); }, startDaemon: async () => { throw new Error('spawn failed'); }, + daemon: async () => { throw Object.assign(new Error('stale'), { notRunning: true }); }, startDaemon: async () => { throw new Error('spawn failed'); }, cli: async (args) => { assert.ok(args.includes('--json')); return [{ file: `qmd://${collectionName('/r')}/src/b.ts`, line: 7, score: 0.5, snippet: 'b' }]; } }); assert.deepEqual(results, [{ path: 'src/b.ts', line: 7, score: 0.5, snippet: 'b' }]); }); @@ -32,3 +33,46 @@ test('CLI results keep foreign collections as qmd URIs', () => { assert.deepEqual(toCliResults([{ file: 'qmd://notes/x.md', line: 1, score: 0.4, snippet: 'n' }], 'repo-x'), [{ path: 'qmd://notes/x.md', line: 1, score: 0.4, snippet: 'n' }]); }); + +const cliHit = (root) => async () => [{ file: `qmd://${collectionName(root)}/src/b.ts`, line: 7, score: 0.5, snippet: 'b' }]; +const cliExpected = [{ path: 'src/b.ts', line: 7, score: 0.5, snippet: 'b' }]; + +test('a live daemon that answers with an error does not trigger a second daemon', async () => { + const results = await runSearch({ root: '/r', query: 'q', topK: 2, + daemon: async () => { throw new Error('daemon answered 500'); }, + startDaemon: async () => assert.fail('must not start'), cli: cliHit('/r') }); + assert.deepEqual(results, cliExpected); +}); + +test('a daemon reply without results falls back to the CLI', async () => { + const results = await runSearch({ root: '/r', query: 'q', topK: 2, + daemon: async () => ({}), startDaemon: async () => assert.fail('must not start'), cli: cliHit('/r') }); + assert.deepEqual(results, cliExpected); +}); + +test('a daemon failure after a successful start falls back to the CLI', async () => { + let calls = 0; + const results = await runSearch({ root: '/r', query: 'q', topK: 2, + daemon: async () => { calls += 1; throw calls === 1 ? Object.assign(new Error('down'), { notRunning: true }) : new Error('daemon answered 500'); }, + startDaemon: async () => {}, cli: cliHit('/r') }); + assert.deepEqual(results, cliExpected); + assert.equal(calls, 2); +}); + +test('startDaemon rejects quickly when the child exits immediately', async () => { + const started = Date.now(); + await assert.rejects(startDaemon({ spawnChild: () => { + const child = new EventEmitter(); child.unref = () => {}; child.pid = 1; + setImmediate(() => child.emit('exit', 1)); + return child; + } }), /exited with code 1/); + assert.ok(Date.now() - started < 5000); +}); + +test('startDaemon rejects when the child cannot be spawned', async () => { + await assert.rejects(startDaemon({ spawnChild: () => { + const child = new EventEmitter(); child.unref = () => {}; child.pid = 1; + setImmediate(() => child.emit('error', new Error('spawn failed'))); + return child; + } }), /spawn failed/); +}); diff --git a/shared/qmd/qmd-search.mjs b/shared/qmd/qmd-search.mjs index f50dde9..0c08edf 100644 --- a/shared/qmd/qmd-search.mjs +++ b/shared/qmd/qmd-search.mjs @@ -16,23 +16,36 @@ export function toCliResults(items, collection) { }); } +const notRunning = (message, cause) => Object.assign(new Error(message, { cause }), { notRunning: true }); + async function callDaemon(path, body) { const info = readJson(daemonFile(), null); - if (!info?.port || !info?.token) throw new Error('daemon not running'); - const response = await fetch(`http://127.0.0.1:${info.port}${path}`, { - method: 'POST', headers: { 'x-qmd-token': info.token, 'content-type': 'application/json' }, - body: JSON.stringify(body), signal: AbortSignal.timeout(600_000) }); + if (!info?.port || !info?.token) throw notRunning('daemon not running'); + let response; + try { + response = await fetch(`http://127.0.0.1:${info.port}${path}`, { + method: 'POST', headers: { 'x-qmd-token': info.token, 'content-type': 'application/json' }, + body: JSON.stringify(body), signal: AbortSignal.timeout(600_000) }); + } catch (error) { + // fetch rejects with a TypeError when the connection is refused or reset; a timeout is a different error. + if (error instanceof TypeError) throw notRunning('daemon not reachable', error); + throw error; + } if (!response.ok) throw new Error(`daemon answered ${response.status}`); return response.json(); } -export async function startDaemon() { +export async function startDaemon({ spawnChild = spawn } = {}) { const device = readJson(setupStatePath(), {}).device || 'cpu'; - const child = spawn(process.execPath, [join(here, 'qmd-daemon.mjs')], { detached: true, stdio: 'ignore', windowsHide: true, env: deviceEnv(device) }); + const child = spawnChild(process.execPath, [join(here, 'qmd-daemon.mjs')], { detached: true, stdio: 'ignore', windowsHide: true, env: deviceEnv(device) }); + let failure = null; + child.once('error', (error) => { failure = error; }); + child.once('exit', (code) => { failure ||= new Error(`daemon exited with code ${code}`); }); child.unref(); const deadline = Date.now() + 120_000; while (Date.now() < deadline) { await new Promise((done) => setTimeout(done, 500)); + if (failure) throw failure; const info = readJson(daemonFile(), null); if (info?.port && info?.token && info.pid === child.pid) return; } @@ -46,16 +59,22 @@ function runCli(args) { export async function runSearch({ root, query, topK, daemon = callDaemon, startDaemon: start = startDaemon, cli = runCli }) { const body = { repo: root, query, limit: topK }; + const ask = async () => { + const reply = await daemon('/search', body); + if (!Array.isArray(reply?.results)) throw new Error('daemon reply has no results'); + return reply.results; + }; try { - return (await daemon('/search', body)).results; - } catch { try { + return await ask(); + } catch (error) { + if (!error.notRunning) throw error; await start(); - return (await daemon('/search', body)).results; - } catch { - const collection = collectionName(root); - return toCliResults(await cli(['query', query, '--json', '-n', String(topK), '-c', collection, '--chunk-strategy', 'auto']), collection); + return await ask(); } + } catch { + const collection = collectionName(root); + return toCliResults(await cli(['query', query, '--json', '-n', String(topK), '-c', collection, '--chunk-strategy', 'auto']), collection); } }