GitNexus/gitnexus/test/unit/write-lock-required.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

78 lines
3 KiB
TypeScript

import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
const { acquire, release, pipeline, resolvePlacement } = vi.hoisted(() => ({
acquire: vi.fn(),
release: vi.fn(),
pipeline: vi.fn(),
resolvePlacement: vi.fn(async (): Promise<{ branch?: string }> => ({})),
}));
vi.mock('../../src/storage/index-lock.js', async (original) => ({
...(await original<typeof import('../../src/storage/index-lock.js')>()),
acquireIndexLock: acquire,
}));
vi.mock('../../src/core/ingestion/pipeline.js', () => ({ runPipelineFromRepo: pipeline }));
vi.mock('../../src/storage/repo-manager.js', async (original) => ({
...(await original<typeof import('../../src/storage/repo-manager.js')>()),
resolveBranchPlacement: (...args: unknown[]) => resolvePlacement(...args),
}));
import { runFullAnalysis } from '../../src/core/run-analyze.js';
import { unregisterRepo } from '../../src/storage/repo-manager.js';
let home: string;
beforeEach(async () => {
home = await fs.mkdtemp(path.join(os.tmpdir(), 'gnx-required-lock-'));
vi.stubEnv('GITNEXUS_HOME', home);
vi.stubEnv('GITNEXUS_FTS_STEMMER', 'porter');
vi.clearAllMocks();
acquire.mockResolvedValue({ lockFree: true, release });
resolvePlacement.mockReset().mockResolvedValue({});
});
afterEach(async () => {
vi.unstubAllEnvs();
await fs.rm(home, { recursive: true, force: true });
});
it('refuses analysis without exclusive ownership before invoking the pipeline', async () => {
await expect(runFullAnalysis(home, {}, { onProgress: () => {} })).rejects.toThrow(
'refusing an unlocked analysis',
);
expect(acquire).toHaveBeenCalled();
expect(pipeline).not.toHaveBeenCalled();
expect(release).toHaveBeenCalledOnce();
await expect(fs.stat(path.join(home, '.gitnexus', 'lbug'))).rejects.toMatchObject({
code: 'ENOENT',
});
});
it('refuses a registry transaction without exclusive ownership', async () => {
const registry = path.join(home, 'registry.json');
const original = JSON.stringify([
{
name: 'keep',
path: home,
storagePath: path.join(home, '.gitnexus'),
indexedAt: '',
lastCommit: '',
},
]);
await fs.writeFile(registry, original);
await expect(unregisterRepo(home)).rejects.toThrow('refusing an unlocked registry transaction');
expect(await fs.readFile(registry, 'utf8')).toBe(original);
expect(release).toHaveBeenCalledOnce();
});
it('refuses analysis without exclusive ownership after the write slot moves', async () => {
resolvePlacement.mockResolvedValueOnce({}).mockResolvedValueOnce({ branch: 'moved' });
acquire.mockResolvedValueOnce({ release }).mockResolvedValueOnce({ lockFree: true, release });
await expect(
runFullAnalysis(home, { branch: 'moved' }, { onProgress: () => {} }),
).rejects.toThrow('refusing an unlocked analysis');
expect(acquire).toHaveBeenCalledTimes(2);
expect(pipeline).not.toHaveBeenCalled();
expect(release).toHaveBeenCalled();
});