GitNexus/gitnexus/test/unit/lbug-extension-loader.test.ts
Gergő Magyar 3f0c74fea0
fix(deps): upgrade @ladybugdb/core to 0.16.0 to resolve native segfaults (#1235)
* fix(deps): upgrade @ladybugdb/core to 0.16.0 to resolve native segfaults

Resolves the SIGSEGV / access-violation (0xC0000005) / exit-139 crashes that
have been reported widely since 1.6.3. The native crashes originate in
@ladybugdb/core 0.15.x — primarily during FTS index creation, VECTOR
extension load, and concurrent query teardown — and are reproducible on
Linux, macOS and Windows. The maintainer-confirmed fix is to bump the
runtime to 0.16.0, which ships nodejs async + memory-management fixes,
extension ABI bump, and macOS Intel binaries.

Adopting 0.16.0 cleanly required three supporting changes; without them
the upgrade itself regresses other paths:

1. maxDBSize must be passed explicitly. 0.16.0 keeps the upstream JSDoc
   note that the default 0 is "introduced temporarily for now to get
   around with the default 8 TB mmap address space limit some
   environment". Constrained CI runners and laptops cannot reserve 8 TB
   and crash with "Buffer manager exception: Mmap for size
   8796093022208 failed." A new gitnexus/src/core/lbug/lbug-config.ts
   centralises a 16 GiB default (overridable via
   GITNEXUS_LBUG_MAX_DB_SIZE) and every Database() construction site
   now passes it.

2. enableCompression default flipped from false to true in 0.16.0. Every
   Database() call site is updated to pass false explicitly so existing
   GitNexus indexes keep the same wire format.

3. Bridge DB sidecar files (.wal, .shadow). 0.16.0 enforces a database-id
   check on .wal / .shadow sidecars and rejects opens whose sidecars
   belong to a different base name. writeBridge now (a) cleans the full
   sidecar set when removing the tmp slot, (b) renames .wal / .shadow
   alongside the main file during the atomic .tmp -> .lbug swap, and
   (c) wraps openBridgeDbReadOnly in a bounded retry on transient
   Win32-Error-33 lock errors. Eager db.init() / conn.init() forces the
   lazy native handle to surface lock contention at the retry site.

Known limitation (not a regression): on Windows the 0.16.0 native binary
does not release the OS file lock until the process exits, so the
close-then-reopen-same-process pattern raises Error 33 after the first
close. Production paths (analyze / serve / mcp each open the DB exactly
once per process) are unaffected, but eight tests that exercise the
pattern are guarded with a process.platform === 'win32' skip; CI's
Linux + macOS shards exercise them as before. Tracking upstream:
kuzudb/kuzu#3872 / #3883 / #4730.

Closes #1136 #1154 #1160 #1162 #1178 #1195 #1196 #1199 #1204 #1206
Refs #1209 (supersedes — Dependabot bump without the supporting fixes)

Made-with: Cursor

* fix(test): isolate LadybugDB native test state

Use per-suite LadybugDB databases in integration helpers so test forks do not reopen a database created by Vitest global setup, and centralize Windows-tolerant native temp cleanup for bridge tests.

* fix(lbug): avoid bridge existence reopen

Reuse the built LadybugDB config in the extension installer and avoid native close/reopen cycles when checking bridge existence on Windows.

Made-with: Cursor

* chore(docs): exclude local lbug plan

Keep the refactor planning note out of the PR while leaving the ignored local copy on disk.

Made-with: Cursor

* refactor(lbug): centralize database construction

Route LadybugDB opens through shared helpers so native constructor defaults stay consistent across core, pool, bridge, and extension install paths.

Made-with: Cursor

---------

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
2026-04-30 17:40:39 +01:00

257 lines
9.5 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import {
ExtensionManager,
getExtensionInstallChildProcessArgs,
getExtensionInstallTimeoutMs,
type ExtensionInstallResult,
} from '../../src/core/lbug/extension-loader.js';
const okInstall: ExtensionInstallResult = {
success: true,
timedOut: false,
message: 'installed',
};
const failedInstall: ExtensionInstallResult = {
success: false,
timedOut: false,
message: 'install failed',
};
const timedOutInstall: ExtensionInstallResult = {
success: false,
timedOut: true,
message: 'INSTALL vector timed out after 10ms',
};
const noopWarn = (): void => {};
describe('ExtensionManager — LOAD-first behavior', () => {
it('uses LOAD only and never invokes INSTALL when the extension is already available', async () => {
const installExtension = vi.fn();
const manager = new ExtensionManager({ policy: 'auto', installExtension });
const query = vi.fn().mockResolvedValue({});
await expect(manager.ensure(query, 'fts', 'FTS')).resolves.toBe(true);
expect(query.mock.calls.map(([sql]) => sql)).toEqual(['LOAD EXTENSION fts']);
expect(installExtension).not.toHaveBeenCalled();
expect(manager.getCapabilities()).toEqual([{ name: 'fts', loaded: true }]);
});
it('treats "already loaded" load errors as success', async () => {
const installExtension = vi.fn();
const manager = new ExtensionManager({ policy: 'auto', installExtension });
const query = vi.fn().mockRejectedValue(new Error('Extension fts is already loaded'));
await expect(manager.ensure(query, 'fts', 'FTS')).resolves.toBe(true);
expect(installExtension).not.toHaveBeenCalled();
});
});
describe('ExtensionManager — install policies', () => {
it('runs bounded out-of-process INSTALL and retries LOAD when policy=auto', async () => {
const installExtension = vi.fn().mockResolvedValue(okInstall);
const manager = new ExtensionManager({ policy: 'auto', installExtension });
const query = vi
.fn()
.mockRejectedValueOnce(new Error('Extension "fts" not found'))
.mockResolvedValueOnce({});
await expect(manager.ensure(query, 'fts', 'FTS', { installTimeoutMs: 1234 })).resolves.toBe(
true,
);
expect(installExtension).toHaveBeenCalledWith('fts', 1234);
expect(query.mock.calls.map(([sql]) => sql)).toEqual([
'LOAD EXTENSION fts',
'LOAD EXTENSION fts',
]);
expect(query.mock.calls.some(([sql]) => String(sql).startsWith('INSTALL '))).toBe(false);
});
it('skips INSTALL and warns when policy=load-only', async () => {
const installExtension = vi.fn();
const warn = vi.fn();
const manager = new ExtensionManager({ policy: 'load-only', installExtension, warn });
const query = vi.fn().mockRejectedValue(new Error('Extension "fts" not found'));
await expect(manager.ensure(query, 'fts', 'FTS')).resolves.toBe(false);
expect(installExtension).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledWith(expect.stringContaining('continuing without FTS features'));
expect(manager.getCapabilities()).toEqual([
{ name: 'fts', loaded: false, reason: expect.stringContaining('load-only') },
]);
});
it('short-circuits LOAD and INSTALL when policy=never', async () => {
const installExtension = vi.fn();
const warn = vi.fn();
const manager = new ExtensionManager({ policy: 'never', installExtension, warn });
const query = vi.fn();
await expect(manager.ensure(query, 'vector', 'VECTOR')).resolves.toBe(false);
expect(query).not.toHaveBeenCalled();
expect(installExtension).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('continuing without VECTOR features'),
);
});
it('per-call options override manager defaults', async () => {
const installExtension = vi.fn().mockResolvedValue(okInstall);
const manager = new ExtensionManager({
policy: 'auto',
installExtension,
warn: noopWarn,
});
const query = vi.fn().mockRejectedValue(new Error('Extension "vector" not found'));
await expect(manager.ensure(query, 'vector', 'VECTOR', { policy: 'load-only' })).resolves.toBe(
false,
);
expect(installExtension).not.toHaveBeenCalled();
});
it('returns false and warns when bounded install times out', async () => {
const installExtension = vi.fn().mockResolvedValue(timedOutInstall);
const warn = vi.fn();
const manager = new ExtensionManager({ policy: 'auto', installExtension, warn });
const query = vi.fn().mockRejectedValue(new Error('Extension "vector" not found'));
await expect(manager.ensure(query, 'vector', 'VECTOR', { installTimeoutMs: 10 })).resolves.toBe(
false,
);
expect(query.mock.calls.map(([sql]) => sql)).toEqual(['LOAD EXTENSION vector']);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('continuing without VECTOR features'),
);
});
});
describe('ExtensionManager — caching', () => {
it('caches install attempt outcome to avoid retrying within the same process', async () => {
const installExtension = vi.fn().mockResolvedValue(timedOutInstall);
const manager = new ExtensionManager({
policy: 'auto',
installExtension,
warn: noopWarn,
});
const query = vi.fn().mockRejectedValue(new Error('Extension "vector" not found'));
await expect(manager.ensure(query, 'vector', 'VECTOR')).resolves.toBe(false);
await expect(manager.ensure(query, 'vector', 'VECTOR')).resolves.toBe(false);
expect(installExtension).toHaveBeenCalledOnce();
});
it('reset() clears capability and install state so install is retried', async () => {
const installExtension = vi.fn().mockResolvedValue(failedInstall);
const manager = new ExtensionManager({
policy: 'auto',
installExtension,
warn: noopWarn,
});
const query = vi.fn().mockRejectedValue(new Error('Extension "fts" not found'));
await manager.ensure(query, 'fts', 'FTS');
expect(manager.getCapabilities()).toHaveLength(1);
manager.reset();
expect(manager.getCapabilities()).toEqual([]);
await manager.ensure(query, 'fts', 'FTS');
expect(installExtension).toHaveBeenCalledTimes(2);
});
});
describe('ExtensionManager — observability', () => {
it('exposes per-extension capability snapshot', async () => {
const manager = new ExtensionManager({ policy: 'load-only', warn: noopWarn });
const okQuery = vi.fn().mockResolvedValue({});
const failQuery = vi.fn().mockRejectedValue(new Error('Extension "vector" not found'));
await manager.ensure(okQuery, 'fts', 'FTS');
await manager.ensure(failQuery, 'vector', 'VECTOR');
expect(manager.getCapabilities()).toEqual([
{ name: 'fts', loaded: true },
{ name: 'vector', loaded: false, reason: expect.stringContaining('load-only') },
]);
});
it('warns at most once per (extension, reason) pair', async () => {
const installExtension = vi.fn();
const warn = vi.fn();
const manager = new ExtensionManager({ policy: 'load-only', installExtension, warn });
const query = vi.fn().mockRejectedValue(new Error('Extension "fts" not found'));
await manager.ensure(query, 'fts', 'FTS');
await manager.ensure(query, 'fts', 'FTS');
expect(warn).toHaveBeenCalledTimes(1);
});
});
describe('ExtensionManager — input validation', () => {
it('rejects extension names that are not bare identifiers', async () => {
const manager = new ExtensionManager({ policy: 'auto' });
const query = vi.fn();
await expect(manager.ensure(query, 'fts; DROP TABLE x', 'FTS')).rejects.toThrow(/Invalid/);
expect(query).not.toHaveBeenCalled();
});
});
describe('installDuckDbExtensionOutOfProcess child process', () => {
it('spawns the stable packaged installer script instead of inline -e code', () => {
const args = getExtensionInstallChildProcessArgs('fts');
expect(args).not.toContain('-e');
expect(args).not.toContain('--input-type=module');
expect(args[0]).toContain('scripts');
expect(args[0]).toContain('install-duckdb-extension.mjs');
expect(args[1]).toBe('fts');
expect(Number(args[2])).toBeGreaterThan(0);
});
it('passes the resolved LadybugDB max DB size to the installer child', () => {
expect(getExtensionInstallChildProcessArgs('fts', 1234).at(-1)).toBe('1234');
});
});
describe('getExtensionInstallTimeoutMs', () => {
it('reads a positive override from the environment', () => {
const original = process.env.GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS;
process.env.GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS = '42';
try {
expect(getExtensionInstallTimeoutMs()).toBe(42);
} finally {
if (original === undefined) {
delete process.env.GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS;
} else {
process.env.GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS = original;
}
}
});
it('falls back to the default when the env var is missing or invalid', () => {
const original = process.env.GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS;
delete process.env.GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS;
try {
expect(getExtensionInstallTimeoutMs()).toBe(15_000);
process.env.GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS = 'notanumber';
expect(getExtensionInstallTimeoutMs()).toBe(15_000);
process.env.GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS = '0';
expect(getExtensionInstallTimeoutMs()).toBe(15_000);
} finally {
if (original === undefined) {
delete process.env.GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS;
} else {
process.env.GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS = original;
}
}
});
});