fix(cli): LadybugDB native-load failures fail closed, incl. truncated-binary SIGBUS (#2441) (#2651)

* test(cli): cover analyzer lazy-action native-load failure (#2441)

createAnalyzerLbugLazyAction — the wrapper the `analyze` command uses — had
only a happy-path test; its native-load-failure branch was untested, so a
regression could silently reintroduce #2441 (analyze exiting 0 after a
LadybugDB native load failure, writing no index while reporting success).

Add a failure-path test asserting that when checkLbugNative() reports the
binary cannot load, the analyzer module is NOT imported, process.exitCode is
set to 1, and the repair message is written to stderr. Mirrors the existing
createLbugLazyAction failure test.

Verified discriminating: the test fails ("expected undefined to be 1") when
the exitCode guard is removed from the analyzer branch, and passes with it
restored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): probe LadybugDB native load out-of-process so a truncated binary fails closed (#2441)

checkLbugNative() loaded lbugjs.node in-process to validate it. That catches
clean load failures (missing dylib, zero-byte, garbage -> "file too short"),
but a merely truncated/corrupted binary (valid header, missing pages) SIGBUSes
the dynamic loader mid-dlopen — a signal, not a catchable throw — taking the
whole CLI down with a raw exit 135 and no guidance.

Load the binary in a throwaway child process instead. Only a child that RAN and
failed (non-zero exit or a fatal signal) marks the binary bad; if the probe
itself could not run — a spawn error or timeout, e.g. a no-subprocess sandbox
or a non-Node execPath — the result is inconclusive and the command's own load
stays authoritative rather than condemning a healthy binary. The probe forces
ELECTRON_RUN_AS_NODE, removes the redundant in-process pre-load, and costs ~20ms.

Regression tests: truncated binary -> ok:false; unspawnable probe -> ok:true.

Verified: a 300KB-truncated native now exits 1 with the repair message
(previously exit 135 SIGBUS); zero-byte/garbage stay graceful; good native
still loads and indexes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergő Magyar 2026-07-23 11:59:56 +01:00 committed by GitHub
parent 4f59831324
commit 76f9f70183
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 157 additions and 27 deletions

View file

@ -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>): 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 {

View file

@ -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<unknown>) => {
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;
}
});
});

View file

@ -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 });
}
});
});