From 803f0bed5f7d714d3ee8577e1e42f17bbf371dca Mon Sep 17 00:00:00 2001 From: LocallyInsaneDB Date: Tue, 19 May 2026 13:09:21 +0200 Subject: [PATCH 1/2] fix(lbug): probe-then-load FTS extension on Windows (#1690) (#1692) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(lbug): probe-then-load FTS extension on Windows (#1690) The Windows skip-on-process.platform==='win32' guard in pool-adapter.ts hard-skipped loadFTSExtension() for every Windows host, even when the FTS extension binary was already present locally at ~/.lbdb/extension//win_amd64/fts/libfts.lbug_extension. That left BM25 silently degraded on Windows hosts that had a working extension on disk, with no error path — `gitnexus doctor` still reported FTS as available, but query returned 0 BM25 hits. This patch adds hasLocalWinFtsExtension() which probes ~/.lbdb/extension/*/win_amd64/fts/ before the Windows skip. When a binary is on disk we call loadFTSExtension(..., { policy: 'load-only' }); the crashing install path documented in #1199 / #1217 is never exercised at query time, and LadybugDB's version-specific resolution combined with the ExtensionManager's tryLoad try/catch handles stale or zero-byte sibling version dirs cleanly (no dlopen attempted on a stale binary). When no binary is on disk at all, we fall back to the upstream skip so install-time SIGSEGV continues to be avoided. Verified on Windows 10 + Node 22.19.0 + gitnexus 1.6.5 + @ladybugdb/core 0.16.1 with the FTS extension cached at 0.16.0: * BM25 timing goes from 0 → ~250-326ms on previously-zero queries * gitnexus context / impact / cypher unaffected * Adversarial-mixed-state run (real 0.16.0 binary + zero-byte stubs at 0.15.0, 0.16.1, 0.17.0): exits 0, no SIGSEGV, FTS resolves to the real 0.16.0 binary, BM25 returns real hits * Stub-only state at the resolution path (0.16.0, zero-byte): exits 0, emits "FTS extension unavailable; load-only policy: extension not pre-installed", FTS marked unavailable cleanly via markUnavailable in extension-loader.ts — no silent greenlight Closes #1690 * test(lbug): cover hasLocalWinFtsExtension probe + format pool-adapter - Export hasLocalWinFtsExtension and add lbug-pool-win-fts-probe.test.ts with 7 cases against a real tmpdir + os.homedir spy: * missing ~/.lbdb/extension dir -> false * extension root present but no version dirs -> false * one version dir with binary present -> true * zero-byte stub at probe path -> true (LOAD failure handled downstream) * multi-version with binary only in a non-first dir -> true * multi-version with no binary anywhere (Nix/Bazel/MDM tree) -> false * fs.readdir throws (EACCES) -> false The Windows conditional in doInitLbug / initLbugWithDb is intentionally not unit-isolated: it reduces to `probe ? load : true` over a fully constructed lbug.Database + Connection pool, which the test/integration/lbug-pool*.test.ts suites already exercise on the windows-latest CI matrix. - Apply prettier format to the fs.stat() call in pool-adapter.ts, resolving the quality/format CI failure surfaced by gitnexus/autofix. Addresses DoD §2.7 test-coverage blocker raised in the production- readiness review on #1692, and the dir-exists-no-file regression case raised on #1690. Refs #1690. --------- Co-authored-by: Gergő Magyar --- gitnexus/src/core/lbug/pool-adapter.ts | 68 +++++++-- .../test/unit/lbug-pool-win-fts-probe.test.ts | 132 ++++++++++++++++++ 2 files changed, 191 insertions(+), 9 deletions(-) create mode 100644 gitnexus/test/unit/lbug-pool-win-fts-probe.test.ts 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); + }); +}); From 92ad0f5491d13a89c7689ca54f0f4ad52d884820 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 05:38:26 +0100 Subject: [PATCH 2/2] chore(deps): bump idna in /eval in the uv group across 1 directory (#1713) --- eval/uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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]]