pool-manager.test.ts122 lines · main
| 1 | import { describe, expect, test } from 'bun:test'; |
| 2 | |
| 3 | import { |
| 4 | computeKillReason, |
| 5 | IsolatePoolImpl, |
| 6 | transitionState, |
| 7 | type IsolateEntry, |
| 8 | } from './pool-manager.js'; |
| 9 | |
| 10 | const baseEntry: IsolateEntry = { |
| 11 | isolateId: 'iso-1', |
| 12 | projectId: 'p1', |
| 13 | deploymentId: 'd1', |
| 14 | state: 'ready', |
| 15 | pid: 1234, |
| 16 | invocationCount: 0, |
| 17 | lastActivityAt: Date.now(), |
| 18 | tmpDir: '/tmp/briven-isolate-iso-1', |
| 19 | createdAt: Date.now(), |
| 20 | consecutiveCrashes: 0, |
| 21 | envValues: [], |
| 22 | }; |
| 23 | |
| 24 | describe('pool-manager state transitions', () => { |
| 25 | test('ready → in_flight on invoke start', () => { |
| 26 | expect(transitionState(baseEntry, { kind: 'invoke_start' }).state).toBe('in_flight'); |
| 27 | }); |
| 28 | |
| 29 | test('in_flight → ready on invoke complete', () => { |
| 30 | const inFlight = { ...baseEntry, state: 'in_flight' as const }; |
| 31 | expect(transitionState(inFlight, { kind: 'invoke_complete' }).state).toBe('ready'); |
| 32 | }); |
| 33 | |
| 34 | test('any → retiring on retire signal', () => { |
| 35 | expect(transitionState(baseEntry, { kind: 'retire' }).state).toBe('retiring'); |
| 36 | }); |
| 37 | |
| 38 | test('any → dead on crash', () => { |
| 39 | expect(transitionState(baseEntry, { kind: 'crash' }).state).toBe('dead'); |
| 40 | }); |
| 41 | }); |
| 42 | |
| 43 | describe('pool-manager kill reason', () => { |
| 44 | test('idle for >threshold returns idle', () => { |
| 45 | const old = { ...baseEntry, lastActivityAt: Date.now() - 11 * 60_000 }; |
| 46 | expect(computeKillReason(old, { idleKillMs: 10 * 60_000, maxInvocations: 1000 })).toBe('idle'); |
| 47 | }); |
| 48 | |
| 49 | test('invocationCount === max returns max_invocations', () => { |
| 50 | const maxed = { ...baseEntry, invocationCount: 1000 }; |
| 51 | expect(computeKillReason(maxed, { idleKillMs: 10 * 60_000, maxInvocations: 1000 })).toBe('max_invocations'); |
| 52 | }); |
| 53 | |
| 54 | test('healthy ready returns null', () => { |
| 55 | expect(computeKillReason(baseEntry, { idleKillMs: 10 * 60_000, maxInvocations: 1000 })).toBeNull(); |
| 56 | }); |
| 57 | }); |
| 58 | |
| 59 | describe('crash loop breaker', () => { |
| 60 | test('records and breaks at threshold within window', () => { |
| 61 | const pool = new IsolatePoolImpl({ |
| 62 | spawn: (async () => { |
| 63 | throw new Error('not used'); |
| 64 | }) as never, |
| 65 | runtimeStubDir: '/tmp/stub', |
| 66 | isolateBaseDir: '/tmp', |
| 67 | maxIsolates: 50, |
| 68 | maxMemoryMb: 128, |
| 69 | invocationTimeoutMs: 30_000, |
| 70 | idleKillMs: 10 * 60_000, |
| 71 | maxInvocationsPerIsolate: 1000, |
| 72 | crashLoopThreshold: 3, |
| 73 | crashLoopWindowMs: 60_000, |
| 74 | runQueryProxy: async () => [], |
| 75 | onLog: () => {}, |
| 76 | loadProjectEnv: async () => ({}), |
| 77 | }); |
| 78 | const p = pool as unknown as { |
| 79 | recordCrash: (k: string) => void; |
| 80 | isCrashLoopBroken: (k: string) => boolean; |
| 81 | }; |
| 82 | p.recordCrash('p1:d1'); |
| 83 | p.recordCrash('p1:d1'); |
| 84 | expect(p.isCrashLoopBroken('p1:d1')).toBe(false); |
| 85 | p.recordCrash('p1:d1'); |
| 86 | expect(p.isCrashLoopBroken('p1:d1')).toBe(true); |
| 87 | }); |
| 88 | |
| 89 | test('out-of-window crashes are trimmed', () => { |
| 90 | const pool = new IsolatePoolImpl({ |
| 91 | spawn: (async () => { |
| 92 | throw new Error('not used'); |
| 93 | }) as never, |
| 94 | runtimeStubDir: '/tmp/stub', |
| 95 | isolateBaseDir: '/tmp', |
| 96 | maxIsolates: 50, |
| 97 | maxMemoryMb: 128, |
| 98 | invocationTimeoutMs: 30_000, |
| 99 | idleKillMs: 10 * 60_000, |
| 100 | maxInvocationsPerIsolate: 1000, |
| 101 | crashLoopThreshold: 3, |
| 102 | crashLoopWindowMs: 50, // tiny window |
| 103 | runQueryProxy: async () => [], |
| 104 | onLog: () => {}, |
| 105 | loadProjectEnv: async () => ({}), |
| 106 | }); |
| 107 | const p = pool as unknown as { |
| 108 | recordCrash: (k: string) => void; |
| 109 | isCrashLoopBroken: (k: string) => boolean; |
| 110 | }; |
| 111 | p.recordCrash('p1:d1'); |
| 112 | p.recordCrash('p1:d1'); |
| 113 | p.recordCrash('p1:d1'); |
| 114 | expect(p.isCrashLoopBroken('p1:d1')).toBe(true); |
| 115 | return new Promise<void>((resolve) => { |
| 116 | setTimeout(() => { |
| 117 | expect(p.isCrashLoopBroken('p1:d1')).toBe(false); // trimmed |
| 118 | resolve(); |
| 119 | }, 100); |
| 120 | }); |
| 121 | }); |
| 122 | }); |