import { afterEach, beforeEach, expect, test } from 'bun:test'; import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { hostname, tmpdir } from 'node:os'; import { join } from 'node:path'; import { E2eBusyError, type LockHolder, SUSPECT_HEARTBEAT_MS, acquireRunLock, clearLock, formatBusy, heartbeatAgeMs, isSuspect, lockStatus, markKept, readHolder, releaseRunLock, } from './run-lock'; // Isolate every test on its own lock file (machine-global path is overridable). let dir: string; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'e2e-lock-')); process.env.CELILO_E2E_LOCK_PATH = join(dir, 'run.lock'); }); afterEach(() => { clearLock(); rmSync(dir, { recursive: true, force: true }); delete process.env.CELILO_E2E_LOCK_PATH; delete process.env.CELILO_E2E_SESSION; }); function writeRawHolder(h: Partial): void { const full: LockHolder = { pid: 123, hostname: hostname(), session: 'other', test: 'foo', runId: 'r', startedAt: new Date().toISOString(), beatAt: Date.now(), state: 'running', ...h, }; writeFileSync(process.env.CELILO_E2E_LOCK_PATH as string, JSON.stringify(full)); } test('acquire writes a holder for this process; release removes it', () => { acquireRunLock({ test: 'mytest', runId: 'run-1' }); const h = readHolder(); expect(h?.pid).toBe(process.pid); expect(h?.test).toBe('mytest'); expect(lockStatus().free).toBe(false); releaseRunLock(); expect(readHolder()).toBeNull(); expect(lockStatus().free).toBe(true); }); test('a live holder makes a second acquire throw E2eBusyError', () => { acquireRunLock({ test: 't1', runId: 'r1' }); // The on-disk holder is us (a live pid) → busy. expect(() => acquireRunLock({ test: 't2', runId: 'r2' })).toThrow(E2eBusyError); releaseRunLock(); }); test('a dead-pid holder on the same host is stale and reclaimed', () => { // 2e9 is far above any real pid → process.kill(pid, 0) throws ESRCH → dead. writeRawHolder({ pid: 2_000_000_000 }); expect(lockStatus().free).toBe(true); // stale → reported free acquireRunLock({ test: 'reclaimer', runId: 'r' }); // reclaims the stale lock expect(readHolder()?.pid).toBe(process.pid); releaseRunLock(); }); test('--keep leaves a kept lock that survives release and blocks other sessions', () => { process.env.CELILO_E2E_SESSION = 'keeper (/keeper/worktree)'; acquireRunLock({ test: 'kept-test', runId: 'r' }); markKept(); releaseRunLock(); const h = readHolder(); expect(h?.state).toBe('kept'); expect(lockStatus().free).toBe(false); // kept is never stale // A plain run from ANOTHER session is refused... process.env.CELILO_E2E_SESSION = 'other (/other/worktree)'; expect(() => acquireRunLock({ test: 'next', runId: 'r2' })).toThrow(E2eBusyError); // ...but a --reuse run (allowKept) takes it over. acquireRunLock({ test: 'reuse', runId: 'r3', allowKept: true }); expect(readHolder()?.pid).toBe(process.pid); releaseRunLock(); }); test('clearLock frees a kept lock (the `cele2e release` path)', () => { writeRawHolder({ state: 'kept', pid: 2_000_000_000 }); expect(lockStatus().free).toBe(false); expect(clearLock()).toBe(true); expect(existsSync(process.env.CELILO_E2E_LOCK_PATH as string)).toBe(false); expect(lockStatus().free).toBe(true); }); test('a kept lock left by THIS session is auto-released by the next run', () => { // The friction case: `run --keep` then `run` from the same session. Refusing // here protected nobody — the only stack at risk was the caller's own — and // the refusal was routinely misread as a finished run, because the previous // run's results dir is still sitting there looking like a clean pass. process.env.CELILO_E2E_SESSION = 'my-branch (/my/worktree)'; acquireRunLock({ test: 'kept-test', runId: 'r' }); markKept(); releaseRunLock(); expect(readHolder()?.state).toBe('kept'); const outcome = acquireRunLock({ test: 'next', runId: 'r2' }); expect(outcome.autoReleasedOwnKept?.test).toBe('kept-test'); expect(readHolder()?.pid).toBe(process.pid); expect(readHolder()?.state).toBe('running'); releaseRunLock(); }); test('a kept lock from a DIFFERENT session is still refused', () => { process.env.CELILO_E2E_SESSION = 'my-branch (/my/worktree)'; // A live pid so staleness can't be what frees it — the point is the session. writeRawHolder({ state: 'kept', session: 'someone-else (/their/worktree)', pid: process.pid }); expect(() => acquireRunLock({ test: 'mine', runId: 'r' })).toThrow(E2eBusyError); }); test('a live pid with a silent heartbeat is SUSPECT but never auto-reclaimed', () => { // build-infra wedged at step [16/27]: the process is alive (so PID-liveness // says "healthy") while its heartbeat has not ticked for half an hour. This // is the one hang the existing staleness check structurally cannot see. writeRawHolder({ pid: process.pid, beatAt: Date.now() - 1_913_000 }); const h = readHolder() as LockHolder; expect(isSuspect(h)).toBe(true); expect(heartbeatAgeMs(h)).toBeGreaterThan(SUSPECT_HEARTBEAT_MS); expect(formatBusy(h)).toContain('SUSPECT'); const status = lockStatus(); expect(status.suspect).toBe(true); // Suspect surfaces the hang; it does NOT kill someone's build for them. expect(status.free).toBe(false); expect(() => acquireRunLock({ test: 'other', runId: 'r' })).toThrow(E2eBusyError); }); test('a fresh heartbeat is not suspect', () => { writeRawHolder({ pid: process.pid, beatAt: Date.now() - 5_000 }); const h = readHolder() as LockHolder; expect(isSuspect(h)).toBe(false); expect(lockStatus().suspect).toBe(false); }); test('a healthy build-infra that blocks the event loop is NOT suspect', () => { // spawnSync('docker', ['build', …]) blocks the event loop, so the heartbeat // stops for the whole of each image build. A live holder was observed 67s // stale while making normal progress; the threshold has to clear that or the // flag fires on every large image and stops meaning anything. writeRawHolder({ pid: process.pid, beatAt: Date.now() - 120_000 }); expect(isSuspect(readHolder() as LockHolder)).toBe(false); }); test('a kept holder is never suspect — its beatAt is frozen on purpose', () => { writeRawHolder({ state: 'kept', pid: process.pid, beatAt: Date.now() - 3_600_000 }); expect(isSuspect(readHolder() as LockHolder)).toBe(false); }); test('a corrupt lock file is reclaimed, not fatal', () => { writeFileSync(process.env.CELILO_E2E_LOCK_PATH as string, 'not json{'); expect(readHolder()).toBeNull(); acquireRunLock({ test: 'survivor', runId: 'r' }); expect(readHolder()?.pid).toBe(process.pid); releaseRunLock(); });