GitNexus/gitnexus/test/unit/group/group-lock.test.ts
azizur100389 3f5ca8cdb7
fix(storage): guard stale file-lock reclamation (#3234)
* fix(storage): guard stale file-lock reclamation

* fix(storage): close lock recovery failure paths

* Address PR review feedback (#3234)

- Flush the lock-child stderr diagnostic before process.exit
- Treat explicit NaN timeouts as the default ceiling
- Document non-retryable guard timeouts on the worker IPC contract

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(storage): stop mislabeling live lock waits as orphan recovery

A brief peer inspect must not attach guardPath or send operators to
RUNBOOK delete steps. Refuse lock-free embeddings sync, stop --watch
only on a true guard timeout, and drop an unreadable self-created
guard before failing closed.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* Address PR review feedback (#3234)

- Verify lock/guard absence before degrading a denied main-lock create
- Launch the third contender from unlinkSync, not the dead rename path
- Document group-lock timeouts for unrecoverable guard leftovers

Co-authored-by: Cursor <cursoragent@cursor.com>

* style: prettier index-lock reclaim guard tests

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(storage): treat O_EXCL as the lock-file presence check

CodeQL flagged existsSync-then-wx on analyze.lock. Create with wx first and
only reclaim unreadable leftovers after grace, so a successor is never
unlinked from a lost race.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-12 11:47:12 +01:00

92 lines
3.8 KiB
TypeScript

import { afterEach, expect, it, vi } from 'vitest';
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
acquireIndexLock,
IndexLockTimeoutError,
type LockRecord,
} from '../../../src/storage/index-lock.js';
import {
getGroupSyncLockDir,
GroupSyncLockError,
withGroupSyncLock,
} from '../../../src/core/group/group-lock.js';
vi.mock('../../../src/storage/index-lock.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../src/storage/index-lock.js')>()),
acquireIndexLock: vi.fn(),
}));
vi.mock('../../../src/core/logger.js', () => ({ logger: { info: vi.fn() } }));
afterEach(() => {
vi.resetAllMocks();
vi.useRealTimers();
vi.unstubAllEnvs();
});
it('preserves quiesced recovery and the exact guard path without running the operation', async () => {
vi.useFakeTimers();
vi.stubEnv('GITNEXUS_INDEX_LOCK_BACKEND', 'file');
const groupDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-group-guard-'));
const guardPath = path.join(getGroupSyncLockDir(groupDir), 'analyze.lock.guard');
mkdirSync(getGroupSyncLockDir(groupDir));
writeFileSync(guardPath, '');
const actual = await vi.importActual<typeof import('../../../src/storage/index-lock.js')>(
'../../../src/storage/index-lock.js',
);
vi.mocked(acquireIndexLock).mockImplementationOnce(actual.acquireIndexLock);
const operation = vi.fn(async () => 'must not run');
try {
const pending = withGroupSyncLock(groupDir, operation).catch((error: unknown) => error);
await vi.runAllTimersAsync();
const error = await pending;
expect(operation).not.toHaveBeenCalled();
expect(error).toBeInstanceOf(GroupSyncLockError);
expect(error).toMatchObject({ reason: 'timeout', groupDir });
expect((error as Error).message).toContain(guardPath);
expect((error as Error).message).toContain('Quiesce all relevant writers');
expect((error as Error).message).toContain('prevent restart');
expect((error as Error).message).toContain('quiesced recovery');
expect((error as Error).message).not.toContain('Re-run once the other sync');
expect((error as Error).cause).toBeInstanceOf(IndexLockTimeoutError);
expect((error as Error).cause).toMatchObject({ guardPath, holderKnown: false });
expect(readFileSync(guardPath, 'utf8')).toBe('');
} finally {
rmSync(groupDir, { recursive: true, force: true });
}
});
it.each([true, false])(
'preserves ordinary timeout messaging with holderKnown=%s',
async (holderKnown) => {
const groupDir = path.resolve('ordinary-group');
const holder: LockRecord = {
v: 1,
pid: holderKnown ? 1234 : -1,
hostname: 'host',
startTime: null,
token: 'token',
invocationId: 'invocation',
acquiredAt: '',
};
const cause = new IndexLockTimeoutError(holder, 123, holderKnown);
vi.useFakeTimers();
vi.mocked(acquireIndexLock).mockImplementationOnce(async () => {
vi.setSystemTime(Date.now() + 123);
throw cause;
});
const operation = vi.fn(async () => 'must not run');
const error = await withGroupSyncLock(groupDir, operation).catch((error: unknown) => error);
expect(operation).not.toHaveBeenCalled();
expect(error).toMatchObject({ reason: 'timeout', groupDir, cause });
expect((error as Error).message).toBe(
`Timed out after 123ms waiting for the sync lock on group "ordinary-group" (${getGroupSyncLockDir(groupDir)}). ` +
(holderKnown
? 'Held by pid 1234 on host (invocation invocation). '
: 'The lock stayed held for the whole wait, but this lock backend cannot identify the holder. ') +
'Nothing was written and this group was not synced. Re-run once the other sync of this group has finished.',
);
expect(cause).not.toHaveProperty('guardPath', expect.any(String));
},
);