mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-06 02:49:56 +00:00
* fix: MCP server crashes under parallel tool calls (#326) * fix: ensure full connection pool is pre-created to avoid race conditions during query execution * fix: improve graceful shutdown handling with exit codes * fix: resolve critical concurrency bugs in connection pool init - Add initPromises dedup map to prevent double-init race when parallel tool calls trigger initLbug for the same repoId simultaneously - Move pool.set() after FTS load so concurrent checkout can't grab a connection mid-async-init (FTS race on available[0]) - Replace lazy createConnection growth path with integrity error — pool is pre-warmed, lazy creation would silence stdout during active queries - Add preWarmActive flag so watchdog timer skips stdout restore during the synchronous pre-warm loop - Unify stdout capture: server.ts imports realStdoutWrite from lbug-adapter instead of capturing its own copy * test: add connection pool parallel stability tests 7 integration tests covering concurrent query safety, waiter queue overflow, stdout.write restoration, connection leak detection, initLbug deduplication, atomic pool visibility, and mixed query types. * fix: run LadybugDB tests sequentially via vitest projects config Vitest's projects feature splits test files into two groups: lbug-db (fileParallelism: false) and default (parallel). This prevents native mmap file-lock conflicts on Windows without requiring the CI shell loop locally. * test: add enrichment Promise.all regression test for #292/#316 Verifies that 3 concurrent queries via Promise.all (the exact pattern from the impact command's enrichment phase at local-backend.ts:1415) complete without SIGSEGV on a pre-warmed connection pool.
160 lines
6.7 KiB
TypeScript
160 lines
6.7 KiB
TypeScript
/**
|
|
* Integration Tests: LadybugDB Connection Pool — Parallel Stability
|
|
*
|
|
* Tests concurrency fixes from PR #349:
|
|
* - Pre-warmed pool handles max parallel queries
|
|
* - Waiter queue drains under overload
|
|
* - Concurrent initLbug deduplication
|
|
* - stdout.write restoration after parallel operations
|
|
* - No connection leaks over sequential workloads
|
|
* - Atomic pool entry visibility (pool.set last)
|
|
* - Mixed query types at full concurrency
|
|
*
|
|
* Connection budget: LadybugDB's native mmap budget caps at ~56
|
|
* simultaneous Connection objects per process. Tests that only need
|
|
* a ready pool share a single 'shared-repo' init (8 connections) to
|
|
* stay well under the limit.
|
|
*/
|
|
import { it, expect, afterAll } from 'vitest';
|
|
import {
|
|
initLbug,
|
|
executeQuery,
|
|
executeParameterized,
|
|
closeLbug,
|
|
isLbugReady,
|
|
} from '../../src/mcp/core/lbug-adapter.js';
|
|
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
|
|
|
|
const SEED_DATA = [
|
|
`CREATE (f:File {id: 'file:index.ts', name: 'index.ts', filePath: 'src/index.ts', content: ''})`,
|
|
`CREATE (fn:Function {id: 'func:main', name: 'main', filePath: 'src/index.ts', startLine: 1, endLine: 10, isExported: true, content: '', description: ''})`,
|
|
`CREATE (fn2:Function {id: 'func:helper', name: 'helper', filePath: 'src/utils.ts', startLine: 1, endLine: 5, isExported: true, content: '', description: ''})`,
|
|
`MATCH (a:Function), (b:Function)
|
|
WHERE a.id = 'func:main' AND b.id = 'func:helper'
|
|
CREATE (a)-[:CodeRelation {type: 'CALLS', confidence: 1.0, reason: 'direct', step: 0}]->(b)`,
|
|
];
|
|
|
|
// ─── Shared-pool tests: reuse one init across 5 tests (8 connections total) ──
|
|
|
|
withTestLbugDB('pool-stability', (handle) => {
|
|
const REPO = 'shared-par';
|
|
let inited = false;
|
|
|
|
const ensurePool = async () => {
|
|
if (!inited) {
|
|
await initLbug(REPO, handle.dbPath);
|
|
inited = true;
|
|
}
|
|
};
|
|
|
|
afterAll(async () => {
|
|
try { await closeLbug(REPO); } catch { /* best-effort */ }
|
|
});
|
|
|
|
it('8 simultaneous queries complete without crashes', async () => {
|
|
await ensurePool();
|
|
const queries = Array.from({ length: 8 }, () =>
|
|
executeQuery(REPO, 'MATCH (n:Function) RETURN n.name AS name')
|
|
);
|
|
const results = await Promise.all(queries);
|
|
expect(results).toHaveLength(8);
|
|
for (const rows of results) {
|
|
expect(rows.length).toBeGreaterThanOrEqual(2);
|
|
}
|
|
});
|
|
|
|
it('12 parallel queries overflow into waiter queue and all complete', async () => {
|
|
await ensurePool();
|
|
const queries = Array.from({ length: 12 }, () =>
|
|
executeQuery(REPO, 'MATCH (n:Function) RETURN n.name AS name')
|
|
);
|
|
const results = await Promise.all(queries);
|
|
expect(results).toHaveLength(12);
|
|
for (const rows of results) {
|
|
expect(rows.length).toBeGreaterThanOrEqual(2);
|
|
}
|
|
});
|
|
|
|
it('process.stdout.write is functional after init and parallel queries', async () => {
|
|
await ensurePool();
|
|
const queries = Array.from({ length: 8 }, () =>
|
|
executeQuery(REPO, 'MATCH (n:Function) RETURN n.name AS name')
|
|
);
|
|
await Promise.all(queries);
|
|
// stdout.write should be the real function, not the silenced no-op
|
|
expect(typeof process.stdout.write).toBe('function');
|
|
});
|
|
|
|
it('50 sequential queries do not leak connections', async () => {
|
|
await ensurePool();
|
|
for (let i = 0; i < 50; i++) {
|
|
await executeQuery(REPO, 'MATCH (n:Function) RETURN n.name AS name');
|
|
}
|
|
// If connections leaked, these 8 parallel queries would exhaust the pool
|
|
const queries = Array.from({ length: 8 }, () =>
|
|
executeQuery(REPO, 'MATCH (n:Function) RETURN n.name AS name')
|
|
);
|
|
const results = await Promise.all(queries);
|
|
expect(results).toHaveLength(8);
|
|
});
|
|
|
|
it('mixed executeQuery + executeParameterized at full concurrency', async () => {
|
|
await ensurePool();
|
|
const queries = [
|
|
executeQuery(REPO, 'MATCH (n:Function) RETURN n.name AS name'),
|
|
executeParameterized(REPO, 'MATCH (n:Function) WHERE n.name = $name RETURN n.name AS name', { name: 'main' }),
|
|
executeQuery(REPO, 'MATCH (n:Function) RETURN n.name AS name'),
|
|
executeParameterized(REPO, 'MATCH (n:Function) WHERE n.name = $name RETURN n.name AS name', { name: 'helper' }),
|
|
executeQuery(REPO, 'MATCH (n:Function) RETURN n.name AS name'),
|
|
executeParameterized(REPO, 'MATCH (n:Function) WHERE n.name = $name RETURN n.name AS name', { name: 'main' }),
|
|
executeQuery(REPO, 'MATCH (n:Function) RETURN n.name AS name'),
|
|
executeQuery(REPO, 'MATCH (n:Function) RETURN n.name AS name'),
|
|
];
|
|
const results = await Promise.all(queries);
|
|
expect(results).toHaveLength(8);
|
|
});
|
|
|
|
// ─── Regression tests for #308 / #314 / #347 ──────────────────────────
|
|
// The impact command's enrichment phase runs 3 concurrent queries via
|
|
// Promise.all (local-backend.ts:1415). Before PR #349's pool pre-warming,
|
|
// this triggered lazy createConnection → silenceStdout → SIGSEGV.
|
|
|
|
it('3 concurrent queries via Promise.all (impact enrichment pattern, #308/#314/#347)', async () => {
|
|
await ensurePool();
|
|
// Mirrors the exact Promise.all pattern from local-backend.ts:1415
|
|
// that caused SIGSEGV in issues #308, #314, #347 before pool pre-warming.
|
|
const [r1, r2, r3] = await Promise.all([
|
|
executeQuery(REPO, 'MATCH (n:Function) RETURN n.name AS name LIMIT 5').catch(() => []),
|
|
executeQuery(REPO, 'MATCH (n:Function) RETURN n.id AS id LIMIT 5').catch(() => []),
|
|
executeQuery(REPO, 'MATCH ()-[r:CodeRelation]->() RETURN r.type AS type LIMIT 5').catch(() => []),
|
|
]);
|
|
expect(r1.length).toBeGreaterThanOrEqual(1);
|
|
expect(r2.length).toBeGreaterThanOrEqual(1);
|
|
expect(r3.length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
// ─── Fresh-state tests: need their own init ──────────────────────────
|
|
|
|
it('concurrent initLbug calls for the same repoId deduplicate', async () => {
|
|
const promises = Array.from({ length: 6 }, () =>
|
|
initLbug('dedup-repo', handle.dbPath)
|
|
);
|
|
await Promise.all(promises);
|
|
expect(isLbugReady('dedup-repo')).toBe(true);
|
|
const rows = await executeQuery('dedup-repo', 'MATCH (n:Function) RETURN n.name AS name');
|
|
expect(rows.length).toBeGreaterThanOrEqual(2);
|
|
await closeLbug('dedup-repo');
|
|
});
|
|
|
|
it('pool entry is not visible until initLbug fully resolves', async () => {
|
|
const initPromise = initLbug('atomic-repo', handle.dbPath);
|
|
// Synchronously, pool should NOT be ready (pool.set is the last operation)
|
|
expect(isLbugReady('atomic-repo')).toBe(false);
|
|
await initPromise;
|
|
expect(isLbugReady('atomic-repo')).toBe(true);
|
|
await closeLbug('atomic-repo');
|
|
});
|
|
}, {
|
|
seed: SEED_DATA,
|
|
poolAdapter: true,
|
|
});
|