diff --git a/gitnexus/test/helpers/test-db.ts b/gitnexus/test/helpers/test-db.ts index 37032ebc8..5be063a2c 100644 --- a/gitnexus/test/helpers/test-db.ts +++ b/gitnexus/test/helpers/test-db.ts @@ -4,7 +4,8 @@ * Creates temporary directories for tests and provides cleanup that tolerates * LadybugDB's known Windows handle-release lag after retries. */ -import fs from 'fs/promises'; +import fs from 'fs'; +import fsp from 'fs/promises'; import os from 'os'; import path from 'path'; @@ -13,22 +14,56 @@ export interface TestDBHandle { cleanup: () => Promise; } +const CLEANUP_MAX_ATTEMPTS = 5; const WINDOWS_NATIVE_LOCK_CODES = new Set(['EBUSY', 'EPERM', 'EACCES', 'ENOTEMPTY']); -export async function cleanupTempDir(tmpDir: string): Promise { +const cleanupBackoffMs = (attempt: number): number => 100 * (attempt + 1); + +const shouldSwallowCleanupError = (err: unknown): boolean => { + const code = (err as NodeJS.ErrnoException | undefined)?.code; + return process.platform === 'win32' && WINDOWS_NATIVE_LOCK_CODES.has(code ?? ''); +}; + +const sleepSync = (ms: number): void => { + const view = new Int32Array(new SharedArrayBuffer(4)); + Atomics.wait(view, 0, 0, ms); +}; + +export function cleanupTempDirSync(tmpDir: string): void { let lastError: unknown; - for (let attempt = 0; attempt < 5; attempt++) { + for (let attempt = 0; attempt < CLEANUP_MAX_ATTEMPTS; attempt++) { try { - await fs.rm(tmpDir, { recursive: true, force: true }); + fs.rmSync(tmpDir, { recursive: true, force: true }); return; } catch (err) { lastError = err; - await new Promise((resolve) => setTimeout(resolve, 100 * (attempt + 1))); + if (attempt < CLEANUP_MAX_ATTEMPTS - 1) { + sleepSync(cleanupBackoffMs(attempt)); + } } } - const code = (lastError as NodeJS.ErrnoException | undefined)?.code; - if (process.platform === 'win32' && WINDOWS_NATIVE_LOCK_CODES.has(code ?? '')) { + if (shouldSwallowCleanupError(lastError)) { + return; + } + throw lastError; +} + +export async function cleanupTempDir(tmpDir: string): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < CLEANUP_MAX_ATTEMPTS; attempt++) { + try { + await fsp.rm(tmpDir, { recursive: true, force: true }); + return; + } catch (err) { + lastError = err; + if (attempt < CLEANUP_MAX_ATTEMPTS - 1) { + await new Promise((resolve) => setTimeout(resolve, cleanupBackoffMs(attempt))); + } + } + } + + if (shouldSwallowCleanupError(lastError)) { return; } throw lastError; @@ -46,7 +81,7 @@ export async function cleanupTempDir(tmpDir: string): Promise { * return. */ export async function createTempDir(prefix: string = 'gitnexus-test-'): Promise { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), prefix)); return { dbPath: tmpDir, cleanup: async () => { diff --git a/gitnexus/test/integration/cli-e2e.test.ts b/gitnexus/test/integration/cli-e2e.test.ts index b739ad070..194b3813b 100644 --- a/gitnexus/test/integration/cli-e2e.test.ts +++ b/gitnexus/test/integration/cli-e2e.test.ts @@ -16,6 +16,7 @@ import os from 'os'; import { fileURLToPath, pathToFileURL } from 'url'; import { createRequire } from 'module'; +import { cleanupTempDirSync } from '../helpers/test-db.js'; const testDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(testDir, '../..'); @@ -75,10 +76,10 @@ afterAll(() => { // Entire tmp copy goes away — no selective cleanup needed. The shared // `test/fixtures/mini-repo/` source was never touched. if (tmpParent) { - fs.rmSync(tmpParent, { recursive: true, force: true }); + cleanupTempDirSync(tmpParent); } if (suiteGitnexusHome) { - fs.rmSync(suiteGitnexusHome, { recursive: true, force: true }); + cleanupTempDirSync(suiteGitnexusHome); } }); @@ -268,8 +269,8 @@ describe('CLI end-to-end', () => { `registry has no entry for ${repo}; entries: ${JSON.stringify(entries.map((e) => e.path))}`, ).toBe(true); } finally { - fs.rmSync(gnHome, { recursive: true, force: true }); - fs.rmSync(repoParent, { recursive: true, force: true }); + cleanupTempDirSync(gnHome); + cleanupTempDirSync(repoParent); } }, 60_000); @@ -310,8 +311,8 @@ describe('CLI end-to-end', () => { expect(`${second.stdout}${second.stderr}`).toMatch(/registry entry/i); expect(second.status).toBe(1); } finally { - fs.rmSync(gnHome, { recursive: true, force: true }); - fs.rmSync(repoParent, { recursive: true, force: true }); + cleanupTempDirSync(gnHome); + cleanupTempDirSync(repoParent); } }, 60_000); @@ -457,12 +458,12 @@ describe('CLI end-to-end', () => { const afterStep4 = JSON.parse(fs.readFileSync(registryPath, 'utf-8')); expect(afterStep4).toHaveLength(2); } finally { - fs.rmSync(parentC, { recursive: true, force: true }); + cleanupTempDirSync(parentC); } } finally { - fs.rmSync(gnHome, { recursive: true, force: true }); - fs.rmSync(parentA, { recursive: true, force: true }); - fs.rmSync(parentB, { recursive: true, force: true }); + cleanupTempDirSync(gnHome); + cleanupTempDirSync(parentA); + cleanupTempDirSync(parentB); } }, 360000); // 6-min outer budget (4 × ~60s analyze calls + fixture setup) }); @@ -571,8 +572,8 @@ describe('CLI end-to-end', () => { expect(r4.status).toBe(0); expect(`${r4.stdout}${r4.stderr}`).toMatch(/Nothing to remove/i); } finally { - fs.rmSync(gnHome, { recursive: true, force: true }); - fs.rmSync(parentA, { recursive: true, force: true }); + cleanupTempDirSync(gnHome); + cleanupTempDirSync(parentA); } }, 180000); // 3-min outer budget (1 × ~60s analyze + 3 × fast remove calls) @@ -675,9 +676,9 @@ describe('CLI end-to-end', () => { // And it's NOT the one we just removed. expect(finalEntries[0].path).not.toBe(repoAEntry.path); } finally { - fs.rmSync(gnHome, { recursive: true, force: true }); - fs.rmSync(parentA, { recursive: true, force: true }); - fs.rmSync(parentB, { recursive: true, force: true }); + cleanupTempDirSync(gnHome); + cleanupTempDirSync(parentA); + cleanupTempDirSync(parentB); } }, 240000); // 4-min outer budget (2 × ~60s analyze + 2 × fast remove) @@ -759,8 +760,8 @@ describe('CLI end-to-end', () => { expect(afterRegistry).toHaveLength(1); expect(afterRegistry[0].storagePath).toBe(repo); // still poisoned (we did that) } finally { - fs.rmSync(gnHome, { recursive: true, force: true }); - fs.rmSync(parent, { recursive: true, force: true }); + cleanupTempDirSync(gnHome); + cleanupTempDirSync(parent); } }, 120000); // 2-min budget (1 × ~60s analyze + 1 × fast remove-refused) }); @@ -864,9 +865,9 @@ describe('CLI end-to-end', () => { expect(afterRegistry).toHaveLength(1); expect(afterRegistry[0].name).toBe('bad-alias'); } finally { - fs.rmSync(gnHome, { recursive: true, force: true }); - fs.rmSync(parentBad, { recursive: true, force: true }); - fs.rmSync(parentGood, { recursive: true, force: true }); + cleanupTempDirSync(gnHome); + cleanupTempDirSync(parentBad); + cleanupTempDirSync(parentGood); } }, 240000); // 4-min budget (2 × ~60s analyze + 1 × fast clean --all) }); @@ -954,7 +955,7 @@ describe('CLI end-to-end', () => { expect(result.status).toBe(0); expect(result.stdout).toMatch(/Repository not indexed/); } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); + cleanupTempDirSync(tmpDir); } }); @@ -968,7 +969,7 @@ describe('CLI end-to-end', () => { expect(result.status).toBe(0); expect(result.stdout).toMatch(/Not a git repository/); } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); + cleanupTempDirSync(tmpDir); } }); @@ -984,7 +985,7 @@ describe('CLI end-to-end', () => { expect(result.status).toBe(1); expect(result.stdout).toMatch(/not.*git repository/i); } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); + cleanupTempDirSync(tmpDir); } }); }); @@ -1014,7 +1015,7 @@ describe('CLI end-to-end', () => { expect(result.status).toBe(1); expect(result.stdout).toMatch(/not.*git repository/i); } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); + cleanupTempDirSync(tmpDir); } }); @@ -1051,7 +1052,7 @@ describe('CLI end-to-end', () => { expect(result.status).toBe(1); expect(result.stdout).toMatch(/No GitNexus index found/); } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); + cleanupTempDirSync(tmpDir); } });