Skip to content

Commit 22fc4e6

Browse files
authored
fix: Use cgroup-aware CPU monitoring inside Docker containers (#1099)
1 parent d52d6f4 commit 22fc4e6

10 files changed

Lines changed: 700 additions & 50 deletions

File tree

.changeset/bitter-waves-matter.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@livekit/agents': patch
3+
---
4+
5+
Use cgroup-aware CPU monitoring inside Docker containers

agents/src/cpu.test.ts

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
// SPDX-FileCopyrightText: 2025 LiveKit, Inc.
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
import { existsSync, readFileSync } from 'node:fs';
5+
import os from 'node:os';
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
7+
import { CGroupV1CpuMonitor, CGroupV2CpuMonitor, DefaultCpuMonitor, getCpuMonitor } from './cpu.js';
8+
9+
vi.mock('node:fs', () => ({
10+
existsSync: vi.fn(() => false),
11+
readFileSync: vi.fn(() => ''),
12+
}));
13+
14+
const mockExistsSync = vi.mocked(existsSync);
15+
const mockReadFileSync = vi.mocked(readFileSync);
16+
17+
describe('cpu', () => {
18+
beforeEach(() => {
19+
vi.clearAllMocks();
20+
delete process.env.NUM_CPUS;
21+
});
22+
23+
afterEach(() => {
24+
delete process.env.NUM_CPUS;
25+
});
26+
27+
describe('getCpuMonitor', () => {
28+
it('returns CGroupV2CpuMonitor when /sys/fs/cgroup/cpu.stat exists', () => {
29+
mockExistsSync.mockImplementation((p) => p === '/sys/fs/cgroup/cpu.stat');
30+
const monitor = getCpuMonitor();
31+
expect(monitor).toBeInstanceOf(CGroupV2CpuMonitor);
32+
});
33+
34+
it('returns CGroupV1CpuMonitor when cgroup v1 paths exist', () => {
35+
mockExistsSync.mockImplementation((p) => p === '/sys/fs/cgroup/cpuacct/cpuacct.usage');
36+
const monitor = getCpuMonitor();
37+
expect(monitor).toBeInstanceOf(CGroupV1CpuMonitor);
38+
});
39+
40+
it('returns DefaultCpuMonitor when no cgroup paths exist', () => {
41+
mockExistsSync.mockReturnValue(false);
42+
const monitor = getCpuMonitor();
43+
expect(monitor).toBeInstanceOf(DefaultCpuMonitor);
44+
});
45+
});
46+
47+
describe('DefaultCpuMonitor', () => {
48+
it('returns os.cpus().length for cpuCount', () => {
49+
const monitor = new DefaultCpuMonitor();
50+
expect(monitor.cpuCount()).toBe(os.cpus().length);
51+
});
52+
53+
it('respects NUM_CPUS env var', () => {
54+
process.env.NUM_CPUS = '4.5';
55+
const monitor = new DefaultCpuMonitor();
56+
expect(monitor.cpuCount()).toBe(4.5);
57+
});
58+
59+
it('ignores invalid NUM_CPUS', () => {
60+
process.env.NUM_CPUS = 'notanumber';
61+
const monitor = new DefaultCpuMonitor();
62+
expect(monitor.cpuCount()).toBe(os.cpus().length);
63+
});
64+
65+
it('cpuPercent returns value in [0, 1]', async () => {
66+
const monitor = new DefaultCpuMonitor();
67+
const result = await monitor.cpuPercent(50);
68+
expect(result).toBeGreaterThanOrEqual(0);
69+
expect(result).toBeLessThanOrEqual(1);
70+
}, 10_000);
71+
});
72+
73+
describe('CGroupV2CpuMonitor', () => {
74+
it('returns quota/period for cpuCount', () => {
75+
mockReadFileSync.mockImplementation((p) => {
76+
if (String(p) === '/sys/fs/cgroup/cpu.max') return '200000 100000';
77+
return '';
78+
});
79+
const monitor = new CGroupV2CpuMonitor();
80+
expect(monitor.cpuCount()).toBe(2);
81+
});
82+
83+
it('falls back to os.cpus().length when quota is max', () => {
84+
mockReadFileSync.mockImplementation((p) => {
85+
if (String(p) === '/sys/fs/cgroup/cpu.max') return 'max 100000';
86+
return '';
87+
});
88+
const monitor = new CGroupV2CpuMonitor();
89+
expect(monitor.cpuCount()).toBe(os.cpus().length);
90+
});
91+
92+
it('handles missing cpu.max gracefully', () => {
93+
mockReadFileSync.mockImplementation(() => {
94+
throw new Error('ENOENT');
95+
});
96+
const monitor = new CGroupV2CpuMonitor();
97+
expect(monitor.cpuCount()).toBe(os.cpus().length);
98+
});
99+
100+
it('respects NUM_CPUS env var', () => {
101+
process.env.NUM_CPUS = '3';
102+
const monitor = new CGroupV2CpuMonitor();
103+
expect(monitor.cpuCount()).toBe(3);
104+
});
105+
106+
it('cpuPercent computes correct value from usage_usec deltas', async () => {
107+
let callCount = 0;
108+
mockReadFileSync.mockImplementation((p) => {
109+
if (String(p) === '/sys/fs/cgroup/cpu.stat') {
110+
callCount++;
111+
// Two reads: 1,000,000 usec apart => 1s of CPU usage over the interval
112+
return callCount <= 1
113+
? 'usage_usec 1000000\nuser_usec 800000\nsystem_usec 200000'
114+
: 'usage_usec 2000000\nuser_usec 1600000\nsystem_usec 400000';
115+
}
116+
if (String(p) === '/sys/fs/cgroup/cpu.max') return '200000 100000';
117+
return '';
118+
});
119+
const monitor = new CGroupV2CpuMonitor();
120+
// interval=100ms, 2 cpus, 1s of usage => 1/(0.1*2) = 5, clamped to 1
121+
const result = await monitor.cpuPercent(100);
122+
expect(result).toBe(1);
123+
}, 10_000);
124+
125+
it('cpuPercent returns fractional load', async () => {
126+
let callCount = 0;
127+
mockReadFileSync.mockImplementation((p) => {
128+
if (String(p) === '/sys/fs/cgroup/cpu.stat') {
129+
callCount++;
130+
// 50,000 usec delta => 0.05s of CPU over 0.1s on 2 cpus => 0.05/(0.1*2) = 0.25
131+
return callCount <= 1 ? 'usage_usec 1000000\n' : 'usage_usec 1050000\n';
132+
}
133+
if (String(p) === '/sys/fs/cgroup/cpu.max') return '200000 100000';
134+
return '';
135+
});
136+
const monitor = new CGroupV2CpuMonitor();
137+
const result = await monitor.cpuPercent(100);
138+
expect(result).toBeCloseTo(0.25, 1);
139+
}, 10_000);
140+
141+
it('throws when usage_usec is missing from cpu.stat', async () => {
142+
mockReadFileSync.mockImplementation((p) => {
143+
if (String(p) === '/sys/fs/cgroup/cpu.stat') return 'user_usec 800000\nsystem_usec 200000';
144+
return '';
145+
});
146+
const monitor = new CGroupV2CpuMonitor();
147+
await expect(() => monitor.cpuPercent(50)).rejects.toThrow('Failed to read CPU usage');
148+
});
149+
});
150+
151+
describe('CGroupV1CpuMonitor', () => {
152+
it('returns quota/period for cpuCount', () => {
153+
mockReadFileSync.mockImplementation((p) => {
154+
if (String(p) === '/sys/fs/cgroup/cpu/cpu.cfs_quota_us') return '200000';
155+
if (String(p) === '/sys/fs/cgroup/cpu/cpu.cfs_period_us') return '100000';
156+
return '';
157+
});
158+
const monitor = new CGroupV1CpuMonitor();
159+
expect(monitor.cpuCount()).toBe(2);
160+
});
161+
162+
it('defaults to 2.0 when quota is -1', () => {
163+
mockReadFileSync.mockImplementation((p) => {
164+
if (String(p) === '/sys/fs/cgroup/cpu/cpu.cfs_quota_us') return '-1';
165+
if (String(p) === '/sys/fs/cgroup/cpu/cpu.cfs_period_us') return '100000';
166+
return '';
167+
});
168+
const monitor = new CGroupV1CpuMonitor();
169+
expect(monitor.cpuCount()).toBe(2.0);
170+
});
171+
172+
it('defaults to 2.0 when quota file is unreadable', () => {
173+
mockReadFileSync.mockImplementation(() => {
174+
throw new Error('ENOENT');
175+
});
176+
const monitor = new CGroupV1CpuMonitor();
177+
expect(monitor.cpuCount()).toBe(2.0);
178+
});
179+
180+
it('clamps cpuCount to minimum 1.0', () => {
181+
mockReadFileSync.mockImplementation((p) => {
182+
if (String(p) === '/sys/fs/cgroup/cpu/cpu.cfs_quota_us') return '50000';
183+
if (String(p) === '/sys/fs/cgroup/cpu/cpu.cfs_period_us') return '100000';
184+
return '';
185+
});
186+
const monitor = new CGroupV1CpuMonitor();
187+
expect(monitor.cpuCount()).toBe(1.0);
188+
});
189+
190+
it('respects NUM_CPUS env var', () => {
191+
process.env.NUM_CPUS = '8';
192+
const monitor = new CGroupV1CpuMonitor();
193+
expect(monitor.cpuCount()).toBe(8);
194+
});
195+
196+
it('cpuPercent computes correct value from nanosecond deltas', async () => {
197+
let callCount = 0;
198+
mockReadFileSync.mockImplementation((p) => {
199+
if (String(p) === '/sys/fs/cgroup/cpuacct/cpuacct.usage') {
200+
callCount++;
201+
// 100_000_000 ns delta = 0.1s CPU over 0.1s interval on 2 cpus => 0.1/(0.1*2) = 0.5
202+
return callCount <= 1 ? '1000000000' : '1100000000';
203+
}
204+
if (String(p) === '/sys/fs/cgroup/cpu/cpu.cfs_quota_us') return '200000';
205+
if (String(p) === '/sys/fs/cgroup/cpu/cpu.cfs_period_us') return '100000';
206+
return '';
207+
});
208+
const monitor = new CGroupV1CpuMonitor();
209+
const result = await monitor.cpuPercent(100);
210+
expect(result).toBeCloseTo(0.5, 1);
211+
}, 10_000);
212+
213+
it('clamps cpuPercent output to [0, 1]', async () => {
214+
let callCount = 0;
215+
mockReadFileSync.mockImplementation((p) => {
216+
if (String(p) === '/sys/fs/cgroup/cpuacct/cpuacct.usage') {
217+
callCount++;
218+
// Huge delta => would exceed 1.0 without clamping
219+
return callCount <= 1 ? '0' : '10000000000';
220+
}
221+
if (String(p) === '/sys/fs/cgroup/cpu/cpu.cfs_quota_us') return '100000';
222+
if (String(p) === '/sys/fs/cgroup/cpu/cpu.cfs_period_us') return '100000';
223+
return '';
224+
});
225+
const monitor = new CGroupV1CpuMonitor();
226+
const result = await monitor.cpuPercent(100);
227+
expect(result).toBeLessThanOrEqual(1);
228+
expect(result).toBeGreaterThanOrEqual(0);
229+
}, 10_000);
230+
231+
it('throws when cpuacct.usage is unreadable', async () => {
232+
mockReadFileSync.mockImplementation(() => {
233+
throw new Error('ENOENT');
234+
});
235+
const monitor = new CGroupV1CpuMonitor();
236+
await expect(() => monitor.cpuPercent(50)).rejects.toThrow('Failed to read cpuacct.usage');
237+
});
238+
});
239+
});

0 commit comments

Comments
 (0)