diff --git a/README.md b/README.md index 0a888df06..4b12d421e 100644 --- a/README.md +++ b/README.md @@ -727,7 +727,7 @@ gitnexus wiki --force # Increase the timeout or retries for large codebase or slow LLM providers -gitnexus wiki --timeout # Per-attempt LLM request timeout in seconds (default: 60) +gitnexus wiki --timeout # LLM request timeout in seconds (default: disabled) gitnexus wiki --retries # Max LLM retry attempts per request (default: 3) ``` diff --git a/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md b/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md index 11945b8cc..f21eaa415 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md +++ b/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md @@ -62,7 +62,7 @@ Generates repository documentation from the knowledge graph using an LLM. Requir | `--api-key ` | LLM API key | | `--concurrency ` | Parallel LLM calls (default: 3) | | `--gist` | Publish wiki as a public GitHub Gist | -| `--timeout ` | Per-attempt LLM request timeout in seconds (default: 60) | +| `--timeout ` | LLM request timeout in seconds (default: disabled) | | `--retries ` | Max LLM retry attempts per request (default: 3) | ### list — Show all indexed repos diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index a20503bc4..ec3636afc 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -13,6 +13,7 @@ import { execFileSync } from 'child_process'; import v8 from 'v8'; import cliProgress from 'cli-progress'; import { closeLbug } from '../core/lbug/lbug-adapter.js'; +import { isWalCorruptionError, WAL_RECOVERY_SUGGESTION } from '../core/lbug/lbug-config.js'; import { getStoragePaths, getGlobalRegistryPath, @@ -638,6 +639,20 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption return; } + // WAL corruption — the index file is unreadable. Give a clear recovery + // path without a confusing stack trace (the native error message alone + // is enough signal). + if (isWalCorruptionError(err) || msg.includes('LadybugDB WAL corruption')) { + cliError( + ` The GitNexus index has a corrupted WAL file.\n` + + ` This usually happens when a previous analysis was interrupted mid-write.\n` + + ` ${WAL_RECOVERY_SUGGESTION}\n`, + { recoveryHint: 'wal-corruption' }, + ); + process.exitCode = 1; + return; + } + // HF download failure — show clean guidance without the raw stack trace. // Checked before writeFatalToStderr so the user sees one focused message // rather than a stack-trace dump followed by a second remediation block. diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 2cadcef0e..c3d3ddcb0 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -168,7 +168,7 @@ program ) .option('--no-reasoning-model', 'Disable reasoning model mode (overrides saved config)') .option('--concurrency ', 'Parallel LLM calls (default: 3)', '3') - .option('--timeout ', 'Per-attempt LLM request timeout in seconds (default: 60)') + .option('--timeout ', 'LLM request timeout in seconds (default: disabled)') .option('--retries ', 'Max LLM retry attempts per request (default: 3)') .option('--gist', 'Publish wiki as a public GitHub Gist after generation') .option('-v, --verbose', 'Enable verbose output (show LLM commands and responses)') diff --git a/gitnexus/src/cli/serve.ts b/gitnexus/src/cli/serve.ts index 9356b5bab..003e2ce69 100644 --- a/gitnexus/src/cli/serve.ts +++ b/gitnexus/src/cli/serve.ts @@ -1,6 +1,7 @@ import { createServer } from '../server/api.js'; import { logger, flushLoggerSync } from '../core/logger.js'; import { cliError } from './cli-message.js'; +import { isWalCorruptionError, WAL_RECOVERY_SUGGESTION } from '../core/lbug/lbug-config.js'; // Catch anything that would cause a silent exit. Pino v10's default // destination is `sync: false` (SonicBoom buffered) — call @@ -34,7 +35,13 @@ export const serveCommand = async (options?: { port?: string; host?: string }) = try { await createServer(port, host); } catch (err: any) { - if (err.code === 'EADDRINUSE') { + if (isWalCorruptionError(err)) { + cliError( + `\nGitNexus server could not start: the index has a corrupted WAL file.\n` + + ` ${WAL_RECOVERY_SUGGESTION}\n`, + { recoveryHint: 'wal-corruption' }, + ); + } else if (err.code === 'EADDRINUSE') { cliError( `\nFailed to start GitNexus server:\n` + ` ${err.message || err}\n\n` + diff --git a/gitnexus/src/cli/wiki.ts b/gitnexus/src/cli/wiki.ts index 8d9da9572..6fe32f4c6 100644 --- a/gitnexus/src/cli/wiki.ts +++ b/gitnexus/src/cli/wiki.ts @@ -37,6 +37,23 @@ export interface WikiCommandOptions { retries?: string; } +function parsePositiveIntegerOption( + value: string | undefined, + flag: string, + multiplier = 1, +): number | undefined { + if (value === undefined) return undefined; + const trimmed = value.trim(); + if (!/^[1-9]\d*$/.test(trimmed)) { + throw new Error(`${flag} must be a positive integer`); + } + const parsed = parseInt(trimmed, 10); + if (parsed > Math.floor(Number.MAX_SAFE_INTEGER / multiplier)) { + throw new Error(`${flag} is too large`); + } + return parsed; +} + /** * Prompt the user for input via stdin. */ @@ -127,6 +144,17 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio return; } + let timeoutSeconds: number | undefined; + let retries: number | undefined; + try { + timeoutSeconds = parsePositiveIntegerOption(options?.timeout, '--timeout', 1000); + retries = parsePositiveIntegerOption(options?.retries, '--retries'); + } catch (error) { + console.log(` Error: ${(error as Error).message}\n`); + process.exitCode = 1; + return; + } + // ── Resolve LLM config (with interactive fallback) ───────────────── // Save any CLI overrides immediately if ( @@ -350,13 +378,11 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio } // ── Apply per-run overrides not saved to config ──────────────────── - if (options?.timeout) { - const secs = parseInt(options.timeout, 10); - if (!isNaN(secs) && secs > 0) llmConfig.requestTimeoutMs = secs * 1000; + if (timeoutSeconds !== undefined) { + llmConfig.requestTimeoutMs = timeoutSeconds * 1000; } - if (options?.retries) { - const n = parseInt(options.retries, 10); - if (!isNaN(n) && n > 0) llmConfig.maxAttempts = n; + if (retries !== undefined) { + llmConfig.maxAttempts = retries; } // ── Setup progress bar with elapsed timer ────────────────────────── @@ -563,6 +589,8 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio if (err.message?.includes('No source files')) { console.log(`\n ${err.message}\n`); + } else if (err.message?.includes('LLM request timed out after')) { + console.log(`\n Timeout: ${err.message}\n`); } else if (err.message?.includes('content filter')) { // Content filter block — actionable message console.log(`\n Content Filter: ${err.message}\n`); diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index 54d98b667..c01966d0c 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -21,7 +21,9 @@ import { closeLbugConnection, isDbBusyError, isOpenRetryExhausted, + isWalCorruptionError, openLbugConnection, + WAL_RECOVERY_SUGGESTION, waitForWindowsHandleRelease, type LbugConnectionHandle, } from './lbug-config.js'; @@ -594,6 +596,24 @@ const doInitLbug = async (dbPath: string) => { // anyway and any genuine cross-process lock contention surfaces // on the next operation via withLbugDb's retry. Logging it here // would just be noise in CI. + // + // WAL corruption: the first DDL write after DB open triggers WAL + // replay — if the WAL file was left in a corrupt state by an + // interrupted previous run, the native engine throws here. Rather + // than logging a WARN and continuing in a broken state, close the + // DB cleanly and surface an actionable error so the caller (serve, + // MCP, analyze) can exit with a clear recovery message. + if (isWalCorruptionError(err)) { + await safeClose(); + currentDbPath = null; + ftsLoaded = false; + vectorExtensionLoaded = false; + ensuredFTSIndexes.clear(); + throw new Error( + `LadybugDB WAL corruption detected at ${dbPath}. ${WAL_RECOVERY_SUGGESTION}\n` + + ` Original error: ${msg.slice(0, 200)}`, + ); + } if (!msg.includes('already exists') && !isDbBusyError(err)) { logger.warn(`⚠️ Schema creation warning: ${msg.slice(0, 120)}`); } diff --git a/gitnexus/src/core/lbug/lbug-config.ts b/gitnexus/src/core/lbug/lbug-config.ts index ceb445693..22f2d3b18 100644 --- a/gitnexus/src/core/lbug/lbug-config.ts +++ b/gitnexus/src/core/lbug/lbug-config.ts @@ -49,7 +49,7 @@ export const LBUG_MAX_DB_SIZE: number = (() => { const WAL_CORRUPTION_RE = /corrupt(ed)?\s+wal|invalid\s+wal\s+record|wal.*corrupt|checksum.*wal/i; export const WAL_RECOVERY_SUGGESTION = - 'WAL corruption detected. Run `gitnexus analyze` to rebuild the index.'; + 'WAL corruption detected. Run `gitnexus analyze --force` to rebuild the index.'; export function isWalCorruptionError(err: unknown): boolean { if (!err) return false; diff --git a/gitnexus/src/core/lbug/pool-adapter.ts b/gitnexus/src/core/lbug/pool-adapter.ts index f18d7fcc3..2b432cba3 100644 --- a/gitnexus/src/core/lbug/pool-adapter.ts +++ b/gitnexus/src/core/lbug/pool-adapter.ts @@ -18,7 +18,11 @@ import fs from 'fs/promises'; import lbug from '@ladybugdb/core'; import { loadFTSExtension } from './lbug-adapter.js'; -import { createLbugDatabase, isWalCorruptionError } from './lbug-config.js'; +import { + createLbugDatabase, + isWalCorruptionError, + WAL_RECOVERY_SUGGESTION, +} from './lbug-config.js'; /** Per-repo pool: one Database, many Connections */ interface PoolEntry { @@ -375,8 +379,7 @@ async function doInitLbug(repoId: string, dbPath: string): Promise { break; } catch (retryErr) { throw new Error( - `LadybugDB WAL corruption detected for ${repoId}. ` + - `Run \`gitnexus analyze\` to rebuild the index. ` + + `LadybugDB WAL corruption detected for ${repoId}. ${WAL_RECOVERY_SUGGESTION} ` + `(${retryErr instanceof Error ? retryErr.message : String(retryErr)})`, ); } diff --git a/gitnexus/src/core/wiki/llm-client.ts b/gitnexus/src/core/wiki/llm-client.ts index 40ef831bf..72948b6b0 100644 --- a/gitnexus/src/core/wiki/llm-client.ts +++ b/gitnexus/src/core/wiki/llm-client.ts @@ -23,7 +23,7 @@ export interface LLMConfig { apiVersion?: string; /** When true, strips sampling params and uses max_completion_tokens instead of max_tokens */ isReasoningModel?: boolean; - /** Per-attempt fetch timeout in ms (default: 60_000). */ + /** Per-attempt fetch timeout in ms. Omit to disable request timeouts. */ requestTimeoutMs?: number; /** Max fetch attempts before giving up (default: 3). */ maxAttempts?: number; @@ -81,6 +81,19 @@ export function estimateTokens(text: string): number { return Math.ceil(text.length / 4); } +function formatTimeoutDuration(timeoutMs: number): string { + if (timeoutMs >= 1000 && timeoutMs % 1000 === 0) { + return `${timeoutMs / 1000}s`; + } + return `${timeoutMs}ms`; +} + +function isTimeoutLikeError(err: unknown): boolean { + if (!(err instanceof Error)) return false; + if (err.name === 'TimeoutError' || err.name === 'AbortError') return true; + return /time(d)?\s*out|timeout/i.test(err.message); +} + /** * Validate that a base URL supplied for LLM API calls is a safe HTTP/HTTPS * endpoint (CWE-918 / CodeQL js/http-to-file-access). @@ -237,12 +250,13 @@ export async function callLLM( ...authHeaders, }, body: JSON.stringify(body), - // Per-attempt timeout. Without this each retry can hang - // indefinitely on a frozen TCP connection — the per-call - // signal is the only timeout `resilientFetch` honors; - // `capDelayMs` only bounds the *backoff* between attempts. - // Default 60s; raise via --timeout for slow models or large pages. - signal: AbortSignal.timeout(config.requestTimeoutMs ?? 60_000), + // Request timeout is opt-in for wiki generation. Large local + // model runs can legitimately take well over a minute, so the + // default runtime path must not impose a hidden 60s ceiling. + signal: + config.requestTimeoutMs !== undefined + ? AbortSignal.timeout(config.requestTimeoutMs) + : undefined, }, { breakerKey: `wiki-llm-${new URL(url).host}`, @@ -261,6 +275,12 @@ export async function callLLM( `LLM API error (${err.response.status} after retries): ${errorText.slice(0, 500)}`, ); } + if (config.requestTimeoutMs !== undefined && isTimeoutLikeError(err)) { + throw new Error( + `LLM request timed out after ${formatTimeoutDuration(config.requestTimeoutMs)}. ` + + 'Increase --timeout or omit it to disable the request timeout.', + ); + } throw err; } diff --git a/gitnexus/test/unit/analyze-wal-error.test.ts b/gitnexus/test/unit/analyze-wal-error.test.ts new file mode 100644 index 000000000..1b4ed5101 --- /dev/null +++ b/gitnexus/test/unit/analyze-wal-error.test.ts @@ -0,0 +1,137 @@ +/** + * Tests for WAL corruption error handling in the `analyzeCommand` CLI. + * + * Before this fix, a WAL corruption error surfaced as a raw stack-trace dump. + * After the fix, it is caught before the generic error path and rendered as + * a clean, actionable message telling the user to run `gitnexus analyze --force`. + * + * Mirrors the test shape of analyze-worker-timeout.test.ts: + * - vi.mock the heavy dependencies so no real DB / git is touched + * - drive `analyzeCommand` with a mocked `runFullAnalysis` that throws + * - assert on process.exitCode and the logged output + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const runFullAnalysisMock = vi.fn(); + +vi.mock('../../src/core/run-analyze.js', () => ({ + runFullAnalysis: runFullAnalysisMock, +})); + +vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({ + closeLbug: vi.fn(async () => undefined), +})); + +vi.mock('../../src/storage/repo-manager.js', () => ({ + getStoragePaths: vi.fn(() => ({ storagePath: '.gitnexus', lbugPath: '.gitnexus/lbug' })), + getGlobalRegistryPath: vi.fn(() => 'registry.json'), + RegistryNameCollisionError: class RegistryNameCollisionError extends Error {}, + AnalysisNotFinalizedError: class AnalysisNotFinalizedError extends Error {}, + assertAnalysisFinalized: vi.fn(async () => undefined), +})); + +vi.mock('../../src/storage/git.js', () => ({ + getGitRoot: vi.fn(() => '/repo'), + hasGitDir: vi.fn(() => true), +})); + +vi.mock('../../src/core/ingestion/utils/max-file-size.js', () => ({ + getMaxFileSizeBannerMessage: vi.fn(() => null), +})); + +// analyze.ts imports isHfDownloadFailure from hf-env.js, which in turn imports +// from gitnexus-shared (not linked in dev). Mock the module to break the chain. +vi.mock('../../src/core/embeddings/hf-env.js', () => ({ + isHfDownloadFailure: vi.fn(() => false), +})); + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe('analyzeCommand WAL corruption error handling', () => { + beforeEach(() => { + vi.resetModules(); + runFullAnalysisMock.mockReset(); + process.exitCode = undefined; + // Ensure ensureHeap() short-circuits (heap already at target size) + process.env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ''} --max-old-space-size=8192`.trim(); + }); + + it('surfaces a clean recovery message on a re-wrapped WAL corruption error', async () => { + // This error shape is what lbug-adapter throws after detecting WAL corruption + // in doInitLbug and re-wrapping it with the recovery suggestion. + const walError = new Error( + 'LadybugDB WAL corruption detected at /repo/.gitnexus/lbug. ' + + 'Run `gitnexus analyze` to rebuild the index.\n' + + ' Original error: Runtime exception: Corrupted wal file.', + ); + runFullAnalysisMock.mockRejectedValue(walError); + + const { _captureLogger } = await import('../../src/core/logger.js'); + const cap = _captureLogger(); + const { analyzeCommand } = await import('../../src/cli/analyze.js'); + + await analyzeCommand(undefined, {}); + + expect(process.exitCode).toBe(1); + + const records = cap.records(); + const walRecord = records.find( + (r) => typeof r.msg === 'string' && r.msg.includes('gitnexus analyze --force'), + ); + expect(walRecord).toBeDefined(); + + // Raw stack trace must NOT appear via cliError + const stackRecord = records.find( + (r) => typeof r.msg === 'string' && r.msg.includes('at analyzeCommand'), + ); + expect(stackRecord).toBeUndefined(); + + cap.restore(); + }); + + it('surfaces a clean recovery message when the native WAL error fires directly', async () => { + // isWalCorruptionError fires on the native engine message before re-wrapping. + const nativeWalError = new Error( + 'Runtime exception: Corrupted wal file. Read out invalid WAL record type.', + ); + runFullAnalysisMock.mockRejectedValue(nativeWalError); + + const { _captureLogger } = await import('../../src/core/logger.js'); + const cap = _captureLogger(); + const { analyzeCommand } = await import('../../src/cli/analyze.js'); + + await analyzeCommand(undefined, {}); + + expect(process.exitCode).toBe(1); + + const records = cap.records(); + const walRecord = records.find( + (r) => typeof r.msg === 'string' && r.msg.includes('gitnexus analyze --force'), + ); + expect(walRecord).toBeDefined(); + + cap.restore(); + }); + + it('does NOT route non-WAL errors through the WAL handler', async () => { + const genericError = new Error('Some unexpected failure unrelated to WAL'); + runFullAnalysisMock.mockRejectedValue(genericError); + + const { _captureLogger } = await import('../../src/core/logger.js'); + const cap = _captureLogger(); + const { analyzeCommand } = await import('../../src/cli/analyze.js'); + + await analyzeCommand(undefined, {}); + + expect(process.exitCode).toBe(1); + + // The WAL recovery message must NOT appear for unrelated errors + const records = cap.records(); + const walRecord = records.find( + (r) => typeof r.msg === 'string' && r.msg.includes('gitnexus analyze --force'), + ); + expect(walRecord).toBeUndefined(); + + cap.restore(); + }); +}); diff --git a/gitnexus/test/unit/lbug-adapter-wal-schema.test.ts b/gitnexus/test/unit/lbug-adapter-wal-schema.test.ts new file mode 100644 index 000000000..c5f4b6773 --- /dev/null +++ b/gitnexus/test/unit/lbug-adapter-wal-schema.test.ts @@ -0,0 +1,259 @@ +/** + * Tests for WAL corruption detection in the doInitLbug schema creation loop. + * + * Before this fix, a corrupt WAL that threw during schema DDL was silently + * logged as WARN. After the fix, `isWalCorruptionError` is checked first: + * the DB is closed cleanly and an Error with `WAL_RECOVERY_SUGGESTION` is + * thrown so the caller (serve / MCP / analyze) can exit with a clear message. + * + * Two test layers (same pattern as lbug-checkpoint-lifecycle.test.ts): + * 1. Structural — grep the adapter source to verify the guard is wired in. + * 2. Behavioural — vi.doMock + vi.resetModules to exercise the runtime path. + */ +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +const makeOpenMock = () => + vi.fn(async () => ({ + writeFile: vi.fn(async () => {}), + close: vi.fn(async () => {}), + })); + +const SCHEMA_MOCK = { + NODE_TABLES: ['File', 'Function', 'Class'], + REL_TABLE_NAME: 'CodeRelation', + EMBEDDING_TABLE_NAME: 'Embedding', + STALE_HASH_SENTINEL: '__stale__', + SCHEMA_QUERIES: ['CREATE NODE TABLE IF NOT EXISTS File (id STRING, PRIMARY KEY(id))'], +}; + +function makeFsMock(dbPath: string) { + const ENOENT = Object.assign(new Error(`ENOENT: ${dbPath}`), { code: 'ENOENT' }); + return { + default: { + lstat: vi.fn(async () => { + throw ENOENT; + }), + access: vi.fn(async () => { + throw ENOENT; + }), + unlink: vi.fn(async () => {}), + mkdir: vi.fn(async () => {}), + open: makeOpenMock(), + }, + }; +} + +// ─── Structural tests ───────────────────────────────────────────────────────── + +describe('doInitLbug WAL corruption guard — structural', () => { + let adapterSource: string; + let schemaLoopBody: string; + + beforeAll(async () => { + adapterSource = await fs.readFile( + path.join(__dirname, '..', '..', 'src', 'core', 'lbug', 'lbug-adapter.ts'), + 'utf-8', + ); + // 3000-char window from the SCHEMA_QUERIES loop comfortably covers the + // full catch block including the throw with WAL_RECOVERY_SUGGESTION. + const loopIdx = adapterSource.indexOf('for (const schemaQuery of SCHEMA_QUERIES)'); + schemaLoopBody = adapterSource.slice(loopIdx, loopIdx + 3000); + }); + + it('imports isWalCorruptionError and WAL_RECOVERY_SUGGESTION from lbug-config', () => { + expect(adapterSource).toMatch(/isWalCorruptionError/); + expect(adapterSource).toMatch(/WAL_RECOVERY_SUGGESTION/); + expect(adapterSource).toMatch(/from '\.\/lbug-config\.js'/); + }); + + it('calls isWalCorruptionError inside the schema creation loop catch block', () => { + expect(schemaLoopBody).toMatch(/isWalCorruptionError\(err\)/); + }); + + it('WAL guard calls safeClose() to avoid leaving an open handle', () => { + expect(schemaLoopBody).toMatch(/await safeClose\(\)/); + }); + + it('WAL guard resets currentDbPath to null', () => { + expect(schemaLoopBody).toMatch(/currentDbPath = null/); + }); + + it('WAL guard throws with WAL_RECOVERY_SUGGESTION in the message', () => { + expect(schemaLoopBody).toMatch(/WAL_RECOVERY_SUGGESTION/); + expect(schemaLoopBody).toMatch(/throw new Error/); + }); + + it('WAL guard appears BEFORE the generic schema-warning logger.warn', () => { + const walGuardIdx = schemaLoopBody.indexOf('isWalCorruptionError(err)'); + // Avoid multi-byte emoji — search for the text portion only + const warnIdx = schemaLoopBody.indexOf('Schema creation warning'); + expect(walGuardIdx).toBeGreaterThan(-1); + expect(warnIdx).toBeGreaterThan(-1); + expect(walGuardIdx).toBeLessThan(warnIdx); + }); +}); + +// ─── Behavioural tests ──────────────────────────────────────────────────────── + +describe('doInitLbug WAL corruption guard — behavioural', () => { + afterEach(() => { + vi.doUnmock('fs/promises'); + vi.doUnmock('../../src/core/lbug/schema.js'); + vi.doUnmock('../../src/core/lbug/lbug-config.js'); + vi.doUnmock('../../src/core/lbug/extension-loader.js'); + vi.doUnmock('../../src/core/logger.js'); + vi.resetModules(); + vi.clearAllMocks(); + }); + + it('throws with WAL recovery message when a schema query raises a WAL corruption error', async () => { + vi.resetModules(); + + const dbPath = '/tmp/gitnexus-lbug-wal-schema-throw/lbug'; + const walError = new Error( + 'Runtime exception: Corrupted wal file. Read out invalid WAL record type.', + ); + const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() }; + const conn = { + query: vi.fn().mockRejectedValueOnce(walError).mockResolvedValue(queryResult), + close: vi.fn(async () => {}), + }; + const db = { close: vi.fn(async () => {}) }; + + vi.doMock('fs/promises', () => makeFsMock(dbPath)); + vi.doMock('../../src/core/lbug/schema.js', () => SCHEMA_MOCK); + vi.doMock('../../src/core/lbug/lbug-config.js', () => ({ + openLbugConnection: vi.fn(async () => ({ db, conn })), + closeLbugConnection: vi.fn(async () => {}), + isDbBusyError: vi.fn(() => false), + isOpenRetryExhausted: vi.fn(() => false), + isWalCorruptionError: vi.fn((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + return /corrupt.*wal|invalid.*wal.*record/i.test(msg); + }), + WAL_RECOVERY_SUGGESTION: + 'WAL corruption detected. Run `gitnexus analyze --force` to rebuild the index.', + waitForWindowsHandleRelease: vi.fn(async () => true), + })); + vi.doMock('../../src/core/lbug/extension-loader.js', () => ({ + extensionManager: { + ensure: vi.fn(async () => true), + getCapabilities: vi.fn(() => []), + reset: vi.fn(), + }, + })); + vi.doMock('../../src/core/logger.js', () => ({ + logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() }, + })); + + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + + // Catch the error once and assert both patterns in the message. + // (mockRejectedValueOnce is consumed on the first call, so a second + // initLbug call would succeed — test both patterns in one shot.) + const err = await adapter.initLbug(dbPath).catch((e: unknown) => e); + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toMatch(/LadybugDB WAL corruption detected/); + expect((err as Error).message).toMatch(/gitnexus analyze/); + }); + + it('does NOT throw for unrecognised schema errors — logs warn and continues', async () => { + vi.resetModules(); + + const dbPath = '/tmp/gitnexus-lbug-wal-schema-nonwal/lbug'; + const genericError = new Error('some unrelated schema warning'); + const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() }; + let callCount = 0; + const conn = { + query: vi.fn(async () => { + callCount++; + if (callCount === 1) throw genericError; + return queryResult; + }), + close: vi.fn(async () => {}), + }; + const db = { close: vi.fn(async () => {}) }; + const warnMock = vi.fn(); + + vi.doMock('fs/promises', () => makeFsMock(dbPath)); + vi.doMock('../../src/core/lbug/schema.js', () => SCHEMA_MOCK); + vi.doMock('../../src/core/lbug/lbug-config.js', () => ({ + openLbugConnection: vi.fn(async () => ({ db, conn })), + closeLbugConnection: vi.fn(async () => {}), + isDbBusyError: vi.fn(() => false), + isOpenRetryExhausted: vi.fn(() => false), + isWalCorruptionError: vi.fn(() => false), // always false → generic warn path + WAL_RECOVERY_SUGGESTION: + 'WAL corruption detected. Run `gitnexus analyze --force` to rebuild the index.', + waitForWindowsHandleRelease: vi.fn(async () => true), + })); + vi.doMock('../../src/core/lbug/extension-loader.js', () => ({ + extensionManager: { + ensure: vi.fn(async () => true), + getCapabilities: vi.fn(() => []), + reset: vi.fn(), + }, + })); + vi.doMock('../../src/core/logger.js', () => ({ + logger: { warn: warnMock, info: vi.fn(), error: vi.fn(), debug: vi.fn() }, + })); + + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + + // Must resolve without throwing — non-WAL schema errors are swallowed (logged as WARN) + await expect(adapter.initLbug(dbPath)).resolves.toBeDefined(); + expect(warnMock).toHaveBeenCalledWith(expect.stringContaining('Schema creation warning')); + + await adapter.closeLbug(); + }); + + it('calls safeClose() (db.close) when WAL corruption is detected mid-schema', async () => { + vi.resetModules(); + + const dbPath = '/tmp/gitnexus-lbug-wal-schema-state/lbug'; + const walError = new Error('Corrupted wal file. Read out invalid WAL record type.'); + const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() }; + const conn = { + query: vi.fn().mockRejectedValueOnce(walError).mockResolvedValue(queryResult), + close: vi.fn(async () => {}), + }; + const db = { close: vi.fn(async () => {}) }; + + vi.doMock('fs/promises', () => makeFsMock(dbPath)); + vi.doMock('../../src/core/lbug/schema.js', () => SCHEMA_MOCK); + vi.doMock('../../src/core/lbug/lbug-config.js', () => ({ + openLbugConnection: vi.fn(async () => ({ db, conn })), + closeLbugConnection: vi.fn(async () => {}), + isDbBusyError: vi.fn(() => false), + isOpenRetryExhausted: vi.fn(() => false), + isWalCorruptionError: vi.fn((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + return /corrupt.*wal|invalid.*wal.*record/i.test(msg); + }), + WAL_RECOVERY_SUGGESTION: + 'WAL corruption detected. Run `gitnexus analyze --force` to rebuild the index.', + waitForWindowsHandleRelease: vi.fn(async () => true), + })); + vi.doMock('../../src/core/lbug/extension-loader.js', () => ({ + extensionManager: { + ensure: vi.fn(async () => true), + getCapabilities: vi.fn(() => []), + reset: vi.fn(), + }, + })); + vi.doMock('../../src/core/logger.js', () => ({ + logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() }, + })); + + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + + await expect(adapter.initLbug(dbPath)).rejects.toThrow(/LadybugDB WAL corruption/); + + // safeClose was called — db.close is its final step + expect(db.close).toHaveBeenCalled(); + }); +}); diff --git a/gitnexus/test/unit/pool-wal-recovery.test.ts b/gitnexus/test/unit/pool-wal-recovery.test.ts index 19b24c583..cda42806a 100644 --- a/gitnexus/test/unit/pool-wal-recovery.test.ts +++ b/gitnexus/test/unit/pool-wal-recovery.test.ts @@ -34,6 +34,8 @@ vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({ vi.mock('../../src/core/lbug/lbug-config.js', () => ({ createLbugDatabase: vi.fn(), LBUG_MAX_DB_SIZE: 1024, + WAL_RECOVERY_SUGGESTION: + 'WAL corruption detected. Run `gitnexus analyze --force` to rebuild the index.', isWalCorruptionError: vi.fn((err: unknown) => { const msg = err instanceof Error ? err.message : String(err ?? ''); return /corrupt(ed)?\s+wal|invalid\s+wal\s+record/i.test(msg); diff --git a/gitnexus/test/unit/wiki-flags.test.ts b/gitnexus/test/unit/wiki-flags.test.ts index af19c676d..891c9e77f 100644 --- a/gitnexus/test/unit/wiki-flags.test.ts +++ b/gitnexus/test/unit/wiki-flags.test.ts @@ -264,6 +264,384 @@ describe('WikiGenerator --review mode', () => { }); }); +describe('wikiCommand --timeout validation', () => { + const originalExitCode = process.exitCode; + const tooLargeTimeout = String(Math.floor(Number.MAX_SAFE_INTEGER / 1000) + 1); + + beforeEach(() => { + vi.resetModules(); + process.exitCode = undefined; + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.doUnmock('../../src/storage/git.js'); + vi.doUnmock('../../src/storage/repo-manager.js'); + vi.doUnmock('../../src/core/wiki/llm-client.js'); + vi.doUnmock('../../src/core/wiki/generator.js'); + vi.doUnmock('cli-progress'); + process.exitCode = originalExitCode; + }); + + it.each(['', ' ', '0', '-1', 'abc', '3.14', tooLargeTimeout])( + 'rejects invalid --timeout value %s before starting generation', + async (timeout) => { + const generatorCtor = vi.fn().mockImplementation(() => ({ + run: vi.fn(), + })); + + vi.doMock('../../src/storage/git.js', () => ({ + getGitRoot: vi.fn(), + isGitRepo: vi.fn().mockReturnValue(true), + })); + vi.doMock('../../src/storage/repo-manager.js', () => ({ + getStoragePaths: vi + .fn() + .mockReturnValue({ storagePath: '/tmp/wiki-storage', lbugPath: '/tmp/wiki-db' }), + loadMeta: vi.fn().mockResolvedValue({ createdAt: '2026-01-01T00:00:00Z' }), + loadCLIConfig: vi.fn().mockResolvedValue({ + apiKey: 'sk-test', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4o', + provider: 'openai', + }), + saveCLIConfig: vi.fn(), + })); + vi.doMock('../../src/core/wiki/llm-client.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveLLMConfig: vi.fn().mockResolvedValue({ + apiKey: 'sk-test', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4o', + maxTokens: 16_384, + temperature: 0, + provider: 'openai', + }), + }; + }); + vi.doMock('../../src/core/wiki/generator.js', () => ({ + WikiGenerator: generatorCtor, + })); + vi.doMock('cli-progress', () => ({ + default: { + SingleBar: vi.fn(function () { + return { + start: vi.fn(), + update: vi.fn(), + stop: vi.fn(), + }; + }), + Presets: { shades_grey: {} }, + }, + })); + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const { wikiCommand } = await import('../../src/cli/wiki.js'); + + await wikiCommand('/tmp/repo', { timeout }); + + expect(process.exitCode).toBe(1); + expect(generatorCtor).not.toHaveBeenCalled(); + const expectedMessage = + timeout === tooLargeTimeout + ? ' Error: --timeout is too large\n' + : ' Error: --timeout must be a positive integer\n'; + expect(consoleSpy).toHaveBeenCalledWith(expectedMessage); + }, + ); +}); + +describe('wikiCommand --retries validation', () => { + const originalExitCode = process.exitCode; + + beforeEach(() => { + vi.resetModules(); + process.exitCode = undefined; + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.doUnmock('../../src/storage/git.js'); + vi.doUnmock('../../src/storage/repo-manager.js'); + vi.doUnmock('../../src/core/wiki/llm-client.js'); + vi.doUnmock('../../src/core/wiki/generator.js'); + vi.doUnmock('cli-progress'); + process.exitCode = originalExitCode; + }); + + it.each(['', ' ', '0', '-1', 'abc', '3.14'])( + 'rejects invalid --retries value %s before starting generation', + async (retries) => { + const generatorCtor = vi.fn().mockImplementation(() => ({ + run: vi.fn(), + })); + + vi.doMock('../../src/storage/git.js', () => ({ + getGitRoot: vi.fn(), + isGitRepo: vi.fn().mockReturnValue(true), + })); + vi.doMock('../../src/storage/repo-manager.js', () => ({ + getStoragePaths: vi + .fn() + .mockReturnValue({ storagePath: '/tmp/wiki-storage', lbugPath: '/tmp/wiki-db' }), + loadMeta: vi.fn().mockResolvedValue({ createdAt: '2026-01-01T00:00:00Z' }), + loadCLIConfig: vi.fn().mockResolvedValue({ + apiKey: 'sk-test', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4o', + provider: 'openai', + }), + saveCLIConfig: vi.fn(), + })); + vi.doMock('../../src/core/wiki/llm-client.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveLLMConfig: vi.fn().mockResolvedValue({ + apiKey: 'sk-test', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4o', + maxTokens: 16_384, + temperature: 0, + provider: 'openai', + }), + }; + }); + vi.doMock('../../src/core/wiki/generator.js', () => ({ + WikiGenerator: generatorCtor, + })); + vi.doMock('cli-progress', () => ({ + default: { + SingleBar: vi.fn(function () { + return { + start: vi.fn(), + update: vi.fn(), + stop: vi.fn(), + }; + }), + Presets: { shades_grey: {} }, + }, + })); + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const { wikiCommand } = await import('../../src/cli/wiki.js'); + + await wikiCommand('/tmp/repo', { retries }); + + expect(process.exitCode).toBe(1); + expect(generatorCtor).not.toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith(' Error: --retries must be a positive integer\n'); + }, + ); +}); + +describe('wikiCommand --timeout mapping', () => { + const originalExitCode = process.exitCode; + + beforeEach(() => { + vi.resetModules(); + process.exitCode = undefined; + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.doUnmock('../../src/storage/git.js'); + vi.doUnmock('../../src/storage/repo-manager.js'); + vi.doUnmock('../../src/core/wiki/llm-client.js'); + vi.doUnmock('../../src/core/wiki/generator.js'); + vi.doUnmock('cli-progress'); + process.exitCode = originalExitCode; + }); + + async function loadWikiCommandHarness() { + let capturedConfig: Record | undefined; + const generatorCtor = vi + .fn() + .mockImplementation(function (_repoPath, _storagePath, _lbugPath, config) { + capturedConfig = config; + return { + run: vi.fn().mockResolvedValue({ mode: 'up-to-date', pagesGenerated: 0 }), + }; + }); + + vi.doMock('../../src/storage/git.js', () => ({ + getGitRoot: vi.fn(), + isGitRepo: vi.fn().mockReturnValue(true), + })); + vi.doMock('../../src/storage/repo-manager.js', () => ({ + getStoragePaths: vi + .fn() + .mockReturnValue({ storagePath: '/tmp/wiki-storage', lbugPath: '/tmp/wiki-db' }), + loadMeta: vi.fn().mockResolvedValue({ createdAt: '2026-01-01T00:00:00Z' }), + loadCLIConfig: vi.fn().mockResolvedValue({ + apiKey: 'sk-test', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4o', + provider: 'openai', + }), + saveCLIConfig: vi.fn(), + })); + vi.doMock('../../src/core/wiki/llm-client.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveLLMConfig: vi.fn().mockResolvedValue({ + apiKey: 'sk-test', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4o', + maxTokens: 16_384, + temperature: 0, + provider: 'openai', + }), + }; + }); + vi.doMock('../../src/core/wiki/generator.js', () => ({ + WikiGenerator: generatorCtor, + })); + vi.doMock('cli-progress', () => ({ + default: { + SingleBar: vi.fn(function () { + return { + start: vi.fn(), + update: vi.fn(), + stop: vi.fn(), + }; + }), + Presets: { shades_grey: {} }, + }, + })); + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const { wikiCommand } = await import('../../src/cli/wiki.js'); + return { + wikiCommand, + generatorCtor, + consoleSpy, + getCapturedConfig: () => capturedConfig, + }; + } + + it('maps --timeout seconds to requestTimeoutMs before constructing WikiGenerator', async () => { + const harness = await loadWikiCommandHarness(); + + await harness.wikiCommand('/tmp/repo', { timeout: '120' }); + + expect(harness.generatorCtor).toHaveBeenCalledTimes(1); + expect(harness.getCapturedConfig()?.requestTimeoutMs).toBe(120_000); + }); + + it('leaves requestTimeoutMs undefined when --timeout is omitted', async () => { + const harness = await loadWikiCommandHarness(); + + await harness.wikiCommand('/tmp/repo', {}); + + expect(harness.generatorCtor).toHaveBeenCalledTimes(1); + expect(harness.getCapturedConfig()?.requestTimeoutMs).toBeUndefined(); + }); + + it('maps --retries to maxAttempts before constructing WikiGenerator', async () => { + const harness = await loadWikiCommandHarness(); + + await harness.wikiCommand('/tmp/repo', { retries: '5' }); + + expect(harness.generatorCtor).toHaveBeenCalledTimes(1); + expect(harness.getCapturedConfig()?.maxAttempts).toBe(5); + }); +}); + +describe('wikiCommand timeout messaging', () => { + const originalExitCode = process.exitCode; + + beforeEach(() => { + vi.resetModules(); + process.exitCode = undefined; + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.doUnmock('../../src/storage/git.js'); + vi.doUnmock('../../src/storage/repo-manager.js'); + vi.doUnmock('../../src/core/wiki/llm-client.js'); + vi.doUnmock('../../src/core/wiki/generator.js'); + vi.doUnmock('cli-progress'); + process.exitCode = originalExitCode; + }); + + it('surfaces a dedicated timeout message when wiki generation hits the configured timeout', async () => { + const generatorCtor = vi.fn().mockImplementation(function () { + return { + run: vi + .fn() + .mockRejectedValue( + new Error( + 'LLM request timed out after 120s. Increase --timeout or omit it to disable the request timeout.', + ), + ), + }; + }); + + vi.doMock('../../src/storage/git.js', () => ({ + getGitRoot: vi.fn(), + isGitRepo: vi.fn().mockReturnValue(true), + })); + vi.doMock('../../src/storage/repo-manager.js', () => ({ + getStoragePaths: vi + .fn() + .mockReturnValue({ storagePath: '/tmp/wiki-storage', lbugPath: '/tmp/wiki-db' }), + loadMeta: vi.fn().mockResolvedValue({ createdAt: '2026-01-01T00:00:00Z' }), + loadCLIConfig: vi.fn().mockResolvedValue({ + apiKey: 'sk-test', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4o', + provider: 'openai', + }), + saveCLIConfig: vi.fn(), + })); + vi.doMock('../../src/core/wiki/llm-client.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveLLMConfig: vi.fn().mockResolvedValue({ + apiKey: 'sk-test', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4o', + maxTokens: 16_384, + temperature: 0, + provider: 'openai', + }), + }; + }); + vi.doMock('../../src/core/wiki/generator.js', () => ({ + WikiGenerator: generatorCtor, + })); + vi.doMock('cli-progress', () => ({ + default: { + SingleBar: vi.fn(function () { + return { + start: vi.fn(), + update: vi.fn(), + stop: vi.fn(), + }; + }), + Presets: { shades_grey: {} }, + }, + })); + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const { wikiCommand } = await import('../../src/cli/wiki.js'); + + await wikiCommand('/tmp/repo', { timeout: '120' }); + + expect(process.exitCode).toBe(1); + expect(generatorCtor).toHaveBeenCalledTimes(1); + expect(consoleSpy).toHaveBeenCalledWith( + '\n Timeout: LLM request timed out after 120s. Increase --timeout or omit it to disable the request timeout.\n', + ); + }); +}); + // ─── CLI config round-trip with cursor provider ────────────────────── describe('CLI config round-trip with cursor provider', () => { diff --git a/gitnexus/test/unit/wiki-llm-client.test.ts b/gitnexus/test/unit/wiki-llm-client.test.ts index 52b633566..5b6a827c3 100644 --- a/gitnexus/test/unit/wiki-llm-client.test.ts +++ b/gitnexus/test/unit/wiki-llm-client.test.ts @@ -237,6 +237,143 @@ describe('callLLM — reasoning model params', () => { }); }); +describe('callLLM — timeout handling', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('does not apply a default timeout when requestTimeoutMs is omitted', async () => { + const fetchSpy = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ choices: [{ message: { content: 'answer' } }], usage: {} }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchSpy); + const timeoutSpy = vi.spyOn(AbortSignal, 'timeout'); + + const { callLLM } = await import('../../src/core/wiki/llm-client.js'); + await callLLM('test', { + apiKey: 'sk-test', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4o', + maxTokens: 500, + temperature: 0, + }); + + expect(timeoutSpy).not.toHaveBeenCalled(); + const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + expect(init.signal).toBeUndefined(); + }); + + it('applies an explicit timeout when requestTimeoutMs is provided', async () => { + const fetchSpy = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ choices: [{ message: { content: 'answer' } }], usage: {} }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchSpy); + const timeoutSignal = new AbortController().signal; + const timeoutSpy = vi.spyOn(AbortSignal, 'timeout').mockReturnValue(timeoutSignal); + + const { callLLM } = await import('../../src/core/wiki/llm-client.js'); + await callLLM('test', { + apiKey: 'sk-test', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4o', + maxTokens: 500, + temperature: 0, + requestTimeoutMs: 120_000, + }); + + expect(timeoutSpy).toHaveBeenCalledWith(120_000); + const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + expect(init.signal).toBe(timeoutSignal); + }); + + it('surfaces a clear timeout error when the request timeout fires', async () => { + const fetchSpy = vi + .fn() + .mockRejectedValue(new DOMException('The operation timed out.', 'TimeoutError')); + vi.stubGlobal('fetch', fetchSpy); + + const { callLLM } = await import('../../src/core/wiki/llm-client.js'); + await expect( + callLLM('test', { + apiKey: 'sk-test', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4o', + maxTokens: 500, + temperature: 0, + requestTimeoutMs: 120_000, + }), + ).rejects.toThrow( + 'LLM request timed out after 120s. Increase --timeout or omit it to disable the request timeout.', + ); + }); + + it('surfaces millisecond timeout durations when the timeout is not a whole second', async () => { + const fetchSpy = vi + .fn() + .mockRejectedValue(new DOMException('The operation timed out.', 'TimeoutError')); + vi.stubGlobal('fetch', fetchSpy); + + const { callLLM } = await import('../../src/core/wiki/llm-client.js'); + await expect( + callLLM('test', { + apiKey: 'sk-test', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4o', + maxTokens: 500, + temperature: 0, + requestTimeoutMs: 1_500, + }), + ).rejects.toThrow( + 'LLM request timed out after 1500ms. Increase --timeout or omit it to disable the request timeout.', + ); + }); + + it('surfaces the same timeout message for timeout-like non-DOM errors', async () => { + const fetchSpy = vi + .fn() + .mockRejectedValue(new Error('request timed out while waiting for response')); + vi.stubGlobal('fetch', fetchSpy); + + const { callLLM } = await import('../../src/core/wiki/llm-client.js'); + await expect( + callLLM('test', { + apiKey: 'sk-test', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4o', + maxTokens: 500, + temperature: 0, + requestTimeoutMs: 120_000, + }), + ).rejects.toThrow( + 'LLM request timed out after 120s. Increase --timeout or omit it to disable the request timeout.', + ); + }); + + it('does not mislabel generic aborted connections as request timeouts', async () => { + const fetchSpy = vi.fn().mockRejectedValue(new Error('connection aborted by server')); + vi.stubGlobal('fetch', fetchSpy); + + const { callLLM } = await import('../../src/core/wiki/llm-client.js'); + await expect( + callLLM('test', { + apiKey: 'sk-test', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4o', + maxTokens: 500, + temperature: 0, + requestTimeoutMs: 120_000, + }), + ).rejects.toThrow('connection aborted by server'); + }); +}); + describe('callLLM — Azure content_filter error', () => { afterEach(() => vi.unstubAllGlobals());