gitnexus 1.6.5-rc.49 → 1.6.5-rc.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -41,6 +41,19 @@ export declare const getDatabase: () => lbug.Database | null;
41
41
  * analyze` and either already happened or will happen on the next run.
42
42
  */
43
43
  export declare const isReadOnlyDbError: (err: unknown) => boolean;
44
+ /**
45
+ * Acquire a cross-process init lock for `dbPath`.
46
+ * Uses `O_CREAT | O_EXCL` for atomic create-or-fail semantics.
47
+ *
48
+ * Returns a release function that removes the lock file. The release
49
+ * function is idempotent and safe to call even if the lock was already
50
+ * cleaned up externally.
51
+ *
52
+ * Throws if the lock cannot be acquired after `INIT_LOCK_MAX_ATTEMPTS`.
53
+ */
54
+ export declare const acquireInitLock: (dbPath: string) => Promise<() => Promise<void>>;
55
+ /** Exported for testing — returns the lock file path for a given dbPath. */
56
+ export declare const _initLockPathForTest: (dbPath: string) => string;
44
57
  export declare const initLbug: (dbPath: string) => Promise<{
45
58
  db: lbug.Database;
46
59
  conn: lbug.Connection;
@@ -1,5 +1,5 @@
1
1
  import fs from 'fs/promises';
2
- import { createReadStream, createWriteStream } from 'fs';
2
+ import { createReadStream, createWriteStream, constants as fsConstants } from 'fs';
3
3
  import { createInterface } from 'readline';
4
4
  import { once } from 'events';
5
5
  import { finished } from 'stream/promises';
@@ -152,6 +152,136 @@ export const isReadOnlyDbError = (err) => {
152
152
  const msg = err instanceof Error ? err.message : String(err);
153
153
  return /read-only database/i.test(msg);
154
154
  };
155
+ const isMissingFileError = (err) => {
156
+ const errno = err;
157
+ return errno?.code === 'ENOENT';
158
+ };
159
+ const extractErrnoCode = (err) => {
160
+ const errno = err;
161
+ return errno?.code;
162
+ };
163
+ const MAX_LOGGED_ERROR_MESSAGE_LENGTH = 160;
164
+ const summarizeError = (err) => (err instanceof Error ? err.message : String(err)).slice(0, MAX_LOGGED_ERROR_MESSAGE_LENGTH);
165
+ // ---------------------------------------------------------------------------
166
+ // Cross-process init lock
167
+ //
168
+ // Prevents a TOCTOU race in orphan sidecar cleanup: between checking that
169
+ // the main DB file is missing and unlinking sidecars, another process could
170
+ // create a fresh DB. The lock file (`${dbPath}.init.lock`) is created with
171
+ // O_CREAT | O_EXCL (atomic create-or-fail) and contains the owning PID +
172
+ // timestamp so stale locks from crashed processes can be reclaimed.
173
+ // ---------------------------------------------------------------------------
174
+ /** Maximum age (ms) before an init lock is considered stale. */
175
+ const INIT_LOCK_STALE_MS = 30_000;
176
+ /** Maximum attempts to acquire the init lock before giving up. */
177
+ const INIT_LOCK_MAX_ATTEMPTS = 6;
178
+ /** Delay between lock-acquisition retries (ms). */
179
+ const INIT_LOCK_RETRY_DELAY_MS = 500;
180
+ const initLockPath = (dbPath) => `${dbPath}.init.lock`;
181
+ /**
182
+ * Returns true when the process identified by `pid` is still running.
183
+ * Uses `process.kill(pid, 0)` which sends signal 0 (a no-op probe) —
184
+ * it throws ESRCH when the process does not exist.
185
+ */
186
+ const isProcessAlive = (pid) => {
187
+ try {
188
+ process.kill(pid, 0);
189
+ return true;
190
+ }
191
+ catch {
192
+ return false;
193
+ }
194
+ };
195
+ /**
196
+ * Try to break a stale lock whose owning process has exited.
197
+ * Returns `true` if the stale lock was removed (caller should retry acquire).
198
+ * Returns `false` if the lock is still valid (another live process owns it).
199
+ */
200
+ const tryBreakStaleLock = async (lockPath) => {
201
+ try {
202
+ const content = await fs.readFile(lockPath, 'utf-8');
203
+ const parsed = JSON.parse(content);
204
+ // If the owning process is still alive AND the lock is not stale, don't break.
205
+ if (typeof parsed.pid === 'number' && isProcessAlive(parsed.pid)) {
206
+ // Even a live process's lock can be stale if it's been held too long
207
+ // (e.g. the process is hung). Check the timestamp.
208
+ if (typeof parsed.ts === 'number' && Date.now() - parsed.ts < INIT_LOCK_STALE_MS) {
209
+ return false;
210
+ }
211
+ }
212
+ // PID is gone or lock exceeded INIT_LOCK_STALE_MS — reclaim it.
213
+ await fs.unlink(lockPath);
214
+ logger.warn(`GitNexus: removed stale init lock (pid=${parsed.pid ?? '?'}, age=${typeof parsed.ts === 'number' ? `${Date.now() - parsed.ts}ms` : '?'})`);
215
+ return true;
216
+ }
217
+ catch (err) {
218
+ // Lock file disappeared between our read and unlink, or is unreadable.
219
+ // Either way, let the caller retry the acquire.
220
+ if (isMissingFileError(err))
221
+ return true;
222
+ // Permission error or corrupt content — log and let caller retry.
223
+ const code = extractErrnoCode(err);
224
+ logger.warn(`GitNexus: unable to inspect init lock (${code ?? 'UNKNOWN'}): ${summarizeError(err)}`);
225
+ return false;
226
+ }
227
+ };
228
+ /**
229
+ * Acquire a cross-process init lock for `dbPath`.
230
+ * Uses `O_CREAT | O_EXCL` for atomic create-or-fail semantics.
231
+ *
232
+ * Returns a release function that removes the lock file. The release
233
+ * function is idempotent and safe to call even if the lock was already
234
+ * cleaned up externally.
235
+ *
236
+ * Throws if the lock cannot be acquired after `INIT_LOCK_MAX_ATTEMPTS`.
237
+ */
238
+ export const acquireInitLock = async (dbPath) => {
239
+ const lockPath = initLockPath(dbPath);
240
+ const payload = JSON.stringify({ pid: process.pid, ts: Date.now() });
241
+ // Ensure the parent directory exists before creating the lock file.
242
+ // On a fresh repo the `.gitnexus/` directory may not exist yet, and
243
+ // fs.open with O_CREAT | O_EXCL would fail with ENOENT.
244
+ await fs.mkdir(path.dirname(lockPath), { recursive: true });
245
+ for (let attempt = 1; attempt <= INIT_LOCK_MAX_ATTEMPTS; attempt++) {
246
+ try {
247
+ const handle = await fs.open(lockPath, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY);
248
+ await handle.writeFile(payload);
249
+ await handle.close();
250
+ // Return the idempotent release function
251
+ return async () => {
252
+ try {
253
+ await fs.unlink(lockPath);
254
+ }
255
+ catch (err) {
256
+ if (!isMissingFileError(err)) {
257
+ const code = extractErrnoCode(err);
258
+ logger.warn(`GitNexus: failed to release init lock (${code ?? 'UNKNOWN'}): ${summarizeError(err)}`);
259
+ }
260
+ }
261
+ };
262
+ }
263
+ catch (err) {
264
+ if (err?.code !== 'EEXIST') {
265
+ throw err; // Unexpected error — propagate immediately
266
+ }
267
+ // Lock file exists — check if it's stale
268
+ const broken = await tryBreakStaleLock(lockPath);
269
+ if (broken && attempt < INIT_LOCK_MAX_ATTEMPTS) {
270
+ continue; // Stale lock removed — retry immediately
271
+ }
272
+ if (attempt === INIT_LOCK_MAX_ATTEMPTS) {
273
+ throw new Error(`GitNexus: unable to acquire init lock after ${INIT_LOCK_MAX_ATTEMPTS} attempts — ` +
274
+ `another gitnexus process may be initializing the same database (${lockPath})`);
275
+ }
276
+ // Live process holds the lock — wait and retry
277
+ await new Promise((resolve) => setTimeout(resolve, INIT_LOCK_RETRY_DELAY_MS));
278
+ }
279
+ }
280
+ // Unreachable — loop always throws or returns
281
+ throw new Error('GitNexus: init lock acquisition failed unexpectedly');
282
+ };
283
+ /** Exported for testing — returns the lock file path for a given dbPath. */
284
+ export const _initLockPathForTest = initLockPath;
155
285
  const runWithSessionLock = async (operation) => {
156
286
  const previous = sessionLock;
157
287
  let release = null;
@@ -310,15 +440,59 @@ const doInitLbug = async (dbPath) => {
310
440
  }
311
441
  // If it's a file, assume it's an existing LadybugDB database - LadybugDB will open it
312
442
  }
313
- catch {
443
+ catch (err) {
444
+ if (!isMissingFileError(err)) {
445
+ throw err;
446
+ }
314
447
  // Path doesn't exist, which is what LadybugDB wants for a new database
315
448
  }
316
- // Ensure parent directory exists
317
- const parentDir = path.dirname(dbPath);
318
- await fs.mkdir(parentDir, { recursive: true });
319
- const opened = await openLbugConnection(lbug, dbPath);
320
- db = opened.db;
321
- conn = opened.conn;
449
+ // ---------------------------------------------------------------------------
450
+ // Cross-process critical section: acquire init lock, clean orphan sidecars,
451
+ // and open the database. The lock prevents a TOCTOU race where another
452
+ // process could create a fresh DB between our access() check and the
453
+ // unlink() of stale sidecars.
454
+ // ---------------------------------------------------------------------------
455
+ const releaseInitLock = await acquireInitLock(dbPath);
456
+ try {
457
+ // Crash-recovery cleanup: if the main DB file is missing, stale sidecars
458
+ // from an interrupted run can block fresh opens indefinitely.
459
+ try {
460
+ await fs.access(dbPath);
461
+ }
462
+ catch (err) {
463
+ if (isMissingFileError(err)) {
464
+ // `.shadow` is documented by LadybugDB checkpointing and `.wal.checkpoint`
465
+ // was observed in the #1618 crash loop that motivated this recovery path.
466
+ const orphanSidecars = [`${dbPath}.shadow`, `${dbPath}.wal.checkpoint`];
467
+ for (const sidecar of orphanSidecars) {
468
+ try {
469
+ await fs.unlink(sidecar);
470
+ logger.warn(`GitNexus: removed orphan sidecar ${path.basename(sidecar)} (no main DB file present)`);
471
+ }
472
+ catch (err) {
473
+ if (isMissingFileError(err)) {
474
+ continue;
475
+ }
476
+ const code = extractErrnoCode(err);
477
+ 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)}`);
478
+ }
479
+ }
480
+ }
481
+ else {
482
+ const code = extractErrnoCode(err);
483
+ logger.warn(`GitNexus: unable to verify main DB file before orphan sidecar cleanup (${code ?? 'UNKNOWN'}); skipping cleanup: ${summarizeError(err)}`);
484
+ }
485
+ }
486
+ // Ensure parent directory exists
487
+ const parentDir = path.dirname(dbPath);
488
+ await fs.mkdir(parentDir, { recursive: true });
489
+ const opened = await openLbugConnection(lbug, dbPath);
490
+ db = opened.db;
491
+ conn = opened.conn;
492
+ }
493
+ finally {
494
+ await releaseInitLock();
495
+ }
322
496
  for (const schemaQuery of SCHEMA_QUERIES) {
323
497
  try {
324
498
  await queryAndDrain(conn, schemaQuery);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.5-rc.49",
3
+ "version": "1.6.5-rc.50",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",