diff --git a/gitnexus/src/core/lbug/native-check.ts b/gitnexus/src/core/lbug/native-check.ts index a9971a5e9..c874ed98e 100644 --- a/gitnexus/src/core/lbug/native-check.ts +++ b/gitnexus/src/core/lbug/native-check.ts @@ -1,6 +1,11 @@ import fs from 'fs'; import path from 'path'; import { createRequire } from 'node:module'; +import { spawnSync, type SpawnSyncReturns } from 'node:child_process'; + +/** Cap the out-of-process native load probe so a hung filesystem cannot wedge a + * CLI startup gate (same bounding rationale as the extension probe below). */ +const NATIVE_LOAD_PROBE_TIMEOUT_MS = 15_000; export interface NativeCheckResult { ok: boolean; @@ -59,35 +64,73 @@ export function checkLbugNative(overridePkgDir?: string): NativeCheckResult { }; } - try { - const _require = createRequire(import.meta.url); - _require(binaryPath); - } catch (err: unknown) { - const nativeError = err instanceof Error ? err.message : String(err); - return { - ok: false, - binaryPath, - message: [ - 'LadybugDB native binary (lbugjs.node) exists but failed to load:', - ` ${nativeError}`, - '', - 'This can happen with a truncated file, ABI mismatch, or wrong-platform binary.', - '', - 'To repair:', - ` node ${path.join(pkgDir, 'install.js')}`, - '', - 'If install scripts were skipped (pnpm dlx / pnpx / ignore-scripts):', - ' pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter \\', - ' dlx gitnexus@latest serve', - ' pnpm add -g --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter gitnexus', - '', - 'If using bun, add to package.json and reinstall:', - ' "trustedDependencies": ["@ladybugdb/core"]', - ].join('\n'), - }; + // Validate loadability in a THROWAWAY CHILD PROCESS, not in-process. A merely + // truncated or corrupted .node (valid header, missing pages) does not throw a + // catchable error — it SIGBUSes the dynamic loader mid-dlopen, which would take + // the whole CLI down with a raw exit 135 and no guidance (#2441). Loading it in + // a child lets us observe that crash (a non-zero exit or a kill signal) and turn + // it into the same actionable failure as a clean load error. The child requires + // the binary by absolute path, exactly as the former in-process load did. + const probe = spawnSync(process.execPath, ['-e', 'require(process.argv[1])', binaryPath], { + encoding: 'utf8', + timeout: NATIVE_LOAD_PROBE_TIMEOUT_MS, + stdio: ['ignore', 'ignore', 'pipe'], + // Run as Node even if process.execPath is an Electron/embedder binary. + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, + }); + + // Only a child that actually RAN and failed proves the binary is bad. If the + // probe could not run at all — a spawn error or a timeout, e.g. a sandbox that + // forbids subprocesses or a non-Node execPath — we could not test the binary, + // so we stay out of the way and let the command's own load be the authority + // rather than condemn a healthy binary. (#2441 still holds: a genuinely broken + // binary loaded in-process later still exits non-zero.) + if (probe.error || probe.status === 0) { + return { ok: true, binaryPath }; } - return { ok: true, binaryPath }; + return { + ok: false, + binaryPath, + message: [ + 'LadybugDB native binary (lbugjs.node) exists but failed to load:', + ` ${describeNativeLoadFailure(probe)}`, + '', + 'This can happen with a truncated file, ABI mismatch, or wrong-platform binary.', + '', + 'To repair:', + ` node ${path.join(pkgDir, 'install.js')}`, + '', + 'If install scripts were skipped (pnpm dlx / pnpx / ignore-scripts):', + ' pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter \\', + ' dlx gitnexus@latest serve', + ' pnpm add -g --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter gitnexus', + '', + 'If using bun, add to package.json and reinstall:', + ' "trustedDependencies": ["@ladybugdb/core"]', + ].join('\n'), + }; +} + +/** + * Describe a child-observed native load failure. Reached only after a probe that + * actually ran and failed: a fatal signal (SIGBUS/SIGSEGV ⇒ truncated/corrupt + * binary), otherwise the child's own load error lifted from its stderr. + */ +function describeNativeLoadFailure(probe: SpawnSyncReturns): string { + if (probe.signal) { + return `crashed while loading (signal ${probe.signal}) — the binary is likely truncated or corrupted`; + } + const lines = (probe.stderr ?? '') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + const errorLine = lines.find((line) => /^\w*Error: /.test(line)); + return ( + errorLine?.replace(/^\w*Error:\s*/, '') ?? + lines.at(-1) ?? + `exited with code ${probe.status ?? 'unknown'}` + ); } export interface FtsProbeResult { diff --git a/gitnexus/test/unit/lazy-action.test.ts b/gitnexus/test/unit/lazy-action.test.ts index 9afe7bc27..973d728ca 100644 --- a/gitnexus/test/unit/lazy-action.test.ts +++ b/gitnexus/test/unit/lazy-action.test.ts @@ -90,4 +90,46 @@ describe('createAnalyzerLbugLazyAction', () => { expect(events).toEqual(['identity-module', 'receipt-captured', 'analyzer-module']); expect(run).toHaveBeenCalledWith(receipt, 'repo', { force: true }); }); + + it('sets exit code 1 and skips the analyzer import when native load fails', async () => { + // Regression guard for #2441: a LadybugDB native-load failure must fail + // closed — no analyzer import, no index write, non-zero exit — not the + // pre-fix "print help then exit 0" silent success. Mirrors the + // createLbugLazyAction failure test above for the analyze-only wrapper. + checkLbugNativeMock.mockReturnValueOnce({ + ok: false, + message: + 'LadybugDB native binary (lbugjs.node) exists but failed to load:\n' + ' dlopen failed', + }); + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + process.exitCode = undefined; + const run = vi.fn(async () => undefined); + const analyzerLoader = vi.fn(async () => ({ run })); + const identityLoader = vi.fn(async () => ({ + captureAnalyzerIdentityBeforeLoad: async (_url: string, loader: () => Promise) => { + const loaded = await loader(); + return { runnerIdentity: { schemaVersion: 4 }, loaded }; + }, + })); + const action = createAnalyzerLbugLazyAction( + identityLoader as never, + analyzerLoader, + 'run', + 'file:///fixture/dist/cli/index.js', + ); + + try { + await expect(action('repo', { force: true })).resolves.toBeUndefined(); + + expect(analyzerLoader).not.toHaveBeenCalled(); + expect(run).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('LadybugDB native binary (lbugjs.node) exists but failed to load:'), + ); + } finally { + stderrSpy.mockRestore(); + process.exitCode = undefined; + } + }); }); diff --git a/gitnexus/test/unit/lbug-native-check.test.ts b/gitnexus/test/unit/lbug-native-check.test.ts index c54b1635b..20883a77e 100644 --- a/gitnexus/test/unit/lbug-native-check.test.ts +++ b/gitnexus/test/unit/lbug-native-check.test.ts @@ -49,4 +49,49 @@ describe('checkLbugNative', () => { await fs.rm(tmpDir, { recursive: true, force: true }); } }); + + it('returns ok:false when lbugjs.node is truncated (loader crashes with a signal)', async () => { + // A partially written .node (valid header, missing pages) SIGBUSes dlopen — a + // signal, not a catchable throw. The out-of-process probe must observe the + // crash and report it, instead of the whole process dying with exit 135 (#2441). + const realPath = checkLbugNative().binaryPath; + expect(realPath).toBeDefined(); + const truncated = (await fs.readFile(realPath!)).subarray(0, 300_000); + + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'lbug-check-')); + try { + await fs.writeFile(path.join(tmpDir, 'install.js'), ''); + await fs.writeFile(path.join(tmpDir, 'lbugjs.node'), truncated); + + const result = checkLbugNative(tmpDir); + + expect(result.ok).toBe(false); + expect(result.message).toContain('failed to load'); + expect(result.message).toContain('install.js'); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + 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, + // so a healthy one must not be condemned; the command's own load stays + // authoritative. (Binary content is irrelevant here — the probe never runs.) + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'lbug-check-')); + 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'), ''); + process.execPath = path.join(tmpDir, 'definitely-not-node'); + + const result = checkLbugNative(tmpDir); + + expect(result.ok).toBe(true); + expect(result.message).toBeUndefined(); + } finally { + process.execPath = originalExecPath; + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); });