| 1 | import * as NodeOS from "node:os"; |
| 2 | import type { HostResourcesSnapshot } from "@t3tools/contracts"; |
| 3 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 4 | import * as Cache from "effect/Cache"; |
| 5 | import * as Context from "effect/Context"; |
| 6 | import * as DateTime from "effect/DateTime"; |
| 7 | import * as Effect from "effect/Effect"; |
| 8 | import * as FileSystem from "effect/FileSystem"; |
| 9 | import * as Layer from "effect/Layer"; |
| 10 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; |
| 11 | |
| 12 | export class HostResources extends Context.Service< |
| 13 | HostResources, |
| 14 | { readonly read: Effect.Effect<HostResourcesSnapshot> } |
| 15 | >()("t3/resourceTelemetry/HostResources") {} |
| 16 | |
| 17 | function 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 | |
| 29 | function 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 | |
| 41 | const 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 | |
| 93 | export const layer = Layer.effect(HostResources, make()); |