diff --git a/gitnexus/README.md b/gitnexus/README.md index 756a40d88..675de437f 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -352,6 +352,13 @@ Installed automatically by both `gitnexus analyze` (per-repo) and `gitnexus setu - Node.js >= 22 - Git repository (uses git for commit tracking) +- **Linux: glibc 2.34 or newer** (Ubuntu 22.04+, RHEL/Rocky/Alma 9+, Debian 12+, Fedora 35+). The + LadybugDB native binary ships as a prebuild against that floor, so on an older host it cannot + load and reinstalling does not help — see + [Linux: `GLIBC_2.34' not found`](#linux-glibc_234-not-found). +- **Windows, for full-text search:** the Microsoft Visual C++ 2015-2022 Redistributable (x64) *and* + OpenSSL 3 (`libssl-3-x64.dll`, `libcrypto-3-x64.dll`) resolvable on `PATH` — see + [Windows: full-text search unavailable](#windows-full-text-search-unavailable). ## Release candidates @@ -434,6 +441,50 @@ pnpm add -g --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=t gitnexus serve ``` +### Linux: `GLIBC_2.34' not found` + +``` +LadybugDB native binary (lbugjs.node) exists but failed to load: + /lib64/libc.so.6: version `GLIBC_2.34' not found (required by .../lbugjs.node) +``` + +The LadybugDB addon ships as a prebuilt binary compiled against **glibc 2.34**. If your +distribution is older (CentOS/RHEL 8 has 2.28, Ubuntu 20.04 has 2.31, Debian 11 has 2.31), the +dynamic loader cannot resolve its symbols. + +**Reinstalling does not help** — every download delivers the same prebuilt binary. The fix is a +newer C library: + +- Run GitNexus on a distribution with glibc 2.34 or newer — Ubuntu 22.04+, RHEL/Rocky/Alma 9+, + Debian 12+, Fedora 35+. +- Or run it in the container image, which bundles a current glibc (see [Docker](#docker)). + +`gitnexus doctor` reports the required and detected glibc versions when this happens +([#2672](https://github.com/abhigyanpatwari/GitNexus/issues/2672)). + +### Windows: full-text search unavailable + +`analyze` completes, but keyword search is degraded and `doctor` shows the FTS extension failing +with Windows error 126 (`The specified module could not be found`). The extension needs two +runtime dependencies Windows does not ship by default: + +1. **Microsoft Visual C++ 2015-2022 Redistributable (x64)** — + +2. **OpenSSL 3** — `libssl-3-x64.dll` and `libcrypto-3-x64.dll`, resolvable on `PATH` + +The redistributable alone is **not** sufficient. If Git for Windows is installed you already have +the OpenSSL DLLs — run `gitnexus` from **Git Bash**, or prepend the directory to `PATH` in the +shell you use: + +```powershell +$env:PATH = "C:\Program Files\Git\mingw64\bin;$env:PATH" +gitnexus analyze --repair-fts +``` + +Without them the index is still built, but without search tables, so `query` returns empty keyword +results until you re-run `gitnexus analyze --repair-fts` from a shell where the DLLs resolve +([#2669](https://github.com/abhigyanpatwari/GitNexus/issues/2669)). + ### Installation fails with native module errors Some optional language grammars (Dart, Proto, Swift, Kotlin) require native compilation. If they fail, GitNexus still works — those languages will be skipped. To skip them intentionally (no C++ toolchain needed), set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` before installing. diff --git a/gitnexus/src/cli/doctor.ts b/gitnexus/src/cli/doctor.ts index 6f7fe40c9..2573dc11d 100644 --- a/gitnexus/src/cli/doctor.ts +++ b/gitnexus/src/cli/doctor.ts @@ -14,6 +14,7 @@ import { import { cudaRedirectDoctorStatus } from '../core/embeddings/onnxruntime-node-resolver.js'; import { checkLbugNative, + type NativeCheckResult, probeFtsExtensionLoad, probeVectorExtensionLoad, } from '../core/lbug/native-check.js'; @@ -170,6 +171,33 @@ export function poolSizeDoctorLine(pool: number, envRaw: string | undefined): st return ` ${padDisplayEnd('pool size', 10)}${value}${envNote}`; } +/** + * The `native` status line. Literal label like the page-size and pool-size lines + * above (no i18n key). + * + * A failed check is not automatically a MISSING binary, and saying so is the + * same misdiagnosis #2672 fixed one layer down: on a host whose glibc is too + * old, `lbugjs.node` is present and merely unloadable, so "missing" sent users + * to reinstall a file that was already there — while the detail written to + * stderr right below said the opposite. Render what the check actually found. + */ +export function nativeStatusLine(check: NativeCheckResult): string { + return ` ${padDisplayEnd('native', 10)}${nativeStatusText(check)}`; +} + +function nativeStatusText(check: NativeCheckResult): string { + if (check.ok) return '✓ lbugjs.node loaded'; + switch (check.kind) { + case 'package_missing': + return '✗ @ladybugdb/core not installed'; + case 'load_failed': + return '✗ lbugjs.node present but failed to load'; + default: + // 'binary_missing', and any future kind: the conservative claim. + return '✗ lbugjs.node missing'; + } +} + export const doctorCommand = async () => { const fingerprint = getRuntimeFingerprint(); const capabilities = getRuntimeCapabilities(); @@ -194,10 +222,8 @@ export const doctorCommand = async () => { poolSizeDoctorLine(getEffectiveBufferPoolSize(), process.env.GITNEXUS_LBUG_BUFFER_POOL_SIZE), ); const nativeCheck = checkLbugNative(); - if (nativeCheck.ok) { - console.log(` ${padDisplayEnd('native', 10)}✓ lbugjs.node loaded`); - } else { - console.log(` ${padDisplayEnd('native', 10)}✗ lbugjs.node missing`); + console.log(nativeStatusLine(nativeCheck)); + if (!nativeCheck.ok) { process.stderr.write(`\n${nativeCheck.message?.replace(/^/gm, ' ')}\n\n`); } console.log(` ${label('doctor.labels.onnx', 10)}${fingerprint.onnxruntime ?? 'unknown'}`); diff --git a/gitnexus/src/core/lbug/extension-load-error.ts b/gitnexus/src/core/lbug/extension-load-error.ts index 712730164..f8213ef4d 100644 --- a/gitnexus/src/core/lbug/extension-load-error.ts +++ b/gitnexus/src/core/lbug/extension-load-error.ts @@ -127,13 +127,25 @@ const VC_REDIST_INSTALL_HINT = 'the Microsoft Visual C++ 2015-2022 Redistributable (x64) from ' + 'https://aka.ms/vs/17/release/vc_redist.x64.exe'; +// Git for Windows already ships the OpenSSL 3 DLLs in its mingw64 bin directory, +// so the identical command that fails in PowerShell succeeds in Git Bash (#2669 +// reporter, who had the VC++ redist installed and still failed until that +// directory was on PATH). Deliberately a fixed system path and never a +// user-profile one: remedy text is NOT path-redacted (fts-indexes.ts redacts +// only the reason), and fts-degraded-warning.test.ts asserts that no +// `C:\Users\…` path ever reaches a user through this surface. +const GIT_BASH_OPENSSL_HINT = + ' If Git for Windows is installed you already have those DLLs: run the same command from Git Bash, ' + + 'or prepend "C:\\Program Files\\Git\\mingw64\\bin" to PATH.'; + // MSVC-first per DuckDB's canonical answer for this exact error; OpenSSL second. const windowsMissingDependencyRemedy = (label: string): string => `The ${label} extension is present but a required runtime library is missing (Windows error 126). ` + 'Reinstalling the extension will NOT help. Install ' + VC_REDIST_INSTALL_HINT + '; if the error persists, the extension also needs OpenSSL 3 ' + - '(libcrypto-3-x64.dll / libssl-3-x64.dll) on the DLL search path.'; + '(libcrypto-3-x64.dll / libssl-3-x64.dll) on the DLL search path.' + + GIT_BASH_OPENSSL_HINT; const posixMissingDependencyRemedy = (label: string): string => `The ${label} extension is present but a shared library it depends on could not be loaded (named in ` + @@ -205,7 +217,8 @@ const structuralMissingDependencyRemedy = (label: string): string => `The ${label} extension file is valid, so the failure is a missing or incompatible runtime dependency, ` + 'not the extension itself — reinstalling will NOT help. On Windows, install ' + VC_REDIST_INSTALL_HINT + - ' and ensure OpenSSL 3 is available; on Linux/macOS install the shared library named in the error above.'; + ' and ensure OpenSSL 3 is available; on Linux/macOS install the shared library named in the error above.' + + GIT_BASH_OPENSSL_HINT; /** * Pull the extension file path out of lbug's load error. lbug's wrapper is diff --git a/gitnexus/src/core/lbug/native-check.ts b/gitnexus/src/core/lbug/native-check.ts index c874ed98e..f518af05a 100644 --- a/gitnexus/src/core/lbug/native-check.ts +++ b/gitnexus/src/core/lbug/native-check.ts @@ -7,10 +7,21 @@ import { spawnSync, type SpawnSyncReturns } from 'node:child_process'; * CLI startup gate (same bounding rationale as the extension probe below). */ const NATIVE_LOAD_PROBE_TIMEOUT_MS = 15_000; +/** + * Why the native check failed. A failed check is NOT necessarily a missing + * binary — the package may be absent, the binary may be absent, or a binary that + * is right there may fail to load (host glibc too old, truncated download). + * Callers that render a status line must tell those apart: reporting all of them + * as "missing" sends users to reinstall a file they already have (#2672). + */ +export type NativeCheckFailureKind = 'package_missing' | 'binary_missing' | 'load_failed'; + export interface NativeCheckResult { ok: boolean; binaryPath?: string; message?: string; + /** Set only when `ok` is false. */ + kind?: NativeCheckFailureKind; } export function checkLbugNative(overridePkgDir?: string): NativeCheckResult { @@ -26,6 +37,7 @@ export function checkLbugNative(overridePkgDir?: string): NativeCheckResult { } catch { return { ok: false, + kind: 'package_missing', message: [ 'LadybugDB package (@ladybugdb/core) is not installed.', '', @@ -40,6 +52,7 @@ export function checkLbugNative(overridePkgDir?: string): NativeCheckResult { return { ok: false, binaryPath, + kind: 'binary_missing', message: [ 'LadybugDB native binary (lbugjs.node) is missing.', '', @@ -89,9 +102,30 @@ export function checkLbugNative(overridePkgDir?: string): NativeCheckResult { return { ok: true, binaryPath }; } + // One failure class is NOT repairable by reinstalling: a host whose glibc is + // older than the prebuilt binary requires. Every download ships the same + // binary, so the generic advice below sends the user around a loop that always + // ends here (#2672). Branch before it, and only here — on the arm where the + // probe actually ran and failed, so an unrunnable probe still fails open above. + const glibcExplanation = glibcTooOldMessage(probe.stderr ?? ''); + if (glibcExplanation !== null) { + return { + ok: false, + binaryPath, + kind: 'load_failed', + message: [ + 'LadybugDB native binary (lbugjs.node) exists but failed to load:', + ` ${describeNativeLoadFailure(probe)}`, + '', + glibcExplanation, + ].join('\n'), + }; + } + return { ok: false, binaryPath, + kind: 'load_failed', message: [ 'LadybugDB native binary (lbugjs.node) exists but failed to load:', ` ${describeNativeLoadFailure(probe)}`, @@ -133,6 +167,80 @@ function describeNativeLoadFailure(probe: SpawnSyncReturns): string { ); } +/** + * A `GLIBC_` token. The dynamic loader names the first unresolved + * versioned symbol as ``version `GLIBC_2.34' not found (required by …)``, but we + * key on the token plus a "not found" line rather than on glibc's exact + * backtick/apostrophe quoting: if that wording ever changes, this degrades to + * the generic failure message instead of misfiring. + */ +const GLIBC_VERSION_TOKEN = /GLIBC_(\d+(?:\.\d+)+)/g; + +/** Numeric dotted-segment order — glibc 2.9 is OLDER than 2.34, not newer. */ +function compareDottedVersions(a: string, b: string): number { + const left = a.split('.').map((part) => Number.parseInt(part, 10)); + const right = b.split('.').map((part) => Number.parseInt(part, 10)); + for (let i = 0; i < Math.max(left.length, right.length); i += 1) { + const diff = (left[i] ?? 0) - (right[i] ?? 0); + if (diff !== 0) return diff; + } + return 0; +} + +/** + * This host's runtime glibc, or null when Node cannot report it (musl builds, + * embedders without `process.report`). Read locally rather than through + * analyzer-identity's `detectLibcVariant`: that module is deliberately reached + * via dynamic import from the CLI lazy actions, and this file is the + * dependency-light startup gate that must not pull it in. + */ +function hostGlibcVersion(): string | null { + try { + const report = process.report?.getReport() as + | { header?: { glibcVersionRuntime?: unknown } } + | undefined; + const runtime = report?.header?.glibcVersionRuntime; + return typeof runtime === 'string' && runtime.length > 0 ? runtime : null; + } catch { + // Report generation is optional on some embedded Node builds; an unknown + // host version still leaves the required version worth printing. + return null; + } +} + +/** + * Explain a glibc-too-old native load failure, or null when the probe's stderr + * describes something else. + * + * Reinstalling cannot fix this class — the package ships one prebuilt binary per + * platform — so the caller must NOT fall through to the reinstall instructions + * (#2672). Exported for direct unit testing: a real `GLIBC_2.34' not found` + * cannot be provoked on a host whose glibc is new enough to run the tests. + */ +export function glibcTooOldMessage(stderr: string): string | null { + const required = stderr + .split('\n') + .filter((line) => /not found/i.test(line)) + .flatMap((line) => [...line.matchAll(GLIBC_VERSION_TOKEN)].map((match) => match[1])) + .sort(compareDottedVersions) + .at(-1); + if (required === undefined) return null; + + const host = hostGlibcVersion(); + return [ + "This host's C library (glibc) is older than the prebuilt binary requires.", + ` required: glibc ${required} or newer`, + ` this host: ${host === null ? 'glibc version could not be determined' : `glibc ${host}`}`, + '', + 'Reinstalling will NOT help — every download ships the same prebuilt binary.', + '', + 'Options:', + ` - Run GitNexus on a distribution with glibc ${required} or newer`, + ' (Ubuntu 22.04+, RHEL/Rocky/Alma 9+, Debian 12+, Fedora 35+).', + ' - Or use the GitNexus container image, which bundles a current glibc.', + ].join('\n'); +} + export interface FtsProbeResult { loaded: boolean; /** Collapsed LadybugDB error when `loaded` is false. */ diff --git a/gitnexus/test/unit/doctor-format.test.ts b/gitnexus/test/unit/doctor-format.test.ts index 416544ed8..33a70dd0e 100644 --- a/gitnexus/test/unit/doctor-format.test.ts +++ b/gitnexus/test/unit/doctor-format.test.ts @@ -4,9 +4,11 @@ import { doctorCommand, localEmbeddingDoctorStatus, padDisplayEnd, + nativeStatusLine, pageSizeDoctorLines, poolSizeDoctorLine, } from '../../src/cli/doctor.js'; +import type { NativeCheckResult } from '../../src/core/lbug/native-check.js'; describe('doctor output formatting', () => { it('keeps ASCII padding equivalent to String.padEnd', () => { @@ -187,6 +189,36 @@ describe('doctor pool-size line (#2631)', () => { }); }); +// #2672: every failed check used to print "lbugjs.node missing", including the +// glibc case where the binary is present and merely unloadable — contradicting +// the detail printed directly beneath it and sending users to reinstall a file +// they already had. +describe('doctor native status line (#2672)', () => { + const nativeStatusCases: ReadonlyArray = [ + ['a loaded binary', { ok: true, binaryPath: '/x/lbugjs.node' }, '✓ lbugjs.node loaded'], + [ + 'an uninstalled package', + { ok: false, kind: 'package_missing', message: 'x' }, + '✗ @ladybugdb/core not installed', + ], + [ + 'an absent binary', + { ok: false, kind: 'binary_missing', binaryPath: '/x/lbugjs.node', message: 'x' }, + '✗ lbugjs.node missing', + ], + [ + 'a present-but-unloadable binary (glibc too old, truncated download)', + { ok: false, kind: 'load_failed', binaryPath: '/x/lbugjs.node', message: 'x' }, + '✗ lbugjs.node present but failed to load', + ], + ['a failure with no kind recorded', { ok: false, message: 'x' }, '✗ lbugjs.node missing'], + ]; + + it.each(nativeStatusCases)('reports %s', (_name, check, expected) => { + expect(nativeStatusLine(check)).toBe(` ${padDisplayEnd('native', 10)}${expected}`); + }); +}); + describe('doctor survives a malformed GITNEXUS_EMBEDDING_DIMS (#2385)', () => { const ENV_KEYS = [ 'GITNEXUS_EMBEDDING_URL', diff --git a/gitnexus/test/unit/extension-load-error.test.ts b/gitnexus/test/unit/extension-load-error.test.ts index 71a9c7d42..4cbba9233 100644 --- a/gitnexus/test/unit/extension-load-error.test.ts +++ b/gitnexus/test/unit/extension-load-error.test.ts @@ -154,6 +154,11 @@ describe('classifyExtensionLoadError', () => { expect(remedy).toMatch(/will NOT help/); // Must not resurrect the old, wrong "retry the network install" instruction. expect(remedy).not.toMatch(/Retry with network access/i); + // #2669: the zero-install path — Git for Windows already ships those DLLs. + expect(remedy).toMatch(/Git Bash/); + expect(remedy).toMatch(/mingw64/); + // Never a user-profile path: remedy text reaches /api/search unredacted. + expect(remedy).not.toMatch(/C:\\Users\\/); }); it('hedged fallback remedy points at the OS error and offers both branches (language-independent)', () => { @@ -318,6 +323,9 @@ describe('diagnoseExtensionLoad (structural, language-independent)', () => { const { kind, remedy } = diagnoseExtensionLoad(reason); expect(kind).toBe('missing_dependency'); expect(remedy).toMatch(/vc_redist\.x64\.exe/); + // #2669: the structural remedy carries the same zero-install hint. + expect(remedy).toMatch(/Git Bash/); + expect(remedy).not.toMatch(/C:\\Users\\/); } finally { rmSync(dir, { recursive: true, force: true }); } diff --git a/gitnexus/test/unit/lbug-native-check.test.ts b/gitnexus/test/unit/lbug-native-check.test.ts index 20883a77e..2bb9958a5 100644 --- a/gitnexus/test/unit/lbug-native-check.test.ts +++ b/gitnexus/test/unit/lbug-native-check.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import os from 'os'; import path from 'path'; import fs from 'fs/promises'; -import { checkLbugNative } from '../../src/core/lbug/native-check.js'; +import { checkLbugNative, glibcTooOldMessage } from '../../src/core/lbug/native-check.js'; describe('checkLbugNative', () => { it('returns ok:true when the real @ladybugdb/core binary is present', () => { @@ -20,6 +20,7 @@ describe('checkLbugNative', () => { const result = checkLbugNative(tmpDir); expect(result.ok).toBe(false); + expect(result.kind).toBe('binary_missing'); expect(result.message).toContain('missing'); expect(result.message).toContain('install.js'); expect(result.message).toContain('trustedDependencies'); @@ -43,6 +44,8 @@ describe('checkLbugNative', () => { const result = checkLbugNative(tmpDir); expect(result.ok).toBe(false); + // Present but unloadable — doctor must not call this "missing" (#2672). + expect(result.kind).toBe('load_failed'); expect(result.message).toContain('failed to load'); expect(result.message).toContain('install.js'); } finally { @@ -73,6 +76,94 @@ describe('checkLbugNative', () => { } }); + it('does not prescribe a reinstall when the host glibc is too old (#2672)', async () => { + // The generic failure text ("truncated / ABI mismatch / re-run install.js") + // is actively wrong for this class: the reinstall re-downloads the identical + // prebuilt binary and fails identically. Guard the whole assembled message, + // not just the helper, so the branch stays wired into checkLbugNative. + const message = glibcTooOldMessage( + "Error: /lib64/libc.so.6: version `GLIBC_2.34' not found (required by " + + '/usr/lib/node_modules/gitnexus/node_modules/@ladybugdb/core/lbugjs.node)', + ); + + expect(message).toContain('glibc 2.34 or newer'); + expect(message).toContain('will NOT help'); + expect(message).not.toContain('install.js'); + expect(message).not.toContain('trustedDependencies'); + expect(message).not.toContain('--allow-build'); + }); + + // POSIX-only: the fake "node" is a shebang script, which Windows cannot exec. + // The real Windows path has no glibc, so this class cannot occur there anyway. + it.skipIf(process.platform === 'win32')( + 'checkLbugNative routes a glibc load failure to that message, not the reinstall text', + async () => { + // Proves the branch is WIRED, not merely present: the probe child is + // replaced by a script that emits the loader error the #2672 reporter saw. + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'lbug-check-glibc-')); + const originalExecPath = process.execPath; + try { + await fs.writeFile(path.join(tmpDir, 'lbugjs.node'), Buffer.from('content-irrelevant')); + await fs.writeFile(path.join(tmpDir, 'install.js'), ''); + const fakeNode = path.join(tmpDir, 'fake-node'); + await fs.writeFile( + fakeNode, + '#!/bin/sh\n' + + 'echo "Error: /lib64/libc.so.6: version \\`GLIBC_2.34\' not found' + + ' (required by /x/@ladybugdb/core/lbugjs.node)" >&2\n' + + 'exit 1\n', + ); + await fs.chmod(fakeNode, 0o755); + process.execPath = fakeNode; + + const result = checkLbugNative(tmpDir); + + expect(result.ok).toBe(false); + expect(result.kind).toBe('load_failed'); + expect(result.message).toContain('glibc 2.34 or newer'); + expect(result.message).toContain('will NOT help'); + expect(result.message).not.toContain('install.js'); + } finally { + process.execPath = originalExecPath; + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }, + ); + + it('names this host glibc alongside the required one', () => { + const message = glibcTooOldMessage("version `GLIBC_2.34' not found"); + // Never pin the runner's own glibc — assert the line is populated either way. + expect(message).toMatch(/this host: (glibc \d+\.\d+|glibc version could not be determined)/); + }); + + const requiredVersionCases: ReadonlyArray = [ + ['reports the single required version', "version `GLIBC_2.34' not found", '2.34'], + [ + 'reports the highest of several required versions', + "version `GLIBC_2.29' not found\nversion `GLIBC_2.34' not found", + '2.34', + ], + [ + 'orders versions numerically, not lexically', + "version `GLIBC_2.9' not found\nversion `GLIBC_2.34' not found", + '2.34', + ], + ]; + + it.each(requiredVersionCases)('%s', (_name, stderr, expected) => { + expect(glibcTooOldMessage(stderr)).toContain(`glibc ${expected} or newer`); + }); + + const nonGlibcCases: ReadonlyArray = [ + ['an unrelated loader error', 'Error: invalid ELF header'], + ['empty stderr', ''], + ['a GLIBC token with no not-found line', 'linked against GLIBC_2.34 successfully'], + ]; + + it.each(nonGlibcCases)('returns null for %s, leaving the generic message', (_name, stderr) => { + expect(glibcTooOldMessage(stderr)).toBeNull(); + }); + it('returns ok:true when the load probe cannot be spawned (inconclusive, not a broken binary)', async () => { // The binary is present, but the child probe cannot launch — a sandbox that // forbids subprocesses, or a non-Node execPath. We could not test the binary,