fix(qmd): start the search daemon only when none runs and fail fast on child exit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a40ff270d8
commit
08f015de92
2 files changed
+74
-11
No files matched your search
@@ -1,6 +1,7 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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';
|
import { collectionName } from '../../shared/qmd/qmd-lib.mjs';
|
||||||
|
|
||||||
const hit = [{ path: 'src/a.ts', line: 3, score: 0.9, snippet: 's' }];
|
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 () => {
|
test('starts the daemon once when the first call fails, then retries', async () => {
|
||||||
let calls = 0; let started = 0;
|
let calls = 0; let started = 0;
|
||||||
const results = await runSearch({ root: '/r', query: 'q', topK: 5,
|
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') });
|
startDaemon: async () => { started += 1; }, cli: async () => assert.fail('must not fall back') });
|
||||||
assert.deepEqual(results, hit);
|
assert.deepEqual(results, hit);
|
||||||
assert.equal(started, 1);
|
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 () => {
|
test('falls back to the CLI when the daemon cannot start', async () => {
|
||||||
const results = await runSearch({ root: '/r', query: 'q', topK: 2,
|
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' }]; } });
|
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' }]);
|
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'),
|
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' }]);
|
[{ 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/);
|
||||||
|
});
|
||||||
@@ -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) {
|
async function callDaemon(path, body) {
|
||||||
const info = readJson(daemonFile(), null);
|
const info = readJson(daemonFile(), null);
|
||||||
if (!info?.port || !info?.token) throw new Error('daemon not running');
|
if (!info?.port || !info?.token) throw notRunning('daemon not running');
|
||||||
const response = await fetch(`http://127.0.0.1:${info.port}${path}`, {
|
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' },
|
method: 'POST', headers: { 'x-qmd-token': info.token, 'content-type': 'application/json' },
|
||||||
body: JSON.stringify(body), signal: AbortSignal.timeout(600_000) });
|
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}`);
|
if (!response.ok) throw new Error(`daemon answered ${response.status}`);
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startDaemon() {
|
export async function startDaemon({ spawnChild = spawn } = {}) {
|
||||||
const device = readJson(setupStatePath(), {}).device || 'cpu';
|
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();
|
child.unref();
|
||||||
const deadline = Date.now() + 120_000;
|
const deadline = Date.now() + 120_000;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
await new Promise((done) => setTimeout(done, 500));
|
await new Promise((done) => setTimeout(done, 500));
|
||||||
|
if (failure) throw failure;
|
||||||
const info = readJson(daemonFile(), null);
|
const info = readJson(daemonFile(), null);
|
||||||
if (info?.port && info?.token && info.pid === child.pid) return;
|
if (info?.port && info?.token && info.pid === child.pid) return;
|
||||||
}
|
}
|
||||||
@@ -46,17 +59,23 @@ function runCli(args) {
|
|||||||
|
|
||||||
export async function runSearch({ root, query, topK, daemon = callDaemon, startDaemon: start = startDaemon, cli = runCli }) {
|
export async function runSearch({ root, query, topK, daemon = callDaemon, startDaemon: start = startDaemon, cli = runCli }) {
|
||||||
const body = { repo: root, query, limit: topK };
|
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 {
|
try {
|
||||||
return (await daemon('/search', body)).results;
|
|
||||||
} catch {
|
|
||||||
try {
|
try {
|
||||||
|
return await ask();
|
||||||
|
} catch (error) {
|
||||||
|
if (!error.notRunning) throw error;
|
||||||
await start();
|
await start();
|
||||||
return (await daemon('/search', body)).results;
|
return await ask();
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
const collection = collectionName(root);
|
const collection = collectionName(root);
|
||||||
return toCliResults(await cli(['query', query, '--json', '-n', String(topK), '-c', collection, '--chunk-strategy', 'auto']), collection);
|
return toCliResults(await cli(['query', query, '--json', '-n', String(topK), '-c', collection, '--chunk-strategy', 'auto']), collection);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
|||||||
Reference in new issue
Block a user