GitNexus/gitnexus/test/unit/analyze-local-embedding-error.test.ts
Gergő Magyar ac9a4e9abd
Some checks are pending
Gitleaks / gitleaks (push) Waiting to run
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
fix(embeddings): keep ONNX off the install and analyze critical path (#3287)
* fix(embeddings): isolate local ONNX inference in a child_process sidecar

The analyze parent must not load onnxruntime-node. Fork a sidecar for
vectors only and reap it on worker exit; keep Ladybug writes in-process.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(embeddings): share the sidecar client across MCP, serve, and sync

Query hosts now use the core façade instead of a second in-process ONNX
embedder. Search skips an empty table, sync reaps beside closeLbug, and
ready means the stack is resolvable rather than a warm singleton.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(embeddings): refuse Intel Mac and unloadable prefix before npm heal

Analyze, sync, install, and the sidecar client now consult the platform
blocker before forking or downloading the optional stack. HTTP stays the
escape hatch; wasm is not treated as a rescue.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(embeddings): take the ONNX stack off default npm install

Pins live in gitnexusEmbeddingStack. embeddings install writes prefix
overrides before npm spawn. Leftover 1.6.12 package-first trees are residual.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(publish): drop grammar source from the published tarball

Every vendored grammar has 6/6 prebuilds, so files ships those plus
Leiden and FTS instead of parser.c. First ship stays above 80 MiB.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(embeddings): match MCP missing-stack warn to the R20 copy

Default install no longer calls the stack optional, so the once-per-backend
stderr assertion must look for the new lead line.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(review): bound sidecar death, cancel writes, and publish-file guards

Init-time native crashes no longer respawn a child on every query. Local
embedBatch honors AbortSignal after sidecar return, MCP query() surfaces
vector-lane degradation, disconnect always reaps, and the grammar prepack
guard checks files globs instead of on-disk prebuilds.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(embeddings): share runtime preflight and sidecar reap helpers

Analyze and embeddings-sync used the same blocker/prefix/install gate
with different error routing. One assessment keeps those paths aligned
without changing CLI vs thrown-error behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3287)

