// tools/parallel-session-lock.test.mjs // Stream H Task 7 — pure parallel-session-lock module tests. import { describe, it, expect, vi } from 'vitest'; import { acquire, release, refresh, computeWorkspaceHash, LOCK_DEFAULT_TTL_MS, } from './parallel-session-lock.mjs'; function mkMockStore(initial = null) { let current = initial; return { readLock: () => current, writeLock: (v) => { current = v; }, deleteLock: () => { current = null; }, peek: () => current, }; } describe('parallel-session-lock pure module (Stream H Task 7)', () => { it('acquire on empty store succeeds and writes holder record', () => { const store = mkMockStore(null); const r = acquire({ sessionId: 's1', pid: 100, workspaceHash: 'abc', now: 1000, readLock: store.readLock, writeLock: store.writeLock, }); expect(r.acquired).toBe(true); expect(r.holder).toMatchObject({ session_id: 's1', pid: 100, acquired_at: 1000 }); expect(store.peek()).toMatchObject({ schema_version: 1, session_id: 's1', pid: 100, acquired_at: 1000, ttl_ms: LOCK_DEFAULT_TTL_MS }); }); it('acquire blocked when a fresh lock from another session exists', () => { const existing = { schema_version: 1, session_id: 'other', pid: 999, acquired_at: 500, ttl_ms: LOCK_DEFAULT_TTL_MS }; const store = mkMockStore(existing); const r = acquire({ sessionId: 's1', pid: 100, workspaceHash: 'abc', now: 1000, readLock: store.readLock, writeLock: store.writeLock, }); expect(r.acquired).toBe(false); expect(r.holder).toMatchObject({ session_id: 'other', pid: 999 }); expect(store.peek()).toBe(existing); // unchanged }); it('acquire takes over a stale lock from another session (past TTL)', () => { const existing = { schema_version: 1, session_id: 'old', pid: 7, acquired_at: 0, ttl_ms: 100 }; const store = mkMockStore(existing); const r = acquire({ sessionId: 's1', pid: 100, workspaceHash: 'abc', now: 1000, readLock: store.readLock, writeLock: store.writeLock, }); expect(r.acquired).toBe(true); expect(r.holder).toMatchObject({ session_id: 's1', pid: 100, acquired_at: 1000 }); }); it('refresh same-session updates acquired_at without losing ownership', () => { const existing = { schema_version: 1, session_id: 's1', pid: 100, acquired_at: 500, ttl_ms: LOCK_DEFAULT_TTL_MS }; const store = mkMockStore(existing); const r = refresh({ sessionId: 's1', workspaceHash: 'abc', now: 1000, readLock: store.readLock, writeLock: store.writeLock, }); expect(r.refreshed).toBe(true); expect(store.peek().acquired_at).toBe(1000); }); it('refresh other-session is a no-op (does not steal lock)', () => { const existing = { schema_version: 1, session_id: 'other', pid: 999, acquired_at: 500, ttl_ms: LOCK_DEFAULT_TTL_MS }; const store = mkMockStore(existing); const r = refresh({ sessionId: 's1', workspaceHash: 'abc', now: 1000, readLock: store.readLock, writeLock: store.writeLock, }); expect(r.refreshed).toBe(false); expect(store.peek()).toBe(existing); }); it('release same-session deletes the lock', () => { const existing = { schema_version: 1, session_id: 's1', pid: 100, acquired_at: 500, ttl_ms: LOCK_DEFAULT_TTL_MS }; const store = mkMockStore(existing); release({ sessionId: 's1', workspaceHash: 'abc', readLock: store.readLock, deleteLock: store.deleteLock }); expect(store.peek()).toBe(null); }); it('release other-session does NOT delete the lock', () => { const existing = { schema_version: 1, session_id: 'other', pid: 999, acquired_at: 500, ttl_ms: LOCK_DEFAULT_TTL_MS }; const store = mkMockStore(existing); release({ sessionId: 's1', workspaceHash: 'abc', readLock: store.readLock, deleteLock: store.deleteLock }); expect(store.peek()).toBe(existing); }); }); describe('computeWorkspaceHash (Stream H Task 7)', () => { it('returns 12 hex chars', () => { const h = computeWorkspaceHash('/some/path'); expect(h).toMatch(/^[0-9a-f]{12}$/); }); it('is deterministic per path', () => { expect(computeWorkspaceHash('/some/path')).toBe(computeWorkspaceHash('/some/path')); }); it('differs across paths', () => { expect(computeWorkspaceHash('/a')).not.toBe(computeWorkspaceHash('/b')); }); }); // 7.5 (дыра 5, Блок 4.5) — pid-liveness: свежий (не-stale) лок мёртвого держателя блокировал на // весь TTL. Теперь acquire перехватывает, если держатель не жив (isPidAlive инъектируется в // parallel-session-lock; реальная проверка — isProcessAlive через process.kill(pid,0)). import { isProcessAlive } from './parallel-session-lock.mjs'; describe('parallel-session-lock pid-liveness (7.5, дыра 5)', () => { const fresh = { schema_version: 1, session_id: 'other', pid: 999, acquired_at: 900, ttl_ms: LOCK_DEFAULT_TTL_MS }; it('свежий лок чужой сессии, держатель МЁРТВ → перехват (acquired)', () => { const store = mkMockStore({ ...fresh }); const r = acquire({ sessionId: 's1', pid: 100, workspaceHash: 'abc', now: 1000, readLock: store.readLock, writeLock: store.writeLock, isPidAlive: () => false, }); expect(r.acquired).toBe(true); expect(store.peek()).toMatchObject({ session_id: 's1', pid: 100 }); }); it('свежий лок чужой сессии, держатель ЖИВ → блок (acquired false)', () => { const store = mkMockStore({ ...fresh }); const r = acquire({ sessionId: 's1', pid: 100, workspaceHash: 'abc', now: 1000, readLock: store.readLock, writeLock: store.writeLock, isPidAlive: () => true, }); expect(r.acquired).toBe(false); expect(r.holder).toMatchObject({ session_id: 'other', pid: 999 }); }); it('без isPidAlive (backward-compat) свежий чужой лок → блок', () => { const store = mkMockStore({ ...fresh }); const r = acquire({ sessionId: 's1', pid: 100, workspaceHash: 'abc', now: 1000, readLock: store.readLock, writeLock: store.writeLock, }); expect(r.acquired).toBe(false); }); it('isProcessAlive: наш pid жив, невалидный pid мёртв', () => { expect(isProcessAlive(process.pid)).toBe(true); expect(isProcessAlive(0)).toBe(false); expect(isProcessAlive(-1)).toBe(false); expect(isProcessAlive('x')).toBe(false); }); });