fix(test): retry Windows temp cleanup in cli-e2e teardown (#1688)

This commit is contained in:
Gergő Magyar 2026-05-18 18:17:54 +01:00 committed by GitHub
parent 33f18ceaa2
commit c9199b654f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 69 additions and 33 deletions

View file

@ -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<void>;
}
const CLEANUP_MAX_ATTEMPTS = 5;
const WINDOWS_NATIVE_LOCK_CODES = new Set(['EBUSY', 'EPERM', 'EACCES', 'ENOTEMPTY']);
export async function cleanupTempDir(tmpDir: string): Promise<void> {
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<void> {
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<void> {
* return.
*/
export async function createTempDir(prefix: string = 'gitnexus-test-'): Promise<TestDBHandle> {
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 () => {

View file

@ -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);
}
});