Merge branch 'main' into copilot/migrate-rust-to-scope-resolution

This commit is contained in:
Gergő Magyar 2026-05-16 16:20:42 +01:00 committed by GitHub
commit e4f04e964c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 996 additions and 8 deletions

View file

@ -1,5 +1,5 @@
import fs from 'fs/promises';
import { createReadStream, createWriteStream } from 'fs';
import { createReadStream, createWriteStream, constants as fsConstants } from 'fs';
import { createInterface } from 'readline';
import { once } from 'events';
import { finished } from 'stream/promises';
@ -201,6 +201,163 @@ export const isReadOnlyDbError = (err: unknown): boolean => {
return /read-only database/i.test(msg);
};
const isMissingFileError = (err: unknown): boolean => {
const errno = err as NodeJS.ErrnoException;
return errno?.code === 'ENOENT';
};
const extractErrnoCode = (err: unknown): string | undefined => {
const errno = err as NodeJS.ErrnoException;
return errno?.code;
};
const MAX_LOGGED_ERROR_MESSAGE_LENGTH = 160;
const summarizeError = (err: unknown): string =>
(err instanceof Error ? err.message : String(err)).slice(0, MAX_LOGGED_ERROR_MESSAGE_LENGTH);
// ---------------------------------------------------------------------------
// Cross-process init lock
//
// Prevents a TOCTOU race in orphan sidecar cleanup: between checking that
// the main DB file is missing and unlinking sidecars, another process could
// create a fresh DB. The lock file (`${dbPath}.init.lock`) is created with
// O_CREAT | O_EXCL (atomic create-or-fail) and contains the owning PID +
// timestamp so stale locks from crashed processes can be reclaimed.
// ---------------------------------------------------------------------------
/** Maximum age (ms) before an init lock is considered stale. */
const INIT_LOCK_STALE_MS = 30_000;
/** Maximum attempts to acquire the init lock before giving up. */
const INIT_LOCK_MAX_ATTEMPTS = 6;
/** Delay between lock-acquisition retries (ms). */
const INIT_LOCK_RETRY_DELAY_MS = 500;
const initLockPath = (dbPath: string): string => `${dbPath}.init.lock`;
/**
* Returns true when the process identified by `pid` is still running.
* Uses `process.kill(pid, 0)` which sends signal 0 (a no-op probe)
* it throws ESRCH when the process does not exist.
*/
const isProcessAlive = (pid: number): boolean => {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
};
/**
* Try to break a stale lock whose owning process has exited.
* Returns `true` if the stale lock was removed (caller should retry acquire).
* Returns `false` if the lock is still valid (another live process owns it).
*/
const tryBreakStaleLock = async (lockPath: string): Promise<boolean> => {
try {
const content = await fs.readFile(lockPath, 'utf-8');
const parsed = JSON.parse(content) as { pid?: number; ts?: number };
// If the owning process is still alive AND the lock is not stale, don't break.
if (typeof parsed.pid === 'number' && isProcessAlive(parsed.pid)) {
// Even a live process's lock can be stale if it's been held too long
// (e.g. the process is hung). Check the timestamp.
if (typeof parsed.ts === 'number' && Date.now() - parsed.ts < INIT_LOCK_STALE_MS) {
return false;
}
}
// PID is gone or lock exceeded INIT_LOCK_STALE_MS — reclaim it.
await fs.unlink(lockPath);
logger.warn(
`GitNexus: removed stale init lock (pid=${parsed.pid ?? '?'}, age=${typeof parsed.ts === 'number' ? `${Date.now() - parsed.ts}ms` : '?'})`,
);
return true;
} catch (err) {
// Lock file disappeared between our read and unlink, or is unreadable.
// Either way, let the caller retry the acquire.
if (isMissingFileError(err)) return true;
// Permission error or corrupt content — log and let caller retry.
const code = extractErrnoCode(err);
logger.warn(
`GitNexus: unable to inspect init lock (${code ?? 'UNKNOWN'}): ${summarizeError(err)}`,
);
return false;
}
};
/**
* Acquire a cross-process init lock for `dbPath`.
* Uses `O_CREAT | O_EXCL` for atomic create-or-fail semantics.
*
* Returns a release function that removes the lock file. The release
* function is idempotent and safe to call even if the lock was already
* cleaned up externally.
*
* Throws if the lock cannot be acquired after `INIT_LOCK_MAX_ATTEMPTS`.
*/
export const acquireInitLock = async (dbPath: string): Promise<() => Promise<void>> => {
const lockPath = initLockPath(dbPath);
const payload = JSON.stringify({ pid: process.pid, ts: Date.now() });
// Ensure the parent directory exists before creating the lock file.
// On a fresh repo the `.gitnexus/` directory may not exist yet, and
// fs.open with O_CREAT | O_EXCL would fail with ENOENT.
await fs.mkdir(path.dirname(lockPath), { recursive: true });
for (let attempt = 1; attempt <= INIT_LOCK_MAX_ATTEMPTS; attempt++) {
try {
const handle = await fs.open(
lockPath,
fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY,
);
await handle.writeFile(payload);
await handle.close();
// Return the idempotent release function
return async () => {
try {
await fs.unlink(lockPath);
} catch (err) {
if (!isMissingFileError(err)) {
const code = extractErrnoCode(err);
logger.warn(
`GitNexus: failed to release init lock (${code ?? 'UNKNOWN'}): ${summarizeError(err)}`,
);
}
}
};
} catch (err) {
if ((err as NodeJS.ErrnoException)?.code !== 'EEXIST') {
throw err; // Unexpected error — propagate immediately
}
// Lock file exists — check if it's stale
const broken = await tryBreakStaleLock(lockPath);
if (broken && attempt < INIT_LOCK_MAX_ATTEMPTS) {
continue; // Stale lock removed — retry immediately
}
if (attempt === INIT_LOCK_MAX_ATTEMPTS) {
throw new Error(
`GitNexus: unable to acquire init lock after ${INIT_LOCK_MAX_ATTEMPTS} attempts — ` +
`another gitnexus process may be initializing the same database (${lockPath})`,
);
}
// Live process holds the lock — wait and retry
await new Promise((resolve) => setTimeout(resolve, INIT_LOCK_RETRY_DELAY_MS));
}
}
// Unreachable — loop always throws or returns
throw new Error('GitNexus: init lock acquisition failed unexpectedly');
};
/** Exported for testing — returns the lock file path for a given dbPath. */
export const _initLockPathForTest = initLockPath;
const runWithSessionLock = async <T>(operation: () => Promise<T>): Promise<T> => {
const previous = sessionLock;
let release: (() => void) | null = null;
@ -364,17 +521,64 @@ const doInitLbug = async (dbPath: string) => {
await fs.rm(dbPath, { recursive: true, force: true });
}
// If it's a file, assume it's an existing LadybugDB database - LadybugDB will open it
} catch {
} catch (err) {
if (!isMissingFileError(err)) {
throw err;
}
// Path doesn't exist, which is what LadybugDB wants for a new database
}
// Ensure parent directory exists
const parentDir = path.dirname(dbPath);
await fs.mkdir(parentDir, { recursive: true });
// ---------------------------------------------------------------------------
// Cross-process critical section: acquire init lock, clean orphan sidecars,
// and open the database. The lock prevents a TOCTOU race where another
// process could create a fresh DB between our access() check and the
// unlink() of stale sidecars.
// ---------------------------------------------------------------------------
const releaseInitLock = await acquireInitLock(dbPath);
try {
// Crash-recovery cleanup: if the main DB file is missing, stale sidecars
// from an interrupted run can block fresh opens indefinitely.
try {
await fs.access(dbPath);
} catch (err) {
if (isMissingFileError(err)) {
// `.shadow` is documented by LadybugDB checkpointing and `.wal.checkpoint`
// was observed in the #1618 crash loop that motivated this recovery path.
const orphanSidecars = [`${dbPath}.shadow`, `${dbPath}.wal.checkpoint`];
for (const sidecar of orphanSidecars) {
try {
await fs.unlink(sidecar);
logger.warn(
`GitNexus: removed orphan sidecar ${path.basename(sidecar)} (no main DB file present)`,
);
} catch (err) {
if (isMissingFileError(err)) {
continue;
}
const code = extractErrnoCode(err);
logger.warn(
`GitNexus: failed to remove orphan sidecar ${path.basename(sidecar)} (${code ?? 'UNKNOWN'}) while main DB file is missing; LadybugDB open may still fail: ${summarizeError(err)}`,
);
}
}
} else {
const code = extractErrnoCode(err);
logger.warn(
`GitNexus: unable to verify main DB file before orphan sidecar cleanup (${code ?? 'UNKNOWN'}); skipping cleanup: ${summarizeError(err)}`,
);
}
}
const opened = await openLbugConnection(lbug, dbPath);
db = opened.db;
conn = opened.conn;
// Ensure parent directory exists
const parentDir = path.dirname(dbPath);
await fs.mkdir(parentDir, { recursive: true });
const opened = await openLbugConnection(lbug, dbPath);
db = opened.db;
conn = opened.conn;
} finally {
await releaseInitLock();
}
for (const schemaQuery of SCHEMA_QUERIES) {
try {

View file

@ -0,0 +1,330 @@
/**
* Integration test: orphan sidecar recovery in doInitLbug.
*
* Exercises the real `initLbug` `doInitLbug` path against a native
* LadybugDB instance. Creates actual orphan `.shadow` and
* `.wal.checkpoint` files on disk (without a main DB file) and confirms
* that `initLbug` cleans them up and opens a fresh database successfully.
*
* This complements the unit-level mocked coverage in
* `lbug-checkpoint-lifecycle.test.ts` with a real-filesystem,
* real-LadybugDB integration proof required by DoD §2.7.
*/
import fs from 'fs/promises';
import path from 'path';
import { describe, it, expect } from 'vitest';
import { createTempDir } from '../helpers/test-db.js';
/**
* LadybugDB 0.16.0 has a known Windows-only regression: `Database.close()`
* does not release the underlying file lock until the process exits, so any
* `closeLbug()` followed by `initLbug(samePath)` in the same process raises
* Win32 Error 33. Skip reopen-dependent tests on Windows.
*/
const itLbugReopen = process.platform === 'win32' ? it.skip : it;
describe('orphan sidecar recovery — native integration', () => {
itLbugReopen(
'initLbug recovers when both .shadow and .wal.checkpoint orphan sidecars are present without a main DB file',
async () => {
const tmp = await createTempDir('gitnexus-lbug-orphan-');
const dbPath = path.join(tmp.dbPath, 'lbug');
const shadowPath = `${dbPath}.shadow`;
const walCheckpointPath = `${dbPath}.wal.checkpoint`;
try {
// Simulate crash-recovery state: orphan sidecars without main DB file
await fs.writeFile(shadowPath, 'stale-shadow-data');
await fs.writeFile(walCheckpointPath, 'stale-wal-checkpoint-data');
// Confirm precondition: main DB file does NOT exist, sidecars DO
await expect(fs.access(dbPath)).rejects.toThrow();
await expect(fs.access(shadowPath)).resolves.toBeUndefined();
await expect(fs.access(walCheckpointPath)).resolves.toBeUndefined();
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
// initLbug should clean up orphan sidecars and open a fresh DB
await adapter.initLbug(dbPath);
// Verify the database is functional — execute a simple query
const rows = await adapter.executeQuery('RETURN 1 AS result');
expect(rows).toEqual([{ result: 1 }]);
// Verify orphan sidecars were removed
await expect(fs.access(shadowPath)).rejects.toThrow();
await expect(fs.access(walCheckpointPath)).rejects.toThrow();
await adapter.closeLbug();
} finally {
await tmp.cleanup();
}
},
);
itLbugReopen(
'initLbug recovers when only .shadow orphan sidecar is present (partial crash state)',
async () => {
const tmp = await createTempDir('gitnexus-lbug-orphan-');
const dbPath = path.join(tmp.dbPath, 'lbug');
const shadowPath = `${dbPath}.shadow`;
const walCheckpointPath = `${dbPath}.wal.checkpoint`;
try {
// Only .shadow present — partial crash state
await fs.writeFile(shadowPath, 'stale-shadow-data');
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
await adapter.initLbug(dbPath);
const rows = await adapter.executeQuery('RETURN 42 AS answer');
expect(rows).toEqual([{ answer: 42 }]);
// .shadow cleaned, .wal.checkpoint was never present
await expect(fs.access(shadowPath)).rejects.toThrow();
await expect(fs.access(walCheckpointPath)).rejects.toThrow();
await adapter.closeLbug();
} finally {
await tmp.cleanup();
}
},
);
itLbugReopen('initLbug succeeds on a clean path with no orphan sidecars (baseline)', async () => {
const tmp = await createTempDir('gitnexus-lbug-orphan-');
const dbPath = path.join(tmp.dbPath, 'lbug');
try {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
await adapter.initLbug(dbPath);
const rows = await adapter.executeQuery('RETURN 1 AS ok');
expect(rows).toEqual([{ ok: 1 }]);
await adapter.closeLbug();
} finally {
await tmp.cleanup();
}
});
itLbugReopen(
'initLbug does not attempt orphan cleanup when the main DB file exists',
async () => {
const tmp = await createTempDir('gitnexus-lbug-orphan-');
const dbPath = path.join(tmp.dbPath, 'lbug');
// Place a marker file with a non-sidecar extension next to the DB path.
// Our cleanup only targets `.shadow` and `.wal.checkpoint` and only when
// the main DB is missing. We verify the DB opens normally and the marker
// remains — proving that init did not perform broad sibling file cleanup.
const markerPath = `${dbPath}.test-marker`;
try {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
// Create a real DB file by initializing normally
await adapter.initLbug(dbPath);
await adapter.closeLbug();
// Plant marker file next to the existing DB
await fs.writeFile(markerPath, 'should-survive');
// Re-init: main DB exists, so orphan cleanup should NOT fire
await adapter.initLbug(dbPath);
const rows = await adapter.executeQuery('RETURN 1 AS ok');
expect(rows).toEqual([{ ok: 1 }]);
// Marker file survives — no broad cleanup happened
const content = await fs.readFile(markerPath, 'utf-8');
expect(content).toBe('should-survive');
await adapter.closeLbug();
} finally {
// Clean up marker file — best-effort; may already be absent
await fs.unlink(markerPath).catch(() => {
/* test cleanup only */
});
await tmp.cleanup();
}
},
);
});
// ---------------------------------------------------------------------------
// Init lock — cross-process ownership contract
// ---------------------------------------------------------------------------
describe('init lock — single-process ownership contract', () => {
itLbugReopen('acquireInitLock succeeds when parent directory does not exist yet', async () => {
const tmp = await createTempDir('gitnexus-lbug-orphan-');
// Use a nested path whose parent directory does NOT exist
const dbPath = path.join(tmp.dbPath, 'nonexistent-subdir', 'lbug');
const lockPath = `${dbPath}.init.lock`;
try {
// Precondition: parent directory must not exist
await expect(fs.access(path.dirname(dbPath))).rejects.toThrow();
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
const release = await adapter.acquireInitLock(dbPath);
// Lock file should exist — parent dir was created automatically
const content = await fs.readFile(lockPath, 'utf-8');
const parsed = JSON.parse(content);
expect(parsed.pid).toBe(process.pid);
await release();
// Lock file gone after release
await expect(fs.access(lockPath)).rejects.toThrow();
} finally {
await tmp.cleanup();
}
});
itLbugReopen('acquireInitLock creates and releases lock file atomically', async () => {
const tmp = await createTempDir('gitnexus-lbug-orphan-');
const dbPath = path.join(tmp.dbPath, 'lbug');
const lockPath = `${dbPath}.init.lock`;
try {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
const release = await adapter.acquireInitLock(dbPath);
// Lock file should exist while held
const content = await fs.readFile(lockPath, 'utf-8');
const parsed = JSON.parse(content);
expect(parsed.pid).toBe(process.pid);
expect(typeof parsed.ts).toBe('number');
// Release the lock
await release();
// Lock file should be gone after release
await expect(fs.access(lockPath)).rejects.toThrow();
} finally {
await tmp.cleanup();
}
});
itLbugReopen('acquireInitLock blocks concurrent acquire from same process', async () => {
const tmp = await createTempDir('gitnexus-lbug-orphan-');
const dbPath = path.join(tmp.dbPath, 'lbug');
try {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
const release1 = await adapter.acquireInitLock(dbPath);
// Second acquire should fail because the lock is held by this (alive) process.
// The lock retry budget is small enough that this completes quickly.
await expect(adapter.acquireInitLock(dbPath)).rejects.toThrow(/unable to acquire init lock/);
await release1();
} finally {
await tmp.cleanup();
}
});
itLbugReopen('acquireInitLock reclaims stale lock from dead process', async () => {
const tmp = await createTempDir('gitnexus-lbug-orphan-');
const dbPath = path.join(tmp.dbPath, 'lbug');
const lockPath = `${dbPath}.init.lock`;
try {
// PID far above any realistic range — guaranteed not running on any OS.
const DEAD_PROCESS_PID = 2_000_000_000;
await fs.writeFile(
lockPath,
JSON.stringify({ pid: DEAD_PROCESS_PID, ts: Date.now() - 60_000 }),
);
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
// Should break the stale lock and acquire successfully
const release = await adapter.acquireInitLock(dbPath);
// Verify we own the lock now
const content = await fs.readFile(lockPath, 'utf-8');
const parsed = JSON.parse(content);
expect(parsed.pid).toBe(process.pid);
await release();
} finally {
await tmp.cleanup();
}
});
itLbugReopen('release is idempotent — calling twice does not throw', async () => {
const tmp = await createTempDir('gitnexus-lbug-orphan-');
const dbPath = path.join(tmp.dbPath, 'lbug');
try {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
const release = await adapter.acquireInitLock(dbPath);
await release();
// Second release — lock file already gone, should not throw
await release();
} finally {
await tmp.cleanup();
}
});
itLbugReopen(
'initLbug cleans up lock file after successful init with orphan sidecars',
async () => {
const tmp = await createTempDir('gitnexus-lbug-orphan-');
const dbPath = path.join(tmp.dbPath, 'lbug');
const lockPath = `${dbPath}.init.lock`;
try {
// Plant orphan sidecars
await fs.writeFile(`${dbPath}.shadow`, 'stale-shadow');
await fs.writeFile(`${dbPath}.wal.checkpoint`, 'stale-wal');
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
await adapter.initLbug(dbPath);
// Lock file should be released after init completes
await expect(fs.access(lockPath)).rejects.toThrow();
// DB should be functional
const rows = await adapter.executeQuery('RETURN 1 AS ok');
expect(rows).toEqual([{ ok: 1 }]);
await adapter.closeLbug();
} finally {
await tmp.cleanup();
}
},
);
itLbugReopen('initLbug cleans up lock file even when DB open fails', async () => {
const tmp = await createTempDir('gitnexus-lbug-orphan-');
// Use an invalid path that will cause LadybugDB to fail
const dbPath = path.join(tmp.dbPath, 'nonexistent-subdir', 'deep', 'lbug');
const lockPath = `${dbPath}.init.lock`;
try {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
// initLbug should fail (parent dir structure may cause issues), but
// we primarily care that the lock file is cleaned up even on failure.
// Use a try/catch since the DB open may or may not fail depending
// on how mkdir works.
try {
await adapter.initLbug(dbPath);
await adapter.closeLbug();
} catch {
// Expected — DB open can fail for various reasons
}
// Lock file should always be released, even on failure
await expect(fs.access(lockPath)).rejects.toThrow();
} finally {
await tmp.cleanup();
}
});
});

View file

@ -1,13 +1,461 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
const makeErrnoError = <TCode extends string>(code: TCode, message: string) =>
Object.assign(new Error(message), { code });
/** Stub file handle returned by mocked `fs.open` for the init lock. */
const makeOpenMock = () =>
vi.fn(async () => ({
writeFile: vi.fn(async () => {}),
close: vi.fn(async () => {}),
}));
/** Standard `fs/promises` mock for tests that only need doInitLbug to succeed. */
const mockFsForInit = (dbPath: string) => {
const ENOENT_ERROR = makeErrnoError(
'ENOENT',
`ENOENT: no such file or directory, lstat '${dbPath}'`,
);
vi.doMock('fs/promises', () => ({
default: {
lstat: vi.fn(async () => {
throw ENOENT_ERROR;
}),
access: vi.fn(async () => {
throw ENOENT_ERROR;
}),
unlink: vi.fn(async () => {}),
mkdir: vi.fn(async () => {}),
open: makeOpenMock(),
},
}));
};
describe('lbug adapter CHECKPOINT lifecycle', () => {
afterEach(() => {
vi.doUnmock('fs/promises');
vi.doUnmock('../../src/core/lbug/lbug-config.js');
vi.doUnmock('../../src/core/lbug/extension-loader.js');
vi.doUnmock('../../src/core/logger.js');
vi.resetModules();
vi.clearAllMocks();
});
it('removes orphan sidecars when main DB file is missing before opening LadybugDB', async () => {
vi.resetModules();
const dbPath = '/tmp/gitnexus-lbug-orphan-sidecar/lbug';
const ENOENT_ERROR = makeErrnoError(
'ENOENT',
`ENOENT: no such file or directory, access '${dbPath}'`,
);
const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() };
const conn = {
query: vi.fn(async () => queryResult),
close: vi.fn(async () => {}),
};
const db = { close: vi.fn(async () => {}) };
const unlinkMock = vi.fn(async () => {});
const accessMock = vi.fn(async () => {
throw ENOENT_ERROR;
});
vi.doMock('fs/promises', () => ({
default: {
lstat: vi.fn(async () => {
throw ENOENT_ERROR;
}),
access: accessMock,
unlink: unlinkMock,
mkdir: vi.fn(async () => {}),
open: makeOpenMock(),
},
}));
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
openLbugConnection: vi.fn(async () => ({ db, conn })),
closeLbugConnection: vi.fn(async () => {}),
isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')),
isOpenRetryExhausted: vi.fn(() => false),
waitForWindowsHandleRelease: vi.fn(async () => true),
}));
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
extensionManager: {
ensure: vi.fn(async () => true),
getCapabilities: vi.fn(() => []),
reset: vi.fn(),
},
}));
const warnMock = vi.fn();
vi.doMock('../../src/core/logger.js', () => ({
logger: {
warn: warnMock,
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
await adapter.initLbug(dbPath);
expect(accessMock).toHaveBeenCalledWith(dbPath);
// Unlink called for: .shadow sidecar, .wal.checkpoint sidecar, init lock release
expect(unlinkMock).toHaveBeenCalledWith(`${dbPath}.shadow`);
expect(unlinkMock).toHaveBeenCalledWith(`${dbPath}.wal.checkpoint`);
expect(unlinkMock).toHaveBeenCalledWith(`${dbPath}.init.lock`);
expect(warnMock).toHaveBeenCalledTimes(2);
expect(warnMock).toHaveBeenCalledWith(
'GitNexus: removed orphan sidecar lbug.shadow (no main DB file present)',
);
expect(warnMock).toHaveBeenCalledWith(
'GitNexus: removed orphan sidecar lbug.wal.checkpoint (no main DB file present)',
);
await adapter.closeLbug();
});
it('skips orphan sidecar cleanup when db access fails with non-ENOENT errors', async () => {
vi.resetModules();
const dbPath = '/tmp/gitnexus-lbug-orphan-sidecar-eacces/lbug';
const ENOENT_ERROR = makeErrnoError(
'ENOENT',
`ENOENT: no such file or directory, access '${dbPath}'`,
);
const EACCES_ERROR = makeErrnoError('EACCES', `EACCES: permission denied, access '${dbPath}'`);
const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() };
const conn = {
query: vi.fn(async () => queryResult),
close: vi.fn(async () => {}),
};
const db = { close: vi.fn(async () => {}) };
const accessMock = vi.fn(async () => {
throw EACCES_ERROR;
});
const unlinkMock = vi.fn(async () => {});
vi.doMock('fs/promises', () => ({
default: {
lstat: vi.fn(async () => {
throw ENOENT_ERROR;
}),
access: accessMock,
unlink: unlinkMock,
mkdir: vi.fn(async () => {}),
open: makeOpenMock(),
},
}));
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
openLbugConnection: vi.fn(async () => ({ db, conn })),
closeLbugConnection: vi.fn(async () => {}),
isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')),
isOpenRetryExhausted: vi.fn(() => false),
waitForWindowsHandleRelease: vi.fn(async () => true),
}));
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
extensionManager: {
ensure: vi.fn(async () => true),
getCapabilities: vi.fn(() => []),
reset: vi.fn(),
},
}));
const warnMock = vi.fn();
vi.doMock('../../src/core/logger.js', () => ({
logger: {
warn: warnMock,
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
await adapter.initLbug(dbPath);
expect(accessMock).toHaveBeenCalledWith(dbPath);
// Only the init lock release calls unlink — sidecar cleanup was skipped
expect(unlinkMock).toHaveBeenCalledTimes(1);
expect(unlinkMock).toHaveBeenCalledWith(`${dbPath}.init.lock`);
expect(warnMock).toHaveBeenCalledTimes(1);
expect(warnMock.mock.calls[0]?.[0]).toContain(
'GitNexus: unable to verify main DB file before orphan sidecar cleanup (EACCES); skipping cleanup:',
);
await adapter.closeLbug();
});
it('does not remove sidecars when main db file is present', async () => {
vi.resetModules();
const dbPath = '/tmp/gitnexus-lbug-present/lbug';
const ENOENT_ERROR = makeErrnoError(
'ENOENT',
`ENOENT: no such file or directory, access '${dbPath}'`,
);
const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() };
const conn = {
query: vi.fn(async () => queryResult),
close: vi.fn(async () => {}),
};
const db = { close: vi.fn(async () => {}) };
const accessMock = vi.fn(async () => {});
const unlinkMock = vi.fn(async () => {});
vi.doMock('fs/promises', () => ({
default: {
lstat: vi.fn(async () => {
throw ENOENT_ERROR;
}),
access: accessMock,
unlink: unlinkMock,
mkdir: vi.fn(async () => {}),
open: makeOpenMock(),
},
}));
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
openLbugConnection: vi.fn(async () => ({ db, conn })),
closeLbugConnection: vi.fn(async () => {}),
isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')),
isOpenRetryExhausted: vi.fn(() => false),
waitForWindowsHandleRelease: vi.fn(async () => true),
}));
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
extensionManager: {
ensure: vi.fn(async () => true),
getCapabilities: vi.fn(() => []),
reset: vi.fn(),
},
}));
const warnMock = vi.fn();
vi.doMock('../../src/core/logger.js', () => ({
logger: {
warn: warnMock,
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
await adapter.initLbug(dbPath);
expect(accessMock).toHaveBeenCalledWith(dbPath);
// Only the init lock release calls unlink — no sidecar cleanup needed
expect(unlinkMock).toHaveBeenCalledTimes(1);
expect(unlinkMock).toHaveBeenCalledWith(`${dbPath}.init.lock`);
expect(warnMock).not.toHaveBeenCalled();
await adapter.closeLbug();
});
it.each([
{
code: 'EPERM',
message: 'operation not permitted',
dbPath: '/tmp/gitnexus-lbug-lstat-eperm/lbug',
},
{
code: 'EACCES',
message: 'permission denied',
dbPath: '/tmp/gitnexus-lbug-lstat-eacces/lbug',
},
])('throws when db path lstat fails with non-ENOENT %s', async ({ code, message, dbPath }) => {
vi.resetModules();
const LSTAT_ERROR = makeErrnoError(code, `${code}: ${message}, lstat '${dbPath}'`);
const accessMock = vi.fn(async () => {});
const unlinkMock = vi.fn(async () => {});
vi.doMock('fs/promises', () => ({
default: {
lstat: vi.fn(async () => {
throw LSTAT_ERROR;
}),
access: accessMock,
unlink: unlinkMock,
mkdir: vi.fn(async () => {}),
open: makeOpenMock(),
},
}));
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
openLbugConnection: vi.fn(async () => {
throw new Error('should not be called');
}),
closeLbugConnection: vi.fn(async () => {}),
isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')),
isOpenRetryExhausted: vi.fn(() => false),
waitForWindowsHandleRelease: vi.fn(async () => true),
}));
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
extensionManager: {
ensure: vi.fn(async () => true),
getCapabilities: vi.fn(() => []),
reset: vi.fn(),
},
}));
vi.doMock('../../src/core/logger.js', () => ({
logger: {
warn: vi.fn(),
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
await expect(adapter.initLbug(dbPath)).rejects.toThrow(new RegExp(message, 'i'));
expect(accessMock).not.toHaveBeenCalled();
expect(unlinkMock).not.toHaveBeenCalled();
});
it('handles partial orphan sidecar state and removes only present sidecars', async () => {
vi.resetModules();
const dbPath = '/tmp/gitnexus-lbug-partial-sidecar/lbug';
const ENOENT_ERROR = makeErrnoError(
'ENOENT',
`ENOENT: no such file or directory, access '${dbPath}'`,
);
const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() };
const conn = {
query: vi.fn(async () => queryResult),
close: vi.fn(async () => {}),
};
const db = { close: vi.fn(async () => {}) };
const accessMock = vi.fn(async () => {
throw ENOENT_ERROR;
});
const unlinkMock = vi.fn(async (target: string) => {
if (target.endsWith('.shadow')) throw ENOENT_ERROR;
});
vi.doMock('fs/promises', () => ({
default: {
lstat: vi.fn(async () => {
throw ENOENT_ERROR;
}),
access: accessMock,
unlink: unlinkMock,
mkdir: vi.fn(async () => {}),
open: makeOpenMock(),
},
}));
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
openLbugConnection: vi.fn(async () => ({ db, conn })),
closeLbugConnection: vi.fn(async () => {}),
isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')),
isOpenRetryExhausted: vi.fn(() => false),
waitForWindowsHandleRelease: vi.fn(async () => true),
}));
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
extensionManager: {
ensure: vi.fn(async () => true),
getCapabilities: vi.fn(() => []),
reset: vi.fn(),
},
}));
const warnMock = vi.fn();
vi.doMock('../../src/core/logger.js', () => ({
logger: {
warn: warnMock,
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
await adapter.initLbug(dbPath);
expect(unlinkMock).toHaveBeenCalledWith(`${dbPath}.shadow`);
expect(unlinkMock).toHaveBeenCalledWith(`${dbPath}.wal.checkpoint`);
expect(warnMock).toHaveBeenCalledTimes(1);
expect(warnMock).toHaveBeenCalledWith(
'GitNexus: removed orphan sidecar lbug.wal.checkpoint (no main DB file present)',
);
await adapter.closeLbug();
});
it('proceeds to openLbugConnection when orphan sidecar unlink fails', async () => {
vi.resetModules();
const dbPath = '/tmp/gitnexus-lbug-sidecar-unlink-fail/lbug';
const ENOENT_ERROR = makeErrnoError(
'ENOENT',
`ENOENT: no such file or directory, access '${dbPath}'`,
);
const EPERM_ERROR = makeErrnoError(
'EPERM',
`EPERM: operation not permitted, unlink '${dbPath}.shadow'`,
);
const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() };
const conn = {
query: vi.fn(async () => queryResult),
close: vi.fn(async () => {}),
};
const db = { close: vi.fn(async () => {}) };
const accessMock = vi.fn(async () => {
throw ENOENT_ERROR;
});
const unlinkMock = vi.fn(async () => {
throw EPERM_ERROR;
});
vi.doMock('fs/promises', () => ({
default: {
lstat: vi.fn(async () => {
throw ENOENT_ERROR;
}),
access: accessMock,
unlink: unlinkMock,
mkdir: vi.fn(async () => {}),
open: makeOpenMock(),
},
}));
const openLbugConnectionMock = vi.fn(async () => ({ db, conn }));
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
openLbugConnection: openLbugConnectionMock,
closeLbugConnection: vi.fn(async () => {}),
isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')),
isOpenRetryExhausted: vi.fn(() => false),
waitForWindowsHandleRelease: vi.fn(async () => true),
}));
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
extensionManager: {
ensure: vi.fn(async () => true),
getCapabilities: vi.fn(() => []),
reset: vi.fn(),
},
}));
const warnMock = vi.fn();
vi.doMock('../../src/core/logger.js', () => ({
logger: {
warn: warnMock,
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
await adapter.initLbug(dbPath);
expect(unlinkMock).toHaveBeenCalledTimes(3);
expect(warnMock).toHaveBeenCalledTimes(3);
expect(warnMock.mock.calls[0]?.[0]).toContain(
'GitNexus: failed to remove orphan sidecar lbug.shadow (EPERM) while main DB file is missing; LadybugDB open may still fail:',
);
expect(warnMock.mock.calls[1]?.[0]).toContain(
'GitNexus: failed to remove orphan sidecar lbug.wal.checkpoint (EPERM) while main DB file is missing; LadybugDB open may still fail:',
);
expect(warnMock.mock.calls[2]?.[0]).toContain('GitNexus: failed to release init lock (EPERM)');
expect(openLbugConnectionMock).toHaveBeenCalledWith(expect.anything(), dbPath);
await adapter.closeLbug();
});
it('drains and closes CHECKPOINT result before closing connection and database handles', async () => {
vi.resetModules();
@ -43,6 +491,7 @@ describe('lbug adapter CHECKPOINT lifecycle', () => {
}),
};
mockFsForInit('/tmp/gitnexus-lbug-checkpoint-lifecycle/lbug');
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
openLbugConnection: vi.fn(async () => ({ db, conn })),
closeLbugConnection: vi.fn(async () => {}),
@ -104,6 +553,7 @@ describe('lbug adapter CHECKPOINT lifecycle', () => {
close: vi.fn(async () => {}),
};
mockFsForInit('/tmp/gitnexus-lbug-query-lifecycle/lbug');
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
openLbugConnection: vi.fn(async () => ({ db, conn })),
closeLbugConnection: vi.fn(async () => {}),
@ -158,6 +608,7 @@ describe('lbug adapter CHECKPOINT lifecycle', () => {
close: vi.fn(async () => {}),
};
mockFsForInit('/tmp/gitnexus-lbug-sync-close-lifecycle/lbug');
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
openLbugConnection: vi.fn(async () => ({ db, conn })),
closeLbugConnection: vi.fn(async () => {}),
@ -223,6 +674,7 @@ describe('lbug adapter CHECKPOINT lifecycle', () => {
close: vi.fn(async () => {}),
};
mockFsForInit('/tmp/gitnexus-lbug-array-error-lifecycle/lbug');
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
openLbugConnection: vi.fn(async () => ({ db, conn })),
closeLbugConnection: vi.fn(async () => {}),
@ -303,6 +755,7 @@ describe('lbug adapter CHECKPOINT lifecycle', () => {
close: vi.fn(async () => {}),
};
mockFsForInit('/tmp/gitnexus-lbug-stream-lifecycle/lbug');
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
openLbugConnection: vi.fn(async () => ({ db, conn })),
closeLbugConnection: vi.fn(async () => {}),
@ -383,6 +836,7 @@ describe('lbug adapter CHECKPOINT lifecycle', () => {
close: vi.fn(async () => {}),
};
mockFsForInit('/tmp/gitnexus-lbug-stream-error-lifecycle/lbug');
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
openLbugConnection: vi.fn(async () => ({ db, conn })),
closeLbugConnection: vi.fn(async () => {}),