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
|
-
//
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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