1import * as NodeOS from "node:os";
2import type { HostResourcesSnapshot } from "@t3tools/contracts";
3import * as HostProcess from "@t3tools/shared/HostProcess";
4import * as Cache from "effect/Cache";
5import * as Context from "effect/Context";
6import * as DateTime from "effect/DateTime";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Layer from "effect/Layer";
10import { ChildProcess, ChildProcessSpawner } from "effect/process";
11
12export class HostResources extends Context.Service<
13 HostResources,
14 { readonly read: Effect.Effect<HostResourcesSnapshot> }
15>()("t3/resourceTelemetry/HostResources") {}
16
17function readCpu() {
18 const cpus = NodeOS.cpus();
19 const cpu = cpus.reduce(
20 (sum, { times }) => ({
21 idle: sum.idle + times.idle,
22 total: sum.total + times.user + times.nice + times.sys + times.idle + times.irq,
23 }),
24 { idle: 0, total: 0 },
25 );
26 return { ...cpu, count: cpus.length };
27}
28
29function darwinAvailableMemory(output: string): number | null {
30 const pageSize = /page size of (\d+) bytes/.exec(output)?.[1];
31 const free = /^Pages free:\s+(\d+)\./m.exec(output)?.[1];
32 const inactive = /^Pages inactive:\s+(\d+)\./m.exec(output)?.[1];
33 const speculative = /^Pages speculative:\s+(\d+)\./m.exec(output)?.[1];
34 if (!pageSize || !free || !inactive || !speculative) return null;
35 // vm_stat subtracts speculative pages from its printed "Pages free" count.
36 // Adding them here counts each reclaimable page once; purgeable pages overlap.
37 const available = (Number(free) + Number(inactive) + Number(speculative)) * Number(pageSize);
38 return Number.isSafeInteger(available) && Number(pageSize) > 0 ? available : null;
39}
40
41const make = Effect.fn("makeHostResources")(function* () {
42 const fs = yield* FileSystem.FileSystem;
43 const platform = yield* HostProcess.Platform;
44 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
45
46 const sample = Effect.fn("HostResources.sample")(function* () {
47 const previousCpu = readCpu();
48 // CPU counters need two readings; idle servers do no polling or process scans.
49 yield* Effect.sleep("200 millis");
50 const cpu = readCpu();
51 const totalDelta = cpu.total - previousCpu.total;
52 const idleDelta = cpu.idle - previousCpu.idle;
53 const cpuUtilization =
54 previousCpu.count === cpu.count && totalDelta > 0 && idleDelta >= 0
55 ? Math.min(1, Math.max(0, 1 - idleDelta / totalDelta))
56 : null;
57 const totalMemoryBytes = NodeOS.totalmem();
58 // On Windows libuv returns GlobalMemoryStatusEx.ullAvailPhys, including standby memory.
59 let availableMemoryBytes = NodeOS.freemem();
60 if (platform === "linux") {
61 const meminfo = yield* fs
62 .readFileString("/proc/meminfo")
63 .pipe(Effect.orElseSucceed(() => ""));
64 const available = /^MemAvailable:\s+(\d+)\s+kB$/m.exec(meminfo)?.[1];
65 if (available) availableMemoryBytes = Number(available) * 1024;
66 } else if (platform === "darwin") {
67 const output = yield* spawner
68 .string(ChildProcess.make("/usr/bin/vm_stat", [], { stdin: "ignore", stderr: "ignore" }))
69 .pipe(
70 Effect.timeout("1 second"),
71 Effect.orElseSucceed(() => ""),
72 );
73 availableMemoryBytes = darwinAvailableMemory(output) ?? availableMemoryBytes;
74 }
75 return {
76 sampledAt: DateTime.toEpochMillis(yield* DateTime.now),
77 cpuUtilization,
78 cpuCount: cpu.count,
79 availableMemoryBytes: Math.min(totalMemoryBytes, Math.max(0, availableMemoryBytes)),
80 totalMemoryBytes,
81 };
82 });
83
84 // One server-lifetime cache deduplicates simultaneous requests from all sockets.
85 const cache = yield* Cache.make({
86 capacity: 1,
87 lookup: (_key: "host") => sample(),
88 timeToLive: "5 seconds",
89 });
90 return HostResources.of({ read: Cache.get(cache, "host") });
91});
92
93export const layer = Layer.effect(HostResources, make());