diff --git a/eval/uv.lock b/eval/uv.lock index 04b89f336..a78eadf06 100644 --- a/eval/uv.lock +++ b/eval/uv.lock @@ -760,11 +760,11 @@ wheels = [ [[package]] name = "idna" -version = "3.11" +version = "3.15" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, ] [[package]] diff --git a/gitnexus/src/core/lbug/pool-adapter.ts b/gitnexus/src/core/lbug/pool-adapter.ts index d373d13c4..f551c65f8 100644 --- a/gitnexus/src/core/lbug/pool-adapter.ts +++ b/gitnexus/src/core/lbug/pool-adapter.ts @@ -16,6 +16,8 @@ */ import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; import lbug from '@ladybugdb/core'; import { isReadOnlyDbError, loadFTSExtension } from './lbug-adapter.js'; import { @@ -24,6 +26,42 @@ import { WAL_RECOVERY_SUGGESTION, } from './lbug-config.js'; +/** + * Probe whether a Windows FTS extension binary is locally installed under + * ~/.lbdb/extension//win_amd64/fts/. Returns true on the first + * version dir whose libfts.lbug_extension exists on disk; false if the + * extension root is missing or contains no FTS binary. + * + * Gates the Windows skip-FTS-load guard below so we only skip the load + * when no extension binary is present. When at least one binary exists, + * loadFTSExtension is called with policy: 'load-only' — LadybugDB resolves + * LOAD EXTENSION fts to its version-specific path internally, and the + * ExtensionManager's tryLoad try/catch handles version-mismatch errors + * cleanly without ever attempting dlopen of a stale binary. The install + * path that the #1199/#1217 SIGSEGV documented is never exercised at + * query time. + * + * Exported so unit tests can exercise the probe directly against a + * temp-dir plus spied `os.homedir()` — see lbug-pool-win-fts-probe.test.ts. + */ +export async function hasLocalWinFtsExtension(): Promise { + try { + const extRoot = path.join(os.homedir(), '.lbdb', 'extension'); + const versions = await fs.readdir(extRoot); + for (const v of versions) { + try { + await fs.stat(path.join(extRoot, v, 'win_amd64', 'fts', 'libfts.lbug_extension')); + return true; + } catch { + /* missing for this version, keep looking */ + } + } + } catch { + /* no .lbdb/extension dir */ + } + return false; +} + /** Per-repo pool: one Database, many Connections */ interface PoolEntry { db: lbug.Database; @@ -423,14 +461,24 @@ async function doInitLbug(repoId: string, dbPath: string): Promise { // install; analyze owns extension installation. If LOAD fails, search // features degrade gracefully and the user-facing query path proceeds. if (!shared.ftsLoaded) { - // Windows guard: LOAD EXTENSION fts crashes with SIGSEGV on Windows when - // the FTS extension binary is not installed locally (@ladybugdb/core native - // bug — the extension loader hits an unhandled error path that signals SIGSEGV - // rather than throwing a JS exception, so try/catch cannot protect here). - // Skip the load on Windows; bm25-index.js catches the resulting Kuzu catalog - // errors and returns empty BM25 results gracefully. Graph queries are unaffected. + // Windows guard: LOAD EXTENSION fts crashes with SIGSEGV on Windows during + // *install* — the @ladybugdb/core out-of-process installer hits an unhandled + // error path that signals SIGSEGV instead of throwing (see #1199, #1217). + // The previous unconditional skip was over-broad: it also disabled FTS on + // hosts where the binary was already on disk and only needed LOAD, leaving + // BM25 silently degraded with no error path (see #1690). + // + // Probe ~/.lbdb/extension/*/win_amd64/fts/ first. If any binary is on disk + // we run loadFTSExtension(..., 'load-only'); the install path is never + // exercised, and LadybugDB's version-specific resolution + ExtensionManager + // try/catch handle stale/zero-byte siblings cleanly (verified empirically + // on Win10 + Node 22.19 + gitnexus 1.6.5 + @ladybugdb/core 0.16.1). With + // no binary at all, we fall back to the upstream skip so install-time + // SIGSEGV continues to be avoided. if (process.platform === 'win32') { - shared.ftsLoaded = true; + shared.ftsLoaded = (await hasLocalWinFtsExtension()) + ? await loadFTSExtension(available[0], { policy: 'load-only' }) + : true; } else { shared.ftsLoaded = await loadFTSExtension(available[0], { policy: 'load-only' }); } @@ -497,10 +545,12 @@ export async function initLbugWithDb( // Load FTS extension if not already loaded on this Database. // policy: 'load-only' — same contract as initLbug above; the read pool // must not block on a network install during query execution. - // Windows guard: same SIGSEGV risk as doInitLbug above — skip on Windows. + // Windows guard: same probe-then-load policy as doInitLbug above. if (!shared.ftsLoaded) { if (process.platform === 'win32') { - shared.ftsLoaded = true; + shared.ftsLoaded = (await hasLocalWinFtsExtension()) + ? await loadFTSExtension(available[0], { policy: 'load-only' }) + : true; } else { shared.ftsLoaded = await loadFTSExtension(available[0], { policy: 'load-only' }); } diff --git a/gitnexus/test/unit/lbug-pool-win-fts-probe.test.ts b/gitnexus/test/unit/lbug-pool-win-fts-probe.test.ts new file mode 100644 index 000000000..fec1fe471 --- /dev/null +++ b/gitnexus/test/unit/lbug-pool-win-fts-probe.test.ts @@ -0,0 +1,132 @@ +/** + * Unit tests for the Windows FTS probe in pool-adapter.ts. + * + * Covers `hasLocalWinFtsExtension()` — the helper that gates the + * Windows-only skip of `loadFTSExtension` in `doInitLbug` and + * `initLbugWithDb`. Issue #1690 / PR #1692. + * + * The probe is exercised against a real temp filesystem with + * `os.homedir()` spied to point at the tempdir. This tests the + * actual fs surface (readdir/stat semantics, missing-dir behavior, + * zero-byte file handling) rather than mocking fs internals. + * + * The Windows-branch conditional in `doInitLbug` / `initLbugWithDb` + * is intentionally not unit-tested in isolation: those functions + * require a fully constructed `lbug.Database` + `Connection` pool + * and are exercised end-to-end by `test/integration/lbug-pool*.test.ts` + * on the `windows-latest` matrix. The conditional itself is a single + * expression — `(await hasLocalWinFtsExtension()) ? load : true` — + * whose correctness reduces to the probe being correctly tested here. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import os from 'os'; +import path from 'path'; +import fs from 'fs/promises'; + +// Stub out the LadybugDB native loader and its transitive importers so that +// importing pool-adapter.ts in this unit test does not pull in the .node binary +// (which is built by the postinstall script and is not always present in the +// dev install used for unit tests). +vi.mock('@ladybugdb/core', () => ({ + default: { Database: vi.fn(), Connection: vi.fn() }, +})); +vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({ + isReadOnlyDbError: vi.fn(() => false), + loadFTSExtension: vi.fn(), +})); +vi.mock('../../src/core/lbug/lbug-config.js', () => ({ + createLbugDatabase: vi.fn(), + isWalCorruptionError: vi.fn(() => false), + WAL_RECOVERY_SUGGESTION: '', +})); + +import { hasLocalWinFtsExtension } from '../../src/core/lbug/pool-adapter.js'; + +describe('hasLocalWinFtsExtension', () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-fts-probe-')); + vi.spyOn(os, 'homedir').mockReturnValue(tmpHome); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await fs.rm(tmpHome, { recursive: true, force: true }); + }); + + it('returns false when ~/.lbdb/extension does not exist', async () => { + // tmpHome is empty; the probe should swallow the readdir ENOENT and return false. + await expect(hasLocalWinFtsExtension()).resolves.toBe(false); + }); + + it('returns false when ~/.lbdb/extension exists but has no version dirs', async () => { + await fs.mkdir(path.join(tmpHome, '.lbdb', 'extension'), { recursive: true }); + await expect(hasLocalWinFtsExtension()).resolves.toBe(false); + }); + + it('returns true when a single version dir contains the FTS binary', async () => { + const ftsDir = path.join(tmpHome, '.lbdb', 'extension', '0.16.0', 'win_amd64', 'fts'); + await fs.mkdir(ftsDir, { recursive: true }); + await fs.writeFile(path.join(ftsDir, 'libfts.lbug_extension'), Buffer.from('mock-binary')); + await expect(hasLocalWinFtsExtension()).resolves.toBe(true); + }); + + it('returns true when the binary is a zero-byte stub (LOAD failure handled downstream)', async () => { + // Empirically verified in #1690 thread: LadybugDB resolves LOAD EXTENSION fts to a + // version-specific path internally and the ExtensionManager's tryLoad try/catch + // catches the resulting load error cleanly. Probe is intentionally generous here; + // safety lives in the loader, not the probe. + const ftsDir = path.join(tmpHome, '.lbdb', 'extension', '0.16.0', 'win_amd64', 'fts'); + await fs.mkdir(ftsDir, { recursive: true }); + await fs.writeFile(path.join(ftsDir, 'libfts.lbug_extension'), ''); + await expect(hasLocalWinFtsExtension()).resolves.toBe(true); + }); + + it('returns true when multiple version dirs exist and only one carries the binary', async () => { + const versions = ['0.15.0', '0.16.0', '0.17.0']; + for (const v of versions) { + await fs.mkdir(path.join(tmpHome, '.lbdb', 'extension', v, 'win_amd64', 'fts'), { + recursive: true, + }); + } + // Only 0.16.0 has the binary; the probe should keep iterating past empty siblings. + await fs.writeFile( + path.join( + tmpHome, + '.lbdb', + 'extension', + '0.16.0', + 'win_amd64', + 'fts', + 'libfts.lbug_extension', + ), + Buffer.from('mock-binary'), + ); + await expect(hasLocalWinFtsExtension()).resolves.toBe(true); + }); + + it('returns false when version dirs exist but none contain the binary', async () => { + // Adversarial topology raised by #1690 review: tree exists (Nix store, Bazel + // sandbox seeding, corporate MDM-prepopulated user dirs) but the actual + // libfts.lbug_extension file is absent. Probe must distinguish file from dir. + const versions = ['0.15.0', '0.16.0', '0.17.0']; + for (const v of versions) { + await fs.mkdir(path.join(tmpHome, '.lbdb', 'extension', v, 'win_amd64', 'fts'), { + recursive: true, + }); + } + await expect(hasLocalWinFtsExtension()).resolves.toBe(false); + }); + + it('returns false when fs.readdir throws (e.g. permission denied on the extension root)', async () => { + // Cover the outer try/catch — any fs error walking the extension root is + // treated as "no binary present", matching the upstream skip-guard intent. + const eaccess = Object.assign(new Error('EACCES: permission denied'), { + code: 'EACCES', + }) as NodeJS.ErrnoException; + vi.spyOn(fs, 'readdir').mockRejectedValue(eaccess); + await expect(hasLocalWinFtsExtension()).resolves.toBe(false); + }); +});