diff --git a/scripts/tests/test-qmd-daemon.mjs b/scripts/tests/test-qmd-daemon.mjs index e2f8df1..7f2cb80 100644 --- a/scripts/tests/test-qmd-daemon.mjs +++ b/scripts/tests/test-qmd-daemon.mjs @@ -2,10 +2,10 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; -import { mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; -import { createServer, gitFingerprint, ignoreGlobs, keepModelsLoaded } from '../../shared/qmd/qmd-daemon.mjs'; +import { acquireStartLock, createServer, gitFingerprint, ignoreGlobs, keepModelsLoaded } from '../../shared/qmd/qmd-daemon.mjs'; import { collectionName } from '../../shared/qmd/qmd-lib.mjs'; const defaultHits = () => [{ displayPath: `${collectionName('/r')}/src/a.ts`, body: 'x\nretry here', bestChunkPos: 2, bestChunk: 'retry here', score: 0.91 }]; @@ -201,3 +201,17 @@ test('git ignored paths reach addCollection and are persisted only when they cha assert.deepEqual(persisted[1], [collectionName('/r'), ['dist/**', 'tmp/**']]); assert.equal(s.store.calls.filter((c) => c[0] === 'update').length, 3); }); + +test('the start lock admits one launcher and is taken over after 2 minutes', (t) => { + const dir = mkdtempSync(join(tmpdir(), 'qmd-lock-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const lock = join(dir, 'daemon.lock'); + const release = acquireStartLock(lock); + assert.equal(typeof release, 'function'); + assert.equal(acquireStartLock(lock), null, 'a concurrent launch loses'); + release(); + assert.equal(existsSync(lock), false); + assert.equal(typeof acquireStartLock(lock), 'function'); + assert.equal(acquireStartLock(lock, () => Date.now() + 60_000), null, 'a fresh lock holds'); + assert.equal(typeof acquireStartLock(lock, () => Date.now() + 121_000), 'function', 'a stale lock is taken over'); +}); diff --git a/shared/qmd/qmd-daemon.mjs b/shared/qmd/qmd-daemon.mjs index fd13a2b..3e70489 100644 --- a/shared/qmd/qmd-daemon.mjs +++ b/shared/qmd/qmd-daemon.mjs @@ -1,9 +1,9 @@ // shared/qmd/qmd-daemon.mjs — keeps QMD's models loaded for fast searches; exits when idle. import { execFileSync } from 'node:child_process'; import { randomBytes, timingSafeEqual } from 'node:crypto'; -import { existsSync, readFileSync, statSync } from 'node:fs'; +import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, statSync } from 'node:fs'; import { createServer as createHttpServer } from 'node:http'; -import { join, resolve } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import { setCollectionIgnore } from './qmd-config.mjs'; import { MASK, collectionName, idleMinutes, importQmd, qmdConfigPath, qmdDbPath, readJson, runQmdCli, stateDir, writePrivateJson, writeTextAtomic } from './qmd-lib.mjs'; @@ -42,6 +42,29 @@ export function gitIgnored(repo, escape) { } } +// Exclusive start lock: returns a release function, or null while another launch holds a lock younger than 2 minutes. +// ponytail: two launchers that find the same stale lock can both take it over; the daemon.json health check covers the rest. +export function acquireStartLock(path, now = Date.now) { + mkdirSync(dirname(path), { recursive: true }); + for (let attempt = 0; attempt < 2; attempt++) { + try { + closeSync(openSync(path, 'wx')); + let held = true; + return () => { + if (held) rmSync(path, { force: true }); + held = false; + }; + } catch (error) { + if (error.code !== 'EEXIST') throw error; + } + try { + if (now() - statSync(path).mtimeMs < 120_000) return null; + rmSync(path, { force: true }); + } catch { /* the holder released it meanwhile: try again */ } + } + return null; +} + // QMD 2.8.3 createStore unloads models after 5 idle minutes (store.internal.llm); the daemon's own idle exit replaces that. export function keepModelsLoaded(store) { const llm = store.internal?.llm; @@ -165,11 +188,15 @@ export function createServer({ store, token, idleMs, now = Date.now, onIdle, ext async function main() { const dir = stateDir(); const daemonFile = join(dir, 'daemon.json'); + // The lock comes before the health check, so a launch that waited for another one sees its daemon.json. + const releaseLock = acquireStartLock(join(dir, 'daemon.lock')); + if (!releaseLock) return; // another launch is starting the daemon + process.on('exit', releaseLock); const current = readJson(daemonFile, null); if (current) { try { const health = await fetch(`http://127.0.0.1:${current.port}/health`, { headers: { 'x-qmd-token': current.token }, signal: AbortSignal.timeout(2000) }); - if (health.ok && (await health.json()).service === 'ai-config-qmd') return; + if (health.ok && (await health.json()).service === 'ai-config-qmd') return releaseLock(); } catch { /* stale file: start a new daemon */ } } const { createStore, extractSnippet } = await importQmd(); @@ -214,6 +241,7 @@ async function main() { }); daemon.server.listen(0, '127.0.0.1', () => { writePrivateJson(daemonFile, { port: daemon.server.address().port, token, pid: process.pid }); + releaseLock(); }); setInterval(daemon.checkIdle, 60_000).unref(); process.on('SIGTERM', stop);