GitNexus/gitnexus/test/integration/lbug-pool-stability.test.ts
Hazem 60ede20a11
fix: MCP server crashes under parallel tool calls (#326) (#349)
* 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.
2026-03-18 13:02:01 +00:00

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,
});