f9447f8e5f
CI / typecheck · build · test · bundle-size (push) Failing after 1s
CI / clean-room dependency closure smoke (push) Failing after 1s
CI / server-runtime e2e (docker · pg + valkey) (push) Failing after 2s
Deploy Install Scripts / deploy (push) Failing after 2s
Windows / build (push) Has been cancelled
416 lines
12 KiB
TypeScript
416 lines
12 KiB
TypeScript
import { afterEach, describe, expect, it } from 'bun:test';
|
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
|
import { tmpdir } from 'os';
|
|
import path from 'path';
|
|
import { createProcessRegistry, isPidAlive } from '../../src/supervisor/process-registry.js';
|
|
|
|
function makeTempDir(): string {
|
|
return path.join(tmpdir(), `claude-mem-supervisor-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
}
|
|
|
|
const tempDirs: string[] = [];
|
|
|
|
describe('supervisor ProcessRegistry', () => {
|
|
afterEach(() => {
|
|
while (tempDirs.length > 0) {
|
|
const dir = tempDirs.pop();
|
|
if (dir) {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
});
|
|
|
|
describe('isPidAlive', () => {
|
|
it('treats current process as alive', () => {
|
|
expect(isPidAlive(process.pid)).toBe(true);
|
|
});
|
|
|
|
it('treats an impossibly high PID as dead', () => {
|
|
expect(isPidAlive(2147483647)).toBe(false);
|
|
});
|
|
|
|
it('treats negative PID as dead', () => {
|
|
expect(isPidAlive(-1)).toBe(false);
|
|
});
|
|
|
|
it('treats non-integer PID as dead', () => {
|
|
expect(isPidAlive(3.14)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('persistence', () => {
|
|
it('persists entries to disk and reloads them on initialize', () => {
|
|
const tempDir = makeTempDir();
|
|
tempDirs.push(tempDir);
|
|
mkdirSync(tempDir, { recursive: true });
|
|
const registryPath = path.join(tempDir, 'supervisor.json');
|
|
|
|
const registry1 = createProcessRegistry(registryPath);
|
|
registry1.register('worker:1', {
|
|
pid: process.pid,
|
|
type: 'worker',
|
|
startedAt: '2026-03-15T00:00:00.000Z'
|
|
});
|
|
|
|
expect(existsSync(registryPath)).toBe(true);
|
|
const diskData = JSON.parse(readFileSync(registryPath, 'utf-8'));
|
|
expect(diskData.processes['worker:1']).toBeDefined();
|
|
|
|
const registry2 = createProcessRegistry(registryPath);
|
|
registry2.initialize();
|
|
const records = registry2.getAll();
|
|
expect(records).toHaveLength(1);
|
|
expect(records[0]?.id).toBe('worker:1');
|
|
expect(records[0]?.pid).toBe(process.pid);
|
|
});
|
|
|
|
it('prunes dead processes on initialize', () => {
|
|
const tempDir = makeTempDir();
|
|
tempDirs.push(tempDir);
|
|
mkdirSync(tempDir, { recursive: true });
|
|
const registryPath = path.join(tempDir, 'supervisor.json');
|
|
|
|
writeFileSync(registryPath, JSON.stringify({
|
|
processes: {
|
|
alive: {
|
|
pid: process.pid,
|
|
type: 'worker',
|
|
startedAt: '2026-03-15T00:00:00.000Z'
|
|
},
|
|
dead: {
|
|
pid: 2147483647,
|
|
type: 'mcp',
|
|
startedAt: '2026-03-15T00:00:01.000Z'
|
|
}
|
|
}
|
|
}));
|
|
|
|
const registry = createProcessRegistry(registryPath);
|
|
registry.initialize();
|
|
|
|
const records = registry.getAll();
|
|
expect(records).toHaveLength(1);
|
|
expect(records[0]?.id).toBe('alive');
|
|
expect(existsSync(registryPath)).toBe(true);
|
|
});
|
|
|
|
it('handles corrupted registry file gracefully', () => {
|
|
const tempDir = makeTempDir();
|
|
tempDirs.push(tempDir);
|
|
mkdirSync(tempDir, { recursive: true });
|
|
const registryPath = path.join(tempDir, 'supervisor.json');
|
|
|
|
writeFileSync(registryPath, '{ not valid json!!!');
|
|
|
|
const registry = createProcessRegistry(registryPath);
|
|
registry.initialize();
|
|
|
|
expect(registry.getAll()).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('register and unregister', () => {
|
|
it('register adds an entry retrievable by getAll', () => {
|
|
const tempDir = makeTempDir();
|
|
tempDirs.push(tempDir);
|
|
const registry = createProcessRegistry(path.join(tempDir, 'supervisor.json'));
|
|
|
|
expect(registry.getAll()).toHaveLength(0);
|
|
|
|
registry.register('sdk:1', {
|
|
pid: process.pid,
|
|
type: 'sdk',
|
|
startedAt: '2026-03-15T00:00:00.000Z'
|
|
});
|
|
|
|
const records = registry.getAll();
|
|
expect(records).toHaveLength(1);
|
|
expect(records[0]?.id).toBe('sdk:1');
|
|
expect(records[0]?.type).toBe('sdk');
|
|
});
|
|
|
|
it('unregister removes an entry', () => {
|
|
const tempDir = makeTempDir();
|
|
tempDirs.push(tempDir);
|
|
const registry = createProcessRegistry(path.join(tempDir, 'supervisor.json'));
|
|
|
|
registry.register('sdk:1', {
|
|
pid: process.pid,
|
|
type: 'sdk',
|
|
startedAt: '2026-03-15T00:00:00.000Z'
|
|
});
|
|
expect(registry.getAll()).toHaveLength(1);
|
|
|
|
registry.unregister('sdk:1');
|
|
expect(registry.getAll()).toHaveLength(0);
|
|
});
|
|
|
|
it('unregister is a no-op for unknown IDs', () => {
|
|
const tempDir = makeTempDir();
|
|
tempDirs.push(tempDir);
|
|
const registry = createProcessRegistry(path.join(tempDir, 'supervisor.json'));
|
|
|
|
registry.register('sdk:1', {
|
|
pid: process.pid,
|
|
type: 'sdk',
|
|
startedAt: '2026-03-15T00:00:00.000Z'
|
|
});
|
|
|
|
registry.unregister('nonexistent');
|
|
expect(registry.getAll()).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('getAll', () => {
|
|
it('returns records sorted by startedAt ascending', () => {
|
|
const tempDir = makeTempDir();
|
|
tempDirs.push(tempDir);
|
|
const registry = createProcessRegistry(path.join(tempDir, 'supervisor.json'));
|
|
|
|
registry.register('newest', {
|
|
pid: process.pid,
|
|
type: 'sdk',
|
|
startedAt: '2026-03-15T00:00:02.000Z'
|
|
});
|
|
registry.register('oldest', {
|
|
pid: process.pid,
|
|
type: 'worker',
|
|
startedAt: '2026-03-15T00:00:00.000Z'
|
|
});
|
|
registry.register('middle', {
|
|
pid: process.pid,
|
|
type: 'mcp',
|
|
startedAt: '2026-03-15T00:00:01.000Z'
|
|
});
|
|
|
|
const records = registry.getAll();
|
|
expect(records).toHaveLength(3);
|
|
expect(records[0]?.id).toBe('oldest');
|
|
expect(records[1]?.id).toBe('middle');
|
|
expect(records[2]?.id).toBe('newest');
|
|
});
|
|
|
|
it('returns empty array when no entries exist', () => {
|
|
const tempDir = makeTempDir();
|
|
tempDirs.push(tempDir);
|
|
const registry = createProcessRegistry(path.join(tempDir, 'supervisor.json'));
|
|
|
|
expect(registry.getAll()).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('getBySession', () => {
|
|
it('filters records by session id', () => {
|
|
const tempDir = makeTempDir();
|
|
tempDirs.push(tempDir);
|
|
const registry = createProcessRegistry(path.join(tempDir, 'supervisor.json'));
|
|
|
|
registry.register('sdk:1', {
|
|
pid: process.pid,
|
|
type: 'sdk',
|
|
sessionId: 42,
|
|
startedAt: '2026-03-15T00:00:00.000Z'
|
|
});
|
|
registry.register('sdk:2', {
|
|
pid: process.pid,
|
|
type: 'sdk',
|
|
sessionId: 'other',
|
|
startedAt: '2026-03-15T00:00:01.000Z'
|
|
});
|
|
|
|
const records = registry.getBySession(42);
|
|
expect(records).toHaveLength(1);
|
|
expect(records[0]?.id).toBe('sdk:1');
|
|
});
|
|
|
|
it('returns empty array when no processes match the session', () => {
|
|
const tempDir = makeTempDir();
|
|
tempDirs.push(tempDir);
|
|
const registry = createProcessRegistry(path.join(tempDir, 'supervisor.json'));
|
|
|
|
registry.register('sdk:1', {
|
|
pid: process.pid,
|
|
type: 'sdk',
|
|
sessionId: 42,
|
|
startedAt: '2026-03-15T00:00:00.000Z'
|
|
});
|
|
|
|
expect(registry.getBySession(999)).toHaveLength(0);
|
|
});
|
|
|
|
it('matches string and numeric session IDs by string comparison', () => {
|
|
const tempDir = makeTempDir();
|
|
tempDirs.push(tempDir);
|
|
const registry = createProcessRegistry(path.join(tempDir, 'supervisor.json'));
|
|
|
|
registry.register('sdk:1', {
|
|
pid: process.pid,
|
|
type: 'sdk',
|
|
sessionId: '42',
|
|
startedAt: '2026-03-15T00:00:00.000Z'
|
|
});
|
|
|
|
expect(registry.getBySession(42)).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('pruneDeadEntries', () => {
|
|
it('removes entries with dead PIDs and preserves live ones', () => {
|
|
const tempDir = makeTempDir();
|
|
tempDirs.push(tempDir);
|
|
const registryPath = path.join(tempDir, 'supervisor.json');
|
|
const registry = createProcessRegistry(registryPath);
|
|
|
|
registry.register('alive', {
|
|
pid: process.pid,
|
|
type: 'worker',
|
|
startedAt: '2026-03-15T00:00:00.000Z'
|
|
});
|
|
registry.register('dead', {
|
|
pid: 2147483647,
|
|
type: 'mcp',
|
|
startedAt: '2026-03-15T00:00:01.000Z'
|
|
});
|
|
|
|
const removed = registry.pruneDeadEntries();
|
|
expect(removed).toBe(1);
|
|
expect(registry.getAll()).toHaveLength(1);
|
|
expect(registry.getAll()[0]?.id).toBe('alive');
|
|
});
|
|
|
|
it('returns 0 when all entries are alive', () => {
|
|
const tempDir = makeTempDir();
|
|
tempDirs.push(tempDir);
|
|
const registry = createProcessRegistry(path.join(tempDir, 'supervisor.json'));
|
|
|
|
registry.register('alive', {
|
|
pid: process.pid,
|
|
type: 'worker',
|
|
startedAt: '2026-03-15T00:00:00.000Z'
|
|
});
|
|
|
|
const removed = registry.pruneDeadEntries();
|
|
expect(removed).toBe(0);
|
|
expect(registry.getAll()).toHaveLength(1);
|
|
});
|
|
|
|
it('persists changes to disk after pruning', () => {
|
|
const tempDir = makeTempDir();
|
|
tempDirs.push(tempDir);
|
|
const registryPath = path.join(tempDir, 'supervisor.json');
|
|
const registry = createProcessRegistry(registryPath);
|
|
|
|
registry.register('dead', {
|
|
pid: 2147483647,
|
|
type: 'mcp',
|
|
startedAt: '2026-03-15T00:00:01.000Z'
|
|
});
|
|
|
|
registry.pruneDeadEntries();
|
|
|
|
const diskData = JSON.parse(readFileSync(registryPath, 'utf-8'));
|
|
expect(Object.keys(diskData.processes)).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('clear', () => {
|
|
it('removes all entries', () => {
|
|
const tempDir = makeTempDir();
|
|
tempDirs.push(tempDir);
|
|
const registryPath = path.join(tempDir, 'supervisor.json');
|
|
const registry = createProcessRegistry(registryPath);
|
|
|
|
registry.register('sdk:1', {
|
|
pid: process.pid,
|
|
type: 'sdk',
|
|
startedAt: '2026-03-15T00:00:00.000Z'
|
|
});
|
|
registry.register('sdk:2', {
|
|
pid: process.pid,
|
|
type: 'sdk',
|
|
startedAt: '2026-03-15T00:00:01.000Z'
|
|
});
|
|
|
|
expect(registry.getAll()).toHaveLength(2);
|
|
|
|
registry.clear();
|
|
expect(registry.getAll()).toHaveLength(0);
|
|
|
|
const diskData = JSON.parse(readFileSync(registryPath, 'utf-8'));
|
|
expect(Object.keys(diskData.processes)).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('createProcessRegistry', () => {
|
|
it('creates an isolated instance with a custom path', () => {
|
|
const tempDir1 = makeTempDir();
|
|
const tempDir2 = makeTempDir();
|
|
tempDirs.push(tempDir1, tempDir2);
|
|
|
|
const registry1 = createProcessRegistry(path.join(tempDir1, 'supervisor.json'));
|
|
const registry2 = createProcessRegistry(path.join(tempDir2, 'supervisor.json'));
|
|
|
|
registry1.register('sdk:1', {
|
|
pid: process.pid,
|
|
type: 'sdk',
|
|
startedAt: '2026-03-15T00:00:00.000Z'
|
|
});
|
|
|
|
expect(registry1.getAll()).toHaveLength(1);
|
|
expect(registry2.getAll()).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('reapSession', () => {
|
|
it('unregisters dead processes for the given session', async () => {
|
|
const tempDir = makeTempDir();
|
|
tempDirs.push(tempDir);
|
|
const registry = createProcessRegistry(path.join(tempDir, 'supervisor.json'));
|
|
|
|
registry.register('sdk:99:50001', {
|
|
pid: 2147483640,
|
|
type: 'sdk',
|
|
sessionId: 99,
|
|
startedAt: '2026-03-15T00:00:00.000Z'
|
|
});
|
|
registry.register('mcp:99:50002', {
|
|
pid: 2147483641,
|
|
type: 'mcp',
|
|
sessionId: 99,
|
|
startedAt: '2026-03-15T00:00:01.000Z'
|
|
});
|
|
|
|
registry.register('sdk:100:50003', {
|
|
pid: process.pid,
|
|
type: 'sdk',
|
|
sessionId: 100,
|
|
startedAt: '2026-03-15T00:00:02.000Z'
|
|
});
|
|
|
|
const reaped = await registry.reapSession(99);
|
|
expect(reaped).toBe(2);
|
|
|
|
expect(registry.getBySession(99)).toHaveLength(0);
|
|
expect(registry.getBySession(100)).toHaveLength(1);
|
|
});
|
|
|
|
it('returns 0 when no processes match the session', async () => {
|
|
const tempDir = makeTempDir();
|
|
tempDirs.push(tempDir);
|
|
const registry = createProcessRegistry(path.join(tempDir, 'supervisor.json'));
|
|
|
|
registry.register('sdk:1', {
|
|
pid: process.pid,
|
|
type: 'sdk',
|
|
sessionId: 42,
|
|
startedAt: '2026-03-15T00:00:00.000Z'
|
|
});
|
|
|
|
const reaped = await registry.reapSession(999);
|
|
expect(reaped).toBe(0);
|
|
|
|
expect(registry.getAll()).toHaveLength(1);
|
|
});
|
|
});
|
|
});
|