Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 112 additions & 79 deletions gitnexus/src/core/lbug/lbug-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,7 @@ const ensureReadOnlyConnectionUsable = async (
dbPath: string,
handle: LbugConnectionHandle,
): Promise<LbugConnectionHandle> => {
let shadowReplayErr: unknown;
try {
await queryAndDrain(handle.conn, READ_ONLY_SHADOW_REPLAY_PROBE);
return handle;
Expand All @@ -537,11 +538,25 @@ const ensureReadOnlyConnectionUsable = async (
await closeLbugConnection(handle);
throw err;
}
shadowReplayErr = err;
}

await closeLbugConnection(handle);

const writable = await openLbugConnection(lbug, dbPath);
let writable: LbugConnectionHandle;
try {
writable = await openLbugConnection(lbug, dbPath);
} catch (openErr) {
const code = extractErrnoCode(openErr);
if (code === 'EROFS' || code === 'EACCES' || code === 'EPERM') {
throw new Error(
shadowSidecarRecoveryMessage(dbPath, shadowReplayErr) +
'\n The workspace appears to be read-only — mount it read-write to perform shadow replay recovery,' +
' or re-run `gitnexus analyze` on a writable filesystem to rebuild the index.',
);
}
throw openErr;
}
let missingShadowError: unknown;
try {
await queryAndDrain(writable.conn, READ_ONLY_SHADOW_REPLAY_PROBE);
Expand Down Expand Up @@ -691,94 +706,112 @@ const doInitLbug = async (dbPath: string, readOnly: boolean = false) => {
ensuredFTSIndexes.clear();
}

// LadybugDB stores the database as a single file (not a directory).
// If the path already exists, it must be a valid LadybugDB database file.
// Remove stale empty directories or files from older versions.
try {
const stat = await fs.lstat(dbPath);
if (stat.isSymbolicLink()) {
// Never follow symlinks — just remove the link itself
await fs.unlink(dbPath);
} else if (stat.isDirectory()) {
// Verify path is within expected storage directory before deleting
const realPath = await fs.realpath(dbPath);
const parentDir = path.dirname(dbPath);
const realParent = await fs.realpath(parentDir);
if (!realPath.startsWith(realParent + path.sep) && realPath !== realParent) {
throw new Error(
`Refusing to delete ${dbPath}: resolved path ${realPath} is outside storage directory`,
);
}
// Old-style directory database or empty leftover - remove it
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 (err) {
if (!isMissingFileError(err)) {
throw err;
}
// Path doesn't exist, which is what LadybugDB wants for a new database
}

// ---------------------------------------------------------------------------
// 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.
// Read-only fast path: skip all filesystem mutations (path cleanup, init
// lock, orphan sidecar removal, mkdir) so the open succeeds on read-only
// filesystems such as Docker `:ro` bind mounts. The init lock exists to
// prevent a TOCTOU race during DB *creation* — read-only opens never
// create databases and don't need the lock.
// ---------------------------------------------------------------------------
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.
if (readOnly) {
await preflightLbugSidecars(dbPath, {
mode: 'read-only',
logger,
allowQuarantine: false,
});

const opened = await openLbugConnection(lbug, dbPath, { readOnly: true });
const usable = await ensureReadOnlyConnectionUsable(dbPath, opened);
db = usable.db;
conn = usable.conn;
currentDbReadOnly = true;
} else {
// LadybugDB stores the database as a single file (not a directory).
// If the path already exists, it must be a valid LadybugDB database file.
// Remove stale empty directories or files from older versions.
try {
await fs.access(dbPath);
const stat = await fs.lstat(dbPath);
if (stat.isSymbolicLink()) {
// Never follow symlinks — just remove the link itself
await fs.unlink(dbPath);
} else if (stat.isDirectory()) {
// Verify path is within expected storage directory before deleting
const realPath = await fs.realpath(dbPath);
const parentDir = path.dirname(dbPath);
const realParent = await fs.realpath(parentDir);
if (!realPath.startsWith(realParent + path.sep) && realPath !== realParent) {
throw new Error(
`Refusing to delete ${dbPath}: resolved path ${realPath} is outside storage directory`,
);
}
// Old-style directory database or empty leftover - remove it
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 (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;
if (!isMissingFileError(err)) {
throw err;
}
// Path doesn't exist, which is what LadybugDB wants for a new database
}

// -------------------------------------------------------------------------
// 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)}`,
);
}
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)}`,
);
}
} else {
const code = extractErrnoCode(err);
logger.warn(
`GitNexus: unable to verify main DB file before orphan sidecar cleanup (${code ?? 'UNKNOWN'}); skipping cleanup: ${summarizeError(err)}`,
);
}
}

// Ensure parent directory exists
const parentDir = path.dirname(dbPath);
await fs.mkdir(parentDir, { recursive: true });
await preflightLbugSidecars(dbPath, {
mode: readOnly ? 'read-only' : 'write',
logger,
allowQuarantine: true,
});
// Ensure parent directory exists
const parentDir = path.dirname(dbPath);
await fs.mkdir(parentDir, { recursive: true });
await preflightLbugSidecars(dbPath, {
mode: 'write',
logger,
allowQuarantine: true,
});

const opened = readOnly
? await openLbugConnection(lbug, dbPath, { readOnly: true })
: await openLbugConnection(lbug, dbPath);
const usable = readOnly ? await ensureReadOnlyConnectionUsable(dbPath, opened) : opened;
db = usable.db;
conn = usable.conn;
currentDbReadOnly = readOnly;
} finally {
await releaseInitLock();
const opened = await openLbugConnection(lbug, dbPath);
db = opened.db;
conn = opened.conn;
currentDbReadOnly = false;
} finally {
await releaseInitLock();
}
}

if (!readOnly) {
Expand Down
29 changes: 29 additions & 0 deletions gitnexus/test/integration/lbug-readonly-init.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Integration Tests: read-only doInitLbug path (#1783)
*
* Verifies that read-only LadybugDB opens skip filesystem mutations
* (init lock, orphan sidecar cleanup, mkdir) so they work on read-only
* filesystems such as Docker :ro bind mounts.
*/
import fs from 'fs/promises';
import { it, expect } from 'vitest';
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
import { _initLockPathForTest } from '../../src/core/lbug/lbug-adapter.js';

withTestLbugDB('lbug-readonly-init', (handle) => {
it('read-only open never creates lbug.init.lock on disk', async () => {
const { dbPath } = handle;
const lockPath = _initLockPathForTest(dbPath);

const adapter = await import('../../src/core/lbug/lbug-adapter.js');
await adapter.closeLbug();

await expect(fs.access(lockPath)).rejects.toMatchObject({ code: 'ENOENT' });

await adapter.withLbugDb(dbPath, async () => {}, { readOnly: true });

await expect(fs.access(lockPath)).rejects.toMatchObject({ code: 'ENOENT' });

await adapter.closeLbug();
});
});
Loading