Files
portal/tools/enforce-parallel-session-lock.test.mjs
T
Дмитрий a88a80ed0b feat(m5): 7.5 parallel-session-lock pid-liveness (Пакет 7, дыра 5, Блок 4.5)
Закрыта дыра 5: свежий (не-stale) лок МЁРТВОГО держателя блокировал на весь TTL.
- isProcessAlive(pid): process.kill(pid,0) — ESRCH→мёртв, EPERM→жив; невалидный pid→мёртв.
- acquire +isPidAlive (инъектируемый): перехват, если держатель не жив (помимо stale/same-session).
  Без isPidAlive — backward-compat (старое поведение).
- runAcquireDecision +isPidAlive (default isProcessAlive) → acquire. Живой держатель → блок;
  мёртвый → перехват. Хук остаётся fail-open (availability, не защитный пол).

+5 (pure) +1 (wrapper) тестов; существующий «fresh lock» тест уточнён на живого держателя.
parallel-session-lock + enforce 27/27.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:23:15 +03:00

149 lines
5.6 KiB
JavaScript

// tools/enforce-parallel-session-lock.test.mjs
// Stream H Task 7 — wrapper tests around the pure parallel-session-lock module.
import { describe, it, expect } from 'vitest';
import { decide } from './enforce-parallel-session-lock.mjs';
describe('enforce-parallel-session-lock wrapper (Stream H Task 7)', () => {
it('allow when acquire succeeded (fresh own-lock)', () => {
const r = decide({
acquireResult: { acquired: true, holder: { session_id: 's1', pid: 100, acquired_at: 1000 } },
sessionId: 's1',
});
expect(r.block).toBe(false);
});
it('block when another session holds the lock', () => {
const r = decide({
acquireResult: { acquired: false, holder: { session_id: 'other-session', pid: 999, acquired_at: 500 } },
sessionId: 's1',
});
expect(r.block).toBe(true);
expect(r.reason).toMatch(/parallel session lock.*other-session/i);
});
it('allow when same-session re-acquires (takeover)', () => {
const r = decide({
acquireResult: { acquired: true, holder: { session_id: 's1', pid: 100, acquired_at: 2000 } },
sessionId: 's1',
});
expect(r.block).toBe(false);
});
it('fail-open when acquireResult is missing (internal error path)', () => {
expect(decide({ acquireResult: null, sessionId: 's1' }).block).toBe(false);
expect(decide({ acquireResult: undefined, sessionId: 's1' }).block).toBe(false);
});
it('block message identifies the other holder pid for human triage', () => {
const r = decide({
acquireResult: { acquired: false, holder: { session_id: 'other', pid: 42, acquired_at: 0 } },
sessionId: 's1',
});
expect(r.reason).toMatch(/pid 42/);
});
});
// Live wiring (point 2, 2026-05-31): PreToolUse acquires/refreshes the lock,
// Stop releases it. I/O is injected (readLock/writeLock/deleteLock) so the
// wiring stays pure and unit-testable; main() binds real fs.
import { runAcquireDecision, runReleaseAction } from './enforce-parallel-session-lock.mjs';
describe('runAcquireDecision — PreToolUse acquire/refresh wiring', () => {
it('allows and writes a fresh lock when none exists', () => {
let written = null;
const r = runAcquireDecision({
event: { tool_name: 'Edit', session_id: 'S1' },
now: 1000, pid: 42, cwd: '/ws',
readLock: () => null,
writeLock: (rec) => { written = rec; },
});
expect(r.block).toBe(false);
expect(written).toMatchObject({ session_id: 'S1', pid: 42, acquired_at: 1000 });
});
it('blocks when another LIVE session holds a fresh lock', () => {
// 7.5: явный живой держатель — enforce-parallel-session-lock не должен его перехватывать.
const r = runAcquireDecision({
event: { tool_name: 'Edit', session_id: 'S2' },
now: 1000, pid: 7, cwd: '/ws',
readLock: () => ({ schema_version: 1, session_id: 'S1', pid: 99, acquired_at: 900, ttl_ms: 300000 }),
writeLock: () => {},
isPidAlive: () => true,
});
expect(r.block).toBe(true);
expect(r.reason).toMatch(/S1|pid 99|parallel session/i);
});
it('7.5: перехватывает свежий лок чужой сессии, чей держатель МЁРТВ (pid-liveness)', () => {
let written = null;
const r = runAcquireDecision({
event: { tool_name: 'Edit', session_id: 'S2' },
now: 1000, pid: 7, cwd: '/ws',
readLock: () => ({ schema_version: 1, session_id: 'S1', pid: 99, acquired_at: 900, ttl_ms: 300000 }),
writeLock: (rec) => { written = rec; },
isPidAlive: () => false,
});
expect(r.block).toBe(false);
expect(written.session_id).toBe('S2');
});
it('allows (refresh) when the same session already holds the lock', () => {
let written = null;
const r = runAcquireDecision({
event: { tool_name: 'Edit', session_id: 'S1' },
now: 2000, pid: 42, cwd: '/ws',
readLock: () => ({ schema_version: 1, session_id: 'S1', pid: 42, acquired_at: 900, ttl_ms: 300000 }),
writeLock: (rec) => { written = rec; },
});
expect(r.block).toBe(false);
expect(written.acquired_at).toBe(2000);
});
it('takes over a stale lock from another session (TTL expired)', () => {
let written = null;
const r = runAcquireDecision({
event: { tool_name: 'Edit', session_id: 'S2' },
now: 1_000_000, pid: 7, cwd: '/ws',
readLock: () => ({ schema_version: 1, session_id: 'S1', pid: 99, acquired_at: 0, ttl_ms: 300000 }),
writeLock: (rec) => { written = rec; },
});
expect(r.block).toBe(false);
expect(written.session_id).toBe('S2');
});
});
describe('runReleaseAction — Stop release wiring', () => {
it('deletes the lock when this session owns it', () => {
let deleted = false;
runReleaseAction({
event: { session_id: 'S1' },
cwd: '/ws',
readLock: () => ({ schema_version: 1, session_id: 'S1', pid: 42, acquired_at: 0, ttl_ms: 300000 }),
deleteLock: () => { deleted = true; },
});
expect(deleted).toBe(true);
});
it('does NOT delete a lock owned by another session', () => {
let deleted = false;
runReleaseAction({
event: { session_id: 'S2' },
cwd: '/ws',
readLock: () => ({ schema_version: 1, session_id: 'S1', pid: 42, acquired_at: 0, ttl_ms: 300000 }),
deleteLock: () => { deleted = true; },
});
expect(deleted).toBe(false);
});
it('is a no-op when no lock file exists', () => {
let deleted = false;
runReleaseAction({
event: { session_id: 'S1' },
cwd: '/ws',
readLock: () => null,
deleteLock: () => { deleted = true; },
});
expect(deleted).toBe(false);
});
});