pool-manager.test.ts122 lines · main
1import { describe, expect, test } from 'bun:test';
2
3import {
4 computeKillReason,
5 IsolatePoolImpl,
6 transitionState,
7 type IsolateEntry,
8} from './pool-manager.js';
9
10const 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
24describe('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
43describe('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
59describe('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});