GitNexus/gitnexus/test/unit/drop-fts-index-error-classification.test.ts
Gergő Magyar 21a52af1d4
fix(lbug): ship FTS per-platform and recover in-place native aborts (#3274)
* fix(lbug): pin Ladybug core so Dependabot cannot ship a skewed FTS artifact

The extension version is a separate upstream constant. Ignore daily core bumps and fail the pairing gate when the committed manifest does not name the installed core.

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

* fix(lbug): make doctor and CI FTS gates resolve the packaged artifact

Doctor and the REQUIRE_FTS file gates still treated an empty ~/.lbdb as
unavailable, which would turn three CI jobs red once analyze stops
installing into that tree.

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

* fix(lbug): name native-abort and tuple-missing so analyze cannot mis-advise

The CLI summary's trailing else treated every unknown skip reason as a
missing extension. New crash and platform causes must get their own
remedies, not a network-install hint.

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

* fix(lbug): delete the dead read-path FTS index create

ensureFTSIndex had no production callers and swallowed read-only
CREATE_FTS_INDEX failures, which hid the only signal that a reader
tried to write.

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

* fix(lbug): vendor per-platform FTS artifacts so analyze needs no host install

Keyword search depended on a CDN fetch into ~/.lbdb. Shipping the five
published tuples inside the package makes air-gapped and ignore-scripts
installs load the same artifact the publish gate checksums.

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

* fix(lbug): load the packaged FTS artifact before any network install

Analyze still required a CDN fetch into ~/.lbdb even when the package
already shipped the file. FTS now path-loads the vendored tuple first
and records source labels so a later truncated home copy cannot steal
the diagnosis.

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

* fix(lbug): diagnose a core/extension version skew instead of a missing runtime

A structurally valid FTS artifact whose path version disagrees with the
packaged pin must name both versions, not prescribe VC++ or OpenSSL.

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

* fix(lbug): stamp an FTS phase so repair stays usable after an in-place abort

A native CREATE_FTS_INDEX abort leaves no skip reason; the next run infers
it from the dirty flag, and --repair-fts must not treat that phase as a
half-written graph.

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

* fix(lbug): park an in-place FTS crash WAL without wiping the graph

An FTS abort after a successful checkpoint must reopen the live index on
macOS, Windows, and Linux. Staging never parks the live WAL; readers keep
today's large-WAL refusal.

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

* fix(lbug): refuse read-only opens of an FTS-poisoned WAL

MCP and serve cannot repair a leftover in-place abort. Fail before the
native open and name --repair-fts, on macOS, Windows, and Linux.

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

* fix(lbug): name a vendor-neutral Windows OpenSSL prerequisite

OQ1 is unanswered here so GitNexus does not ship OpenSSL DLLs. Windows
FTS now asks for a system OpenSSL 3 runtime instead of Git Bash PATH.

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

* test(lbug): inject the FTS vendor root and redact it on HTTP and MCP

Path-loaded artifacts no longer vary with HOME. Tests pass an injected
vendor tree and assert search warnings never leak a filesystem path.

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

* docs(lbug): document load-only as the global FTS install default

Analyze still overrides to auto. Packaged per-platform artifacts load
before any network install on macOS, Windows, and Linux.

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

* docs(lbug): format the FTS install-policy README table

Prettier does not run on Markdown in pre-commit, so the U10 table wrap
needs its own formatting commit.

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

* fix(lbug): skip FTS CREATE after a persisted native abort

A recovered analyze run was retrying CREATE_FTS_INDEX from skipReason
alone. Keep that skip until --repair-fts, fail closed on unsupported
tuples, and honor the checkpoint warrant for park/repair.

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

* fix(lbug): honor checkpoint flushed warrant and align FTS tests with packaged vendor

A no-op CHECKPOINT must not satisfy the FTS park warrant, and CI still asserted HOME-only FTS isolation after analyze started path-LOADing the packaged artifact.

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

* test(lbug): accept a nonempty incremental write set in the #2790 recovery check

FTS-phase recovery can incremental-add files (changed=0, added=1). That is not the #2790 empty-diff wipe skip.

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

* fix(lbug): compare FTS home versions to the core pin and tighten the publish filename gate

Ladybug's ~/.lbdb/extension directory is the runtime/core version; treating it as the artifact version false-diagnosed skew. The publish guard now rejects a path-escaping filename the same way the fetch script does.

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

* test(lbug): seed FTS e2e fixtures from the packaged vendor artifact

A machine with no ~/.lbdb copy should still run the vendor-survivorship cases; the seed no longer depends on HOME or a network install.

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

* Address PR review feedback (#3274)

Keep in-place FTS abort evidence after persist so a second CREATE abort
cannot fail-open readers, and close the CLI, loader, embed, and e2e gaps
the review called out.

Note: full npm test hit Ladybug worker-pool startup failures under memory
pressure; tsc and 180 targeted unit tests passed.

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

* Address PR review feedback (#3274)

Run the vendored-path symlink guard on the OS matrix, put e2e HOME
fixtures on Ladybug's real extension layout, pin the embed crash-WAL
gate before the writable open, and let analyze writers park through
missing-shadow recovery.

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

* fix(lbug): keep --repair-fts CI green after vendored-first FTS

Never-installed warning fixtures must not inspect a packaged vendor binary, and a failed dirty restamp must not abort an otherwise successful --repair-fts run.

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

* test(cli): give the #1169 analyze e2e the same 90s Windows budget as its sibling

The first #1169 persist-meta case was still on a 60s spawn/it budget and was killed banner-only on windows-latest after the FTS warning fixture no longer failed the shard first.

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

* test(ci): reweight Windows shards after the FTS e2e grew

Vendored-first HOME fixtures pushed fts-extension-e2e to ~6 minutes on windows-latest, so the old 146s weight packed it with skills-e2e and blew the 20-minute watchdog.

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

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 08:52:24 +01:00

261 lines
11 KiB
TypeScript

/**
* #2589: `dropFTSIndex` must tolerate only benign "nothing to drop"
* `DROP_FTS_INDEX` failures and rethrow everything else — previously it
* swallowed every error unconditionally, which could mask a genuinely
* corrupted FTS index across analyze runs.
*
* `isBenignDropFtsIndexError` is pure string logic (no native connection
* needed), so the classification itself is unit-tested directly, including
* against the exact reported #2589 error text — a native repro of that
* specific engine failure was not achieved during investigation, but the
* classifier's behavior for it is still provable from the message alone.
*/
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { isBenignDropFtsIndexError, dropFTSIndex } from '../../src/core/lbug/lbug-adapter.js';
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
import { createTempDir } from '../helpers/test-db.js';
import {
resetExtensionState,
resolveAnalyzeInstallPolicy,
} from '../../src/core/lbug/extension-loader.js';
describe('isBenignDropFtsIndexError', () => {
it('is true for the FTS-extension/function-not-registered catalog error (probe-verified text)', () => {
expect(
isBenignDropFtsIndexError(
"Catalog exception: function DROP_FTS_INDEX is not defined. This function exists in the FTS extension. You can install and load the extension by running 'INSTALL FTS; LOAD EXTENSION FTS;'.",
),
).toBe(true);
});
it('is true for the index-never-created binder error (probe-verified against the real dropFTSIndex path)', () => {
expect(
isBenignDropFtsIndexError(
"Binder exception: Table File doesn't have an index with name file_fts.",
),
).toBe(true);
});
it('is false for the #2589 runtime inconsistency error (must surface, not be swallowed)', () => {
expect(
isBenignDropFtsIndexError(
"Runtime exception: FTS index 'file_fts' is inconsistent: term 'wiki' is missing during delete.",
),
).toBe(false);
});
it('is false for an unrelated failure', () => {
expect(isBenignDropFtsIndexError('Connection Exception: database is closed')).toBe(false);
});
it('is false for a genuine failure that merely mentions "Binder exception" mid-message (anchored, not a bare substring match)', () => {
expect(
isBenignDropFtsIndexError(
'Runtime exception: internal state corrupted while processing Binder exception: recovery failed.',
),
).toBe(false);
});
});
withTestLbugDB('drop-fts-index-benign-cases', (handle) => {
describe('dropFTSIndex end-to-end benign cases (#2589)', () => {
it('resolves cleanly when the named index was never created', async () => {
void handle;
const { executeQuery } = await import('../../src/core/lbug/lbug-adapter.js');
await executeQuery(
`CREATE NODE TABLE IF NOT EXISTS DropProbe (id STRING PRIMARY KEY, content STRING)`,
);
await expect(dropFTSIndex('DropProbe', 'drop_probe_never_created')).resolves.toBeUndefined();
}, 120_000);
});
});
/**
* #2841: "function DROP_FTS_INDEX is not defined" is benign only when there is
* nothing to drop. When the index is LIVE, that same message means the drop did
* not happen and cannot happen — every later insert/delete against the table
* dies at bind time with an engine error that never mentions FTS. The message
* classifier stays pure (it cannot know whether an index exists); the liveness
* question is settled inside `dropFTSIndex`, on the error path only.
*/
describe('dropFTSIndex with the FTS extension unloaded (#2841)', () => {
const TABLE = 'DropProbe2841';
const LIVE_INDEX = 'drop_probe_2841_live';
let ftsAvailable = true;
/**
* Mutable holder rather than `probe: … | undefined` + `probe!.dbPath`: the
* non-null assertion was a lint warning, and the alternative (a runtime guard
* in every consumer) would put branching into the test path. `beforeAll`
* overwrites both fields; if it never ran, `initLbug('')` fails loudly, which
* is the same outcome the assertion had.
*/
const probe: { dbPath: string; cleanup: () => Promise<void> } = {
dbPath: '',
cleanup: async () => {},
};
beforeAll(async () => {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
const tmp = await createTempDir('gitnexus-2841-drop-probe-');
probe.dbPath = tmp.dbPath;
probe.cleanup = tmp.cleanup;
await adapter.initLbug(probe.dbPath);
try {
ftsAvailable = await adapter.loadFTSExtension(undefined, {
policy: resolveAnalyzeInstallPolicy(),
});
if (ftsAvailable) {
await adapter.executeQuery(
`CREATE NODE TABLE IF NOT EXISTS ${TABLE} (id STRING PRIMARY KEY, name STRING, content STRING)`,
);
// A real, live FTS index — the state that makes the catalog error fatal.
await adapter.createFTSIndex(TABLE, LIVE_INDEX, ['name', 'content']);
}
} finally {
await adapter.closeLbug();
}
}, 120_000);
afterAll(async () => {
await probe.cleanup();
});
beforeEach((ctx) => {
if (!ftsAvailable) {
if (process.env.GITNEXUS_REQUIRE_FTS === '1') {
throw new Error(
'GITNEXUS_REQUIRE_FTS=1 but the FTS extension is unavailable — cannot verify the #2841 drop guard.',
);
}
console.warn(
'[drop-fts-index-error-classification] Skipping the #2841 cases — FTS extension unavailable.',
);
ctx.skip();
}
});
/** Reopen the seeded DB with the extension forced unloadable for this connection. */
const withUnloadedFts = async (run: () => Promise<void>): Promise<void> => {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
const previousPolicy = process.env.GITNEXUS_LBUG_EXTENSION_INSTALL;
process.env.GITNEXUS_LBUG_EXTENSION_INSTALL = 'never';
resetExtensionState();
try {
await adapter.initLbug(probe.dbPath);
await run();
} finally {
await adapter.closeLbug();
if (previousPolicy === undefined) delete process.env.GITNEXUS_LBUG_EXTENSION_INSTALL;
else process.env.GITNEXUS_LBUG_EXTENSION_INSTALL = previousPolicy;
resetExtensionState();
}
};
it('rejects, naming FTS and both remedies, when the index is live and the extension is not loaded', async () => {
await withUnloadedFts(async () => {
await expect(dropFTSIndex(TABLE, LIVE_INDEX)).rejects.toThrow(
/FTS index '.*' on table .* exists but the LadybugDB FTS extension is not loaded/,
);
// Both remedies, asserted as stable SUBSTRINGS — the load-side half is
// generated by `diagnoseExtensionLoad` now, so pinning a whole sentence
// would break on any classifier wording change. `never` is not a load
// failure the classifier recognizes, so the diagnosis here is `unknown`
// and the doctor pointer is the load-side remedy the user gets.
await expect(dropFTSIndex(TABLE, LIVE_INDEX)).rejects.toThrow(/gitnexus doctor/);
await expect(dropFTSIndex(TABLE, LIVE_INDEX)).rejects.toThrow(/analyze --force/);
});
}, 120_000);
it('still resolves when the extension is not loaded and the index does not exist', async () => {
await withUnloadedFts(async () => {
await expect(dropFTSIndex(TABLE, 'drop_probe_2841_absent')).resolves.toBeUndefined();
});
}, 120_000);
/**
* #2374/#2375 redaction contract. LadybugDB's own load error names the
* extension FILE, and that `reason` is what the classifier is fed — so the
* one thing this surface must never do is pass it through into the message a
* user sees. Until now that held only by code inspection.
*
* A bare "policy is never" run cannot prove it: that reason carries no path,
* so the assertion would be vacuous. So the LOAD is forced to fail with a
* real, path-bearing LadybugDB error instead, which drives the classifier
* down its `missing_dependency` branch — the branch whose remedy is derived
* from the very text that contains the path.
*/
const FORCED_EXTENSION_PATH = '/nonexistent-gitnexus-2841/fts.lbug_extension';
const FORCED_LOAD_FAILURE =
`Failed to load library: ${FORCED_EXTENSION_PATH} which is needed by extension: fts; ` +
'libcrypto.so.3: cannot open shared object file: No such file or directory';
it('routes the load-side remedy through the classifier without leaking the path LadybugDB named', async () => {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
const { default: lbug } = await import('@ladybugdb/core');
const previousPolicy = process.env.GITNEXUS_LBUG_EXTENSION_INSTALL;
process.env.GITNEXUS_LBUG_EXTENSION_INSTALL = 'load-only';
resetExtensionState();
const originalQuery = lbug.Connection.prototype.query;
// Installed BEFORE initLbug so FTS can never load on this connection —
// otherwise the DROP would succeed and there would be no message to inspect.
const spy = vi.spyOn(lbug.Connection.prototype, 'query').mockImplementation(function (
this: unknown,
sql: string,
...rest: unknown[]
) {
if (
/^\s*LOAD\s+EXTENSION\b/i.test(sql) &&
(/\bfts\b/i.test(sql) || /libfts\.lbug_extension/i.test(sql))
) {
return Promise.reject(new Error(FORCED_LOAD_FAILURE));
}
return originalQuery.call(this, sql, ...rest);
});
try {
await adapter.initLbug(probe.dbPath);
// Record the capability from the forced failure, so `dropFTSIndex` reads
// a real cached diagnosis rather than re-deriving one from nothing.
await expect(adapter.loadFTSExtension(undefined, { policy: 'load-only' })).resolves.toBe(
false,
);
const rejection: unknown = await dropFTSIndex(TABLE, LIVE_INDEX).catch((e: unknown) => e);
expect(rejection).toBeInstanceOf(Error);
const message = String(rejection);
// The classifier really fired on this reason (POSIX missing-dependency),
// so the redaction assertion below is not vacuous…
expect(message).toContain('Reinstalling the extension will NOT help');
// …and neither the path nor any other filesystem path reached the user.
expect(message).not.toContain(FORCED_EXTENSION_PATH);
expect(message).not.toMatch(/(?:[A-Za-z]:\\|\/)[^\s'"]+/);
} finally {
spy.mockRestore();
await adapter.closeLbug();
if (previousPolicy === undefined) delete process.env.GITNEXUS_LBUG_EXTENSION_INSTALL;
else process.env.GITNEXUS_LBUG_EXTENSION_INSTALL = previousPolicy;
resetExtensionState();
}
}, 120_000);
});
describe('dropFTSIndex fallback diagnosis wiring', () => {
it('extracts the inspect path before resolveFtsVersionPair', () => {
const source = readFileSync(
path.join(__dirname, '..', '..', 'src', 'core', 'lbug', 'lbug-adapter.ts'),
'utf8',
);
const drop = source.slice(source.indexOf('export const dropFTSIndex'));
const inspectAt = drop.indexOf('extractExtensionPath(ftsCapability?.reason)');
const diagnoseAt = drop.indexOf('diagnoseExtensionLoad(');
const pairAt = drop.indexOf('resolveFtsVersionPair(inspectPath)');
expect(inspectAt).toBeGreaterThan(-1);
expect(diagnoseAt).toBeGreaterThan(inspectAt);
expect(pairAt).toBeGreaterThan(diagnoseAt);
expect(drop).not.toContain('resolveFtsVersionPair(undefined)');
});
});