1import { assert, describe, it } from "@effect/vitest";
2import type * as NodeOS from "node:os";
3import * as HostProcess from "@t3tools/shared/HostProcess";
4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as PlatformError from "effect/PlatformError";
9import { ChildProcessSpawner } from "effect/process";
10import * as TestClock from "effect/testing/TestClock";
11import { vi } from "vite-plus/test";
12
13import * as HostResources from "./HostResources.ts";
14
15// An 8-CPU host; every cpus() call advances each CPU by 100 ms at `host.busy` utilization.
16const host = vi.hoisted(() => ({ busy: 0, reads: 0 }));
17vi.mock("node:os", async (importOriginal) => {
18 const actual = await importOriginal<typeof NodeOS>();
19 return {
20 ...actual,
21 totalmem: () => 4 * 1024 ** 3,
22 cpus: () => {
23 const elapsed = 100 * host.reads++;
24 const times = {
25 user: elapsed * host.busy,
26 nice: 0,
27 sys: 0,
28 idle: elapsed * (1 - host.busy),
29 irq: 0,
30 };
31 return Array.from({ length: 8 }, () => ({ model: "test", speed: 1, times }));
32 },
33 };
34});
35const setup = (busy = 0) => {
36 host.busy = busy;
37 host.reads = 0;
38};
39
40const GiB = 1024 ** 3;
41const MiB = 1024 ** 2;
42const cgroup = "/sys/fs/cgroup/t3.slice/server.scope";
43const unlimitedCgroup = {
44 "/proc/self/cgroup": ["0::/t3.slice/server.scope\n"],
45 "/proc/meminfo": [`MemTotal: ${4 * 1024 * 1024} kB\nMemAvailable: ${2 * 1024 * 1024} kB\n`],
46 [`${cgroup}/cpu.max`]: ["max 100000\n"],
47 [`${cgroup}/cpu.stat`]: ["usage_usec 5000000\n"],
48 [`${cgroup}/memory.max`]: ["max\n"],
49 [`${cgroup}/memory.current`]: [`${600 * MiB}\n`],
50 [`${cgroup}/memory.stat`]: [`anon ${450 * MiB}\ninactive_file ${100 * MiB}\n`],
51};
52
53// Each path serves its values in order and repeats the last one.
54const linuxHost = (files: Record<string, ReadonlyArray<string>>) => {
55 const reads = new Map<string, number>();
56 return HostResources.layer.pipe(
57 Layer.provide(
58 Layer.mergeAll(
59 FileSystem.layerNoop({
60 readFileString: (path) => {
61 const values = files[path];
62 if (!values) {
63 return Effect.fail(
64 PlatformError.systemError({
65 _tag: "NotFound",
66 module: "FileSystem",
67 method: "readFileString",
68 pathOrDescriptor: path,
69 }),
70 );
71 }
72 const index = reads.get(path) ?? 0;
73 reads.set(path, index + 1);
74 return Effect.succeed(values[Math.min(index, values.length - 1)]!);
75 },
76 }),
77 Layer.succeed(HostProcess.Platform, "linux"),
78 Layer.succeed(
79 ChildProcessSpawner.ChildProcessSpawner,
80 ChildProcessSpawner.make(() => Effect.die("HostResources spawned on linux")),
81 ),
82 ),
83 ),
84 );
85};
86
87const read = Effect.gen(function* () {
88 const host = yield* HostResources.HostResources;
89 const fiber = yield* Effect.forkChild(host.read);
90 yield* TestClock.adjust("200 millis");
91 return yield* Fiber.join(fiber);
92});
93
94describe("HostResources.layer", () => {
95 it.effect("reports the CPU quota and usage of the server's cgroup", () =>
96 Effect.gen(function* () {
97 setup();
98 const snapshot = yield* read;
99
100 // One CPU of quota, 150 ms of it used during the 200 ms sample.
101 assert.strictEqual(snapshot.cpuCount, 1);
102 assert.strictEqual(snapshot.cpuUtilization, 0.75);
103 }).pipe(
104 Effect.provide(
105 linuxHost({
106 ...unlimitedCgroup,
107 [`${cgroup}/cpu.max`]: ["100000 100000\n"],
108 [`${cgroup}/cpu.stat`]: [
109 "usage_usec 5000000\nuser_usec 4000000\nsystem_usec 1000000\n",
110 "usage_usec 5150000\nuser_usec 4100000\nsystem_usec 1050000\n",
111 ],
112 }),
113 ),
114 ),
115 );
116
117 it.effect("reports memory against the server cgroup's memory.max", () =>
118 Effect.gen(function* () {
119 setup();
120 const snapshot = yield* read;
121
122 assert.strictEqual(snapshot.totalMemoryBytes, GiB);
123 // 600 MiB charged, of which 100 MiB is reclaimable page cache.
124 assert.strictEqual(snapshot.availableMemoryBytes, GiB - 500 * MiB);
125 }).pipe(
126 Effect.provide(
127 linuxHost({
128 ...unlimitedCgroup,
129 [`${cgroup}/memory.max`]: [`${GiB}\n`],
130 }),
131 ),
132 ),
133 );
134
135 it.effect("counts a busy host against an idle, quota-limited server cgroup", () =>
136 Effect.gen(function* () {
137 // Agents in sibling cgroups keep the host 90% busy; the server's own cgroup is idle.
138 setup(0.9);
139 const snapshot = yield* read;
140
141 assert.strictEqual(snapshot.cpuCount, 2);
142 // Only 0.8 of 8 host CPUs are free, which is 60% of the 2-CPU quota in use.
143 assert.closeTo(snapshot.cpuUtilization ?? Number.NaN, 0.6, 1e-9);
144 }).pipe(
145 Effect.provide(
146 linuxHost({
147 ...unlimitedCgroup,
148 [`${cgroup}/cpu.max`]: ["200000 100000\n"],
149 }),
150 ),
151 ),
152 );
153
154 it.effect("samples the CPUs the server is pinned to by id, not by os.cpus() index", () =>
155 Effect.gen(function* () {
156 setup();
157 const snapshot = yield* read;
158
159 assert.strictEqual(snapshot.cpuCount, 1);
160 assert.strictEqual(snapshot.cpuUtilization, 1);
161 }).pipe(
162 Effect.provide(
163 linuxHost({
164 ...unlimitedCgroup,
165 "/proc/self/status": ["Name:\tnode\nCpus_allowed_list:\t4\n"],
166 // CPU 1 is offline; the server's CPU 4 is saturated, everything else is idle.
167 "/proc/stat": [0, 100].map((elapsed) =>
168 [0, 2, 3, 4, 5, 6, 7]
169 .map((id) =>
170 id === 4
171 ? `cpu${id} ${elapsed} 0 0 0 0 0 0 0`
172 : `cpu${id} 0 0 0 ${elapsed} 0 0 0 0`,
173 )
174 .join("\n"),
175 ),
176 }),
177 ),
178 ),
179 );
180
181 it.effect("keeps host memory when the cgroup's memory.current is unreadable", () =>
182 Effect.gen(function* () {
183 setup();
184 const snapshot = yield* read;
185
186 assert.strictEqual(snapshot.totalMemoryBytes, 4 * GiB);
187 assert.strictEqual(snapshot.availableMemoryBytes, 2 * GiB);
188 }).pipe(
189 Effect.provide(
190 linuxHost({
191 ...unlimitedCgroup,
192 [`${cgroup}/memory.max`]: [`${GiB}\n`],
193 [`${cgroup}/memory.current`]: [""],
194 }),
195 ),
196 ),
197 );
198
199 it.effect("keeps host values when the cgroup has no limits", () =>
200 Effect.gen(function* () {
201 setup(0.25);
202 const snapshot = yield* read;
203
204 assert.strictEqual(snapshot.cpuCount, 8);
205 assert.closeTo(snapshot.cpuUtilization ?? Number.NaN, 0.25, 1e-9);
206 assert.strictEqual(snapshot.availableMemoryBytes, 2 * GiB);
207 }).pipe(Effect.provide(linuxHost(unlimitedCgroup))),
208 );
209
210 it.effect("falls back to host load when the cgroup's cpu.stat is unreadable", () =>
211 Effect.gen(function* () {
212 setup(0.9);
213 const snapshot = yield* read;
214
215 assert.strictEqual(snapshot.cpuCount, 2);
216 assert.closeTo(snapshot.cpuUtilization ?? Number.NaN, 0.6, 1e-9);
217 }).pipe(
218 Effect.provide(
219 linuxHost({
220 ...unlimitedCgroup,
221 [`${cgroup}/cpu.max`]: ["200000 100000\n"],
222 [`${cgroup}/cpu.stat`]: [""],
223 }),
224 ),
225 ),
226 );
227});