process-reaper.test.js
1 /** 2 * Tests for process-reaper cron job 3 * 4 * runProcessReaper() uses execSync (ps, kill, free), os.freemem/totalmem, 5 * and writes to a SQLite DB. We mock child_process and os to avoid 6 * actual process killing, then verify the returned stats and DB write. 7 */ 8 9 import { test, describe, mock } from 'node:test'; 10 import assert from 'node:assert/strict'; 11 import { join } from 'path'; 12 import { tmpdir } from 'os'; 13 import { unlinkSync, existsSync } from 'fs'; 14 import Database from 'better-sqlite3'; 15 import { readFileSync } from 'fs'; 16 17 // ─── Setup temp DB ──────────────────────────────────────────────────────────── 18 19 const TEST_DB = join(tmpdir(), `test-process-reaper-${Date.now()}.db`); 20 process.env.DATABASE_PATH = TEST_DB; 21 22 const schemaPath = join(import.meta.dirname, '..', '..', 'db', 'schema.sql'); 23 const schema = readFileSync(schemaPath, 'utf-8'); 24 const db = new Database(TEST_DB); 25 db.exec(schema); 26 db.close(); 27 28 // ─── Mock child_process and os ─────────────────────────────────────────────── 29 30 let mockExecSyncImpl = cmd => ''; 31 32 mock.module('child_process', { 33 namedExports: { 34 execSync: (cmd, opts) => mockExecSyncImpl(cmd, opts), 35 }, 36 }); 37 38 mock.module('os', { 39 namedExports: { 40 freemem: () => 2 * 1024 * 1024 * 1024, // 2 GB free 41 totalmem: () => 8 * 1024 * 1024 * 1024, // 8 GB total 42 cpus: () => [], 43 loadavg: () => [0.5, 0.5, 0.5], 44 }, 45 }); 46 47 mock.module('../../src/utils/logger.js', { 48 defaultExport: class { 49 info() {} 50 warn() {} 51 error() {} 52 success() {} 53 debug() {} 54 }, 55 }); 56 57 mock.module('../../src/utils/load-env.js', { 58 defaultExport: {}, 59 }); 60 61 const { runProcessReaper } = await import('../../src/cron/process-reaper.js'); 62 63 // ─── Tests ──────────────────────────────────────────────────────────────────── 64 65 describe('runProcessReaper', () => { 66 test('returns correct shape', async () => { 67 mockExecSyncImpl = cmd => { 68 if (cmd.includes('grep -c')) return '0\n'; 69 if (cmd.includes('free -m')) return '4096 512\n'; 70 if (cmd.includes('ps -eo')) return '\n'; 71 return ''; 72 }; 73 74 const result = await runProcessReaper(); 75 assert.ok(typeof result.zombie_count === 'number'); 76 assert.ok(typeof result.free_mem_mb === 'number'); 77 assert.ok(typeof result.stale_processes_killed === 'number'); 78 assert.ok(Array.isArray(result.killed)); 79 assert.ok(['ok', 'warning'].includes(result.status)); 80 assert.ok(typeof result.duration_seconds === 'number'); 81 }); 82 83 test('status is ok when memory is healthy and no zombies', async () => { 84 mockExecSyncImpl = cmd => { 85 if (cmd.includes('grep -c')) return '0\n'; // no zombies 86 if (cmd.includes('free -m')) return '8192 0\n'; // plenty of swap free 87 if (cmd.includes('ps -eo')) return '\n'; // no processes 88 return ''; 89 }; 90 91 const result = await runProcessReaper(); 92 assert.strictEqual(result.zombie_count, 0); 93 assert.strictEqual(result.stale_processes_killed, 0); 94 assert.strictEqual(result.status, 'ok'); 95 }); 96 97 test('status is warning when zombie count exceeds default threshold (500)', async () => { 98 mockExecSyncImpl = cmd => { 99 if (cmd.includes('grep -c')) return '600\n'; // 600 zombies > default threshold 500 100 if (cmd.includes('free -m')) return '8192 0\n'; 101 if (cmd.includes('ps -eo')) return '\n'; 102 return ''; 103 }; 104 105 const result = await runProcessReaper(); 106 assert.strictEqual(result.zombie_count, 600); 107 assert.strictEqual(result.status, 'warning'); 108 }); 109 110 test('status is warning when free memory is below default threshold (1024 MB)', async () => { 111 // os mock returns 2GB free which is > 1024 MB threshold — status should be ok 112 // We verify the threshold logic by checking zero-free scenario via swap 113 mockExecSyncImpl = cmd => { 114 if (cmd.includes('grep -c')) return '0\n'; 115 // High swap usage (>70%) triggers warning 116 if (cmd.includes('free -m')) return '1024 800\n'; // 80% swap used > 70% threshold 117 if (cmd.includes('ps -eo')) return '\n'; 118 return ''; 119 }; 120 121 const result = await runProcessReaper(); 122 // swap_pct = 800/1024 = 78% > SWAP_WARN_PCT (70%) → warning 123 assert.strictEqual(result.status, 'warning'); 124 }); 125 126 test('returns stale_processes_killed=0 when no stale processes', async () => { 127 mockExecSyncImpl = cmd => { 128 if (cmd.includes('grep -c')) return '0\n'; 129 if (cmd.includes('free -m')) return '8192 0\n'; 130 if (cmd.includes('ps -eo')) return '\n'; // empty ps output 131 return ''; 132 }; 133 134 const result = await runProcessReaper(); 135 assert.strictEqual(result.stale_processes_killed, 0); 136 assert.deepStrictEqual(result.killed, []); 137 }); 138 139 test('handles execSync failures gracefully', async () => { 140 mockExecSyncImpl = () => { 141 throw new Error('execSync error'); 142 }; 143 144 // Should not throw — all execSync failures are caught internally 145 const result = await runProcessReaper(); 146 assert.ok(typeof result.zombie_count === 'number'); 147 assert.ok(result.zombie_count === 0); // fallback on error 148 }); 149 150 test('kills stale processes when ps returns long-running process', async () => { 151 // A process with etimes > MAX_AGE_MINUTES * 60 (default 7200s) should be killed 152 // Format: PID PPID etimes stat comm 153 const stalePid = 99998; 154 const staleEtimes = 8000; // > 7200s threshold 155 mockExecSyncImpl = cmd => { 156 if (cmd.includes('grep -c')) return '0\n'; 157 if (cmd.includes('free -m')) return '8192 0\n'; 158 if (cmd.includes('ps -eo')) return `${stalePid} 1 ${staleEtimes} S claude\n`; 159 // kill -TERM / kill -KILL commands 160 if (cmd.includes('kill')) return ''; 161 // /proc/PID/cmdline for npm processes (not needed for 'claude') 162 return ''; 163 }; 164 165 const result = await runProcessReaper(); 166 // Should have killed 1 stale claude process 167 assert.strictEqual(result.stale_processes_killed, 1); 168 assert.strictEqual(result.killed.length, 1); 169 assert.strictEqual(result.killed[0].pid, stalePid); 170 }); 171 172 test('skips zombie processes (stat starts with Z)', async () => { 173 const zombiePid = 99997; 174 mockExecSyncImpl = cmd => { 175 if (cmd.includes('grep -c')) return '0\n'; 176 if (cmd.includes('free -m')) return '8192 0\n'; 177 if (cmd.includes('ps -eo')) return `${zombiePid} 1 8000 Z claude\n`; 178 return ''; 179 }; 180 181 const result = await runProcessReaper(); 182 // Zombie should be skipped — can't kill already-dead processes 183 assert.strictEqual(result.stale_processes_killed, 0); 184 assert.strictEqual(result.killed.length, 0); 185 }); 186 187 test('skips processes younger than MAX_AGE threshold', async () => { 188 const youngPid = 99996; 189 mockExecSyncImpl = cmd => { 190 if (cmd.includes('grep -c')) return '0\n'; 191 if (cmd.includes('free -m')) return '8192 0\n'; 192 if (cmd.includes('ps -eo')) return `${youngPid} 1 60 S claude\n`; // 60s old, well under 7200s threshold 193 return ''; 194 }; 195 196 const result = await runProcessReaper(); 197 assert.strictEqual(result.stale_processes_killed, 0); 198 }); 199 200 test('writes to system_health table in DB', async () => { 201 mockExecSyncImpl = cmd => { 202 if (cmd.includes('grep -c')) return '0\n'; 203 if (cmd.includes('free -m')) return '4096 512\n'; 204 if (cmd.includes('ps -eo')) return '\n'; 205 return ''; 206 }; 207 208 await runProcessReaper(); 209 210 const db2 = new Database(TEST_DB); 211 const row = db2 212 .prepare( 213 `SELECT * FROM system_health WHERE check_type='process_reaper' ORDER BY id DESC LIMIT 1` 214 ) 215 .get(); 216 db2.close(); 217 218 assert.ok(row, 'Should have written a system_health row'); 219 assert.strictEqual(row.check_type, 'process_reaper'); 220 assert.ok(['ok', 'warning'].includes(row.status)); 221 }); 222 });