Keep a reaped sidecar from resetting its replacement, wait for dispose,
tighten the publish-files guard, and stop assuming a leftover ONNX tree in CI.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address remaining PR review feedback (#3287)

Clear the sidecar reap timeout, add init IPC slack, and isolate embeddings-sync tests from HTTP-mode env.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(embeddings): unstub globals after sidecar HTTP-mode tests

Keep a leaked fetch stub from failing assertions out of later tests in the same file.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(embeddings): pin sidecar success cases off darwin/x64

The runtime blocker reads the real process platform before the fork mock, so local-success tests must not inherit an Intel Mac host.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address remaining PR review feedback (#3287)

Keep vector degradation per query, treat leftover Intel-Mac stacks as not ready, and document that the CLI image no longer ships ONNX.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Simplify embedding sidecar shutdown and search hot paths

Drop redundant sidecar reaps and unused child helpers, and run FTS alongside semantic search.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address remaining PR review feedback (#3287)

Share HF attempt parsing with the sidecar init deadline, abort embed waits without killing the child, and restore last init options on recreate.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address remaining PR review feedback (#3287)

Treat sub-1 HF attempt env values as invalid, and drop leaked sidecar waiters when IPC send throws.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address remaining PR review feedback (#3287)

Keep sidecar init on a shared chain; each waiter can abort only its own wait.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ci): declare embedding-table existence probe as unordered LIMIT

The empty-table skip in semanticSearch is existence-only; declare it so the #2787 determinism guard stops failing coverage shard 3/3.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 12:11:47 +01:00

265 lines
12 KiB
TypeScript

/**
* Tests for the local-embedding-runtime blocker error path in the
* `analyzeCommand` CLI (#1515 / #1987 review follow-up).
*
* On macOS Intel (darwin/x64) `initEmbedder` throws a GitNexus-authored blocker
* before importing transformers.js. The analyze error handler must route that
* message to a clean `local-embedding-unsupported` message (exit 1) — not the
* generic MODULE_NOT_FOUND "installation may be corrupt" hint, and not the
* network-heuristic HF-download branch — so the explicit platform message wins.
*
* Mirrors the shape of analyze-wal-error.test.ts:
* - vi.mock the heavy dependencies so no real DB / git is touched
* - drive `analyzeCommand` with a mocked `runFullAnalysis` that rejects
* - assert on process.exitCode and the captured logger records
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getLocalEmbeddingRuntimeBlocker } from '../../src/core/embeddings/runtime-support.js';
import type { LoggerCapture } from '../../src/core/logger.js';
async function withCapturedLogger<T>(fn: (cap: LoggerCapture) => Promise<T>): Promise<T> {
const { _captureLogger } = await import('../../src/core/logger.js');
const cap = _captureLogger();
try {
return await fn(cap);
} finally {
cap.restore();
}
}
const runFullAnalysisMock = vi.fn();
// Controllable so the dual-match scenario can force the network heuristic to
// also match the blocker error and prove the blocker branch still wins.
const isHfDownloadFailureMock = vi.fn(() => false);
// Drive analyze's auto-install / capability gate (#2372). Defaults keep the
// existing tests on the happy path (package-sourced, loadable → gate skipped).
const resolveEmbeddingRuntimeMock = vi.fn<() => { source: string } | null>(() => ({
source: 'package',
}));
const isPrefixRuntimeLoadableMock = vi.fn(() => true);
const installEmbeddingRuntimeMock = vi.fn(async () => undefined);
vi.mock('../../src/core/embeddings/runtime-install.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../../src/core/embeddings/runtime-install.js')>()),
resolveEmbeddingRuntime: () => resolveEmbeddingRuntimeMock(),
isPrefixRuntimeLoadable: () => isPrefixRuntimeLoadableMock(),
installEmbeddingRuntime: (...args: unknown[]) => installEmbeddingRuntimeMock(...args),
getEmbeddingRuntimeDir: () => '/fake/embedding-runtime',
}));
vi.mock('../../src/core/run-analyze.js', () => ({
runFullAnalysis: runFullAnalysisMock,
}));
vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({
closeLbug: vi.fn(async () => undefined),
closeLbugBeforeExit: vi.fn(async () => undefined),
isLbugReady: vi.fn(() => false),
// Stub class for the CLI's `err instanceof LbugWipeError` branch (#2409,
// tri-review 4669518496 P2-4): instanceof must evaluate (not TypeError on
// an undefined binding) and correctly NOT claim this suite's errors. Same
// pattern as the RegistryNameCollisionError stub below.
LbugWipeError: class LbugWipeError extends Error {},
}));
vi.mock('../../src/storage/repo-manager.js', () => ({
getStoragePaths: vi.fn(() => ({ storagePath: '.gitnexus', lbugPath: '.gitnexus/lbug' })),
getGlobalRegistryPath: vi.fn(() => 'registry.json'),
RegistryNameCollisionError: class RegistryNameCollisionError extends Error {},
AnalysisNotFinalizedError: class AnalysisNotFinalizedError extends Error {},
assertAnalysisFinalized: vi.fn(async () => undefined),
}));
vi.mock('../../src/storage/git.js', () => ({
getGitRoot: vi.fn(() => '/repo'),
hasGitDir: vi.fn(() => true),
}));
vi.mock('../../src/core/ingestion/utils/max-file-size.js', () => ({
getMaxFileSizeBannerMessage: vi.fn(() => null),
}));
// analyze.ts imports isHfDownloadFailure from hf-env.js, which transitively
// pulls gitnexus-shared. Mock it to break the chain and to drive the
// blocker-vs-HF ordering test below. isLocalEmbeddingRuntimeBlockerMessage
// (runtime-support.js) is intentionally NOT mocked — the real branch must fire.
vi.mock('../../src/core/embeddings/hf-env.js', () => ({
isHfDownloadFailure: isHfDownloadFailureMock,
}));
const blockerMessage = getLocalEmbeddingRuntimeBlocker({
platform: 'darwin',
arch: 'x64',
}) as string;
describe('analyzeCommand local-embedding-runtime error handling', () => {
beforeEach(() => {
vi.resetModules();
runFullAnalysisMock.mockReset();
isHfDownloadFailureMock.mockReset();
isHfDownloadFailureMock.mockReturnValue(false);
resolveEmbeddingRuntimeMock.mockReset();
resolveEmbeddingRuntimeMock.mockReturnValue({ source: 'package' });
isPrefixRuntimeLoadableMock.mockReset();
isPrefixRuntimeLoadableMock.mockReturnValue(true);
installEmbeddingRuntimeMock.mockReset();
installEmbeddingRuntimeMock.mockResolvedValue(undefined);
process.exitCode = undefined;
// Ensure ensureHeap() short-circuits (heap already at target size)
process.env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ''} --max-old-space-size=8192`.trim();
});
it('routes the blocker to a clean local-embedding-unsupported message (exit 1)', async () => {
runFullAnalysisMock.mockRejectedValue(new Error(blockerMessage));
await withCapturedLogger(async (cap) => {
const { analyzeCommand } = await import('../../src/cli/analyze.js');
await analyzeCommand(undefined, { embeddings: true });
expect(process.exitCode).toBe(1);
const records = cap.records();
const blockerRecord = records.find((r) => r.recoveryHint === 'local-embedding-unsupported');
expect(blockerRecord).toBeDefined();
expect(typeof blockerRecord?.msg === 'string' && blockerRecord.msg).toMatch(/macOS Intel/);
});
});
it('does NOT fall through to the module-not-found "installation may be corrupt" hint', async () => {
runFullAnalysisMock.mockRejectedValue(new Error(blockerMessage));
await withCapturedLogger(async (cap) => {
const { analyzeCommand } = await import('../../src/cli/analyze.js');
await analyzeCommand(undefined, { embeddings: true });
const records = cap.records();
const corruptRecord = records.find(
(r) => typeof r.msg === 'string' && r.msg.includes('installation may be corrupt'),
);
expect(corruptRecord).toBeUndefined();
});
});
it('wins over the HF-download branch even when isHfDownloadFailure also matches (R4 ordering)', async () => {
// Force the network heuristic to claim the blocker error too. Because the
// blocker check is ordered before isHfDownloadFailure, the blocker branch
// must still win — this is the only scenario that falsifies a wrong order.
isHfDownloadFailureMock.mockReturnValue(true);
runFullAnalysisMock.mockRejectedValue(new Error(blockerMessage));
await withCapturedLogger(async (cap) => {
const { analyzeCommand } = await import('../../src/cli/analyze.js');
await analyzeCommand(undefined, { embeddings: true });
expect(process.exitCode).toBe(1);
const records = cap.records();
expect(records.some((r) => r.recoveryHint === 'local-embedding-unsupported')).toBe(true);
// The HF-download branch must NOT have fired.
expect(records.some((r) => r.recoveryHint === 'hf-endpoint-unreachable')).toBe(false);
});
});
it('does NOT route unrelated errors through the local-embedding branch', async () => {
runFullAnalysisMock.mockRejectedValue(
new Error('Some unexpected failure unrelated to embeddings'),
);
await withCapturedLogger(async (cap) => {
const { analyzeCommand } = await import('../../src/cli/analyze.js');
await analyzeCommand(undefined, { embeddings: true });
expect(process.exitCode).toBe(1);
const records = cap.records();
expect(records.some((r) => r.recoveryHint === 'local-embedding-unsupported')).toBe(false);
});
});
});
describe('analyzeCommand — prefix-runtime capability gate (#2372)', () => {
beforeEach(() => {
vi.resetModules();
runFullAnalysisMock.mockReset();
isHfDownloadFailureMock.mockReset().mockReturnValue(false);
resolveEmbeddingRuntimeMock.mockReset();
isPrefixRuntimeLoadableMock.mockReset();
installEmbeddingRuntimeMock.mockReset().mockResolvedValue(undefined);
process.exitCode = undefined;
process.env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ''} --max-old-space-size=8192`.trim();
});
it('does not spawn npm on darwin/x64 even when the stack is missing', async () => {
const orig = { platform: process.platform, arch: process.arch };
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
Object.defineProperty(process, 'arch', { value: 'x64', configurable: true });
resolveEmbeddingRuntimeMock.mockReturnValue(null);
isPrefixRuntimeLoadableMock.mockReturnValue(true);
try {
await withCapturedLogger(async (cap) => {
const { analyzeCommand } = await import('../../src/cli/analyze.js');
await analyzeCommand(undefined, { embeddings: true });
expect(process.exitCode).toBe(1);
expect(installEmbeddingRuntimeMock).not.toHaveBeenCalled();
expect(cap.records().some((r) => r.recoveryHint === 'local-embedding-unsupported')).toBe(
true,
);
});
} finally {
Object.defineProperty(process, 'platform', { value: orig.platform, configurable: true });
Object.defineProperty(process, 'arch', { value: orig.arch, configurable: true });
}
});
it('fails fast without installing when nothing is installed and the prefix is unloadable', async () => {
resolveEmbeddingRuntimeMock.mockReturnValue(null);
isPrefixRuntimeLoadableMock.mockReturnValue(false);
await withCapturedLogger(async (cap) => {
const { analyzeCommand } = await import('../../src/cli/analyze.js');
await analyzeCommand(undefined, { embeddings: true });
expect(process.exitCode).toBe(1);
expect(installEmbeddingRuntimeMock).not.toHaveBeenCalled();
const record = cap.records().find((r) => r.recoveryHint === 'local-embedding-stack-missing');
expect(typeof record?.msg === 'string' && record.msg).toMatch(/module\.registerHooks/);
});
});
it('fails fast on a resolved-but-unloadable prefix (the previously-uncaught state)', async () => {
resolveEmbeddingRuntimeMock.mockReturnValue({ source: 'runtime-prefix' });
isPrefixRuntimeLoadableMock.mockReturnValue(false);
await withCapturedLogger(async (cap) => {
const { analyzeCommand } = await import('../../src/cli/analyze.js');
await analyzeCommand(undefined, { embeddings: true });
expect(process.exitCode).toBe(1);
expect(installEmbeddingRuntimeMock).not.toHaveBeenCalled();
expect(cap.records().some((r) => r.recoveryHint === 'local-embedding-stack-missing')).toBe(
true,
);
});
});
it('installs with a shorter-than-default timeout when nothing is installed and the prefix is loadable', async () => {
resolveEmbeddingRuntimeMock.mockReturnValue(null);
isPrefixRuntimeLoadableMock.mockReturnValue(true);
// Reject afterwards so analyze bails right after the install, isolating the gate.
runFullAnalysisMock.mockRejectedValue(new Error('stop after install'));
await withCapturedLogger(async () => {
const { ANALYZE_EMBEDDING_INSTALL_TIMEOUT_MS } =
await import('../../src/core/embeddings/runtime-install.js');
const { analyzeCommand } = await import('../../src/cli/analyze.js');
await analyzeCommand(undefined, { embeddings: true });
expect(installEmbeddingRuntimeMock).toHaveBeenCalledTimes(1);
// analyze must pass the shorter deadline so a blackholed proxy can't stall
// the run for the 10-minute default.
const timeoutArg = installEmbeddingRuntimeMock.mock.calls[0][1] as number;
expect(timeoutArg).toBe(ANALYZE_EMBEDDING_INSTALL_TIMEOUT_MS);
});
});
});