Showing the changes to apps/server/src (2 of 2 files). Show all
Unified · Split
| @@ -1,4 +1,4 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { assert, describe, expect, it } from "@effect/vitest"; |
| 2 | import * as DateTime from "effect/DateTime"; | 2 | import * as DateTime from "effect/DateTime"; |
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; | 4 | import * as Layer from "effect/Layer"; |
| @@ -67,6 +67,98 @@ describe("ProcessDiagnostics", () => { · 62 unmodified lines | |||
| 67 | }), | 67 | }), |
| 68 | ); | 68 | ); |
| 69 | 69 | ||
| 70 | it.effect("parses Windows CIM JSON process rows", () => | ||
| 71 | Effect.sync(() => { | ||
| 72 | const rows = ProcessDiagnostics.parseWindowsProcessRows(`[ | ||
| 73 | { | ||
| 74 | "ProcessId": 10, | ||
| 75 | "ParentProcessId": 1, | ||
| 76 | "Name": "node.exe", | ||
| 77 | "CommandLine": "node server.js", | ||
| 78 | "Status": "Running", | ||
| 79 | "WorkingSetSize": 4096, | ||
| 80 | "PercentProcessorTime": 12.5 | ||
| 81 | }, | ||
| 82 | { | ||
| 83 | "ProcessId": "invalid", | ||
| 84 | "ParentProcessId": 10, | ||
| 85 | "Name": "ignored.exe" | ||
| 86 | }, | ||
| 87 | { | ||
| 88 | "ProcessId": 11, | ||
| 89 | "ParentProcessId": 10, | ||
| 90 | "Name": "agent.exe", | ||
| 91 | "CommandLine": null, | ||
| 92 | "Status": null, | ||
| 93 | "WorkingSetSize": -128, | ||
| 94 | "PercentProcessorTime": -1 | ||
| 95 | } | ||
| 96 | ]`); | ||
| 97 | |||
| 98 | assert.deepStrictEqual(rows, [ | ||
| 99 | { | ||
| 100 | pid: 10, | ||
| 101 | ppid: 1, | ||
| 102 | pgid: null, | ||
| 103 | status: "Running", | ||
| 104 | cpuPercent: 12.5, | ||
| 105 | rssBytes: 4096, | ||
| 106 | elapsed: "", | ||
| 107 | command: "node server.js", | ||
| 108 | }, | ||
| 109 | { | ||
| 110 | pid: 11, | ||
| 111 | ppid: 10, | ||
| 112 | pgid: null, | ||
| 113 | status: "Live", | ||
| 114 | cpuPercent: 0, | ||
| 115 | rssBytes: 0, | ||
| 116 | elapsed: "", | ||
| 117 | command: "agent.exe", | ||
| 118 | }, | ||
| 119 | ]); | ||
| 120 | }), | ||
| 121 | ); | ||
| 122 | |||
| 123 | it.effect("parses single Windows CIM JSON process objects", () => | ||
| 124 | Effect.sync(() => { | ||
| 125 | const rows = ProcessDiagnostics.parseWindowsProcessRows(`{ | ||
| 126 | "ProcessId": 42, | ||
| 127 | "ParentProcessId": 10, | ||
| 128 | "Name": "agent.exe", | ||
| 129 | "CommandLine": "", | ||
| 130 | "WorkingSetSize": 2048, | ||
| 131 | "PercentProcessorTime": 5 | ||
| 132 | }`); | ||
| 133 | |||
| 134 | assert.deepStrictEqual(rows, [ | ||
| 135 | { | ||
| 136 | pid: 42, | ||
| 137 | ppid: 10, | ||
| 138 | pgid: null, | ||
| 139 | status: "Live", | ||
| 140 | cpuPercent: 5, | ||
| 141 | rssBytes: 2048, | ||
| 142 | elapsed: "", | ||
| 143 | command: "agent.exe", | ||
| 144 | }, | ||
| 145 | ]); | ||
| 146 | }), | ||
| 147 | ); | ||
| 148 | |||
| 149 | it.effect("ignores malformed Windows CIM JSON output", () => | ||
| 150 | Effect.sync(() => { | ||
| 151 | assert.deepStrictEqual(ProcessDiagnostics.parseWindowsProcessRows("{not json"), []); | ||
| 152 | assert.deepStrictEqual( | ||
| 153 | ProcessDiagnostics.parseWindowsProcessRows(`{ | ||
| 154 | "ProcessId": 42, | ||
| 155 | "Name": "missing-parent.exe" | ||
| 156 | }`), | ||
| 157 | [], | ||
| 158 | ); | ||
| 159 | }), | ||
| 160 | ); | ||
| 161 | |||
| 70 | it.effect("aggregates only descendants of the server process", () => | 162 | it.effect("aggregates only descendants of the server process", () => |
| 71 | Effect.sync(() => { | 163 | Effect.sync(() => { |
| 72 | const diagnostics = ProcessDiagnostics.aggregateProcessDiagnostics({ | 164 | const diagnostics = ProcessDiagnostics.aggregateProcessDiagnostics({ |
| @@ -28,7 +28,7 @@ export interface ProcessRow { · 27 unmodified lines | |||
| 28 | readonly command: string; | 28 | readonly command: string; |
| 29 | } | 29 | } |
| 30 | 30 | ||
| 31 | const PROCESS_QUERY_TIMEOUT_MS = 1_000; | 31 | const PROCESS_QUERY_TIMEOUT = Duration.seconds(1); |
| 32 | const POSIX_PROCESS_QUERY_COMMAND = "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command="; | 32 | const POSIX_PROCESS_QUERY_COMMAND = "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command="; |
| 33 | const PROCESS_QUERY_MAX_OUTPUT_BYTES = 2 * 1024 * 1024; | 33 | const PROCESS_QUERY_MAX_OUTPUT_BYTES = 2 * 1024 * 1024; |
| 34 | 34 | ||
| @@ -121,6 +121,19 @@ const ProcessDiagnosticsError = Schema.Union([ · 86 unmodified lines | |||
| 121 | type ProcessDiagnosticsError = typeof ProcessDiagnosticsError.Type; | 121 | type ProcessDiagnosticsError = typeof ProcessDiagnosticsError.Type; |
| 122 | const isProcessDiagnosticsError = Schema.is(ProcessDiagnosticsError); | 122 | const isProcessDiagnosticsError = Schema.is(ProcessDiagnosticsError); |
| 123 | 123 | ||
| 124 | const WindowsCimProcessRecord = Schema.Struct({ | ||
| 125 | ProcessId: Schema.Number, | ||
| 126 | ParentProcessId: Schema.Number, | ||
| 127 | Name: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 128 | CommandLine: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 129 | Status: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 130 | WorkingSetSize: Schema.optional(Schema.Number), | ||
| 131 | PercentProcessorTime: Schema.optional(Schema.Number), | ||
| 132 | }); | ||
| 133 | type WindowsCimProcessRecord = typeof WindowsCimProcessRecord.Type; | ||
| 134 | const decodeWindowsProcessJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Json)); | ||
| 135 | const decodeWindowsProcessRecord = Schema.decodeUnknownOption(WindowsCimProcessRecord); | ||
| 136 | |||
| 124 | function parsePositiveInt(value: string): number | null { | 137 | function parsePositiveInt(value: string): number | null { |
| 125 | const parsed = Number.parseInt(value, 10); | 138 | const parsed = Number.parseInt(value, 10); |
| 126 | return Number.isInteger(parsed) && parsed > 0 ? parsed : null; | 139 | return Number.isInteger(parsed) && parsed > 0 ? parsed : null; |
| @@ -201,51 +214,53 @@ export function parsePosixProcessRows(output: string): ReadonlyArray<ProcessRow> · 74 unmodified lines | |||
| 201 | return rows; | 214 | return rows; |
| 202 | } | 215 | } |
| 203 | 216 | ||
| 204 | function normalizeWindowsProcessRow(value: unknown): ProcessRow | null { | 217 | function nonEmptyStringOption(value: string | null | undefined): Option.Option<string> { |
| 205 | if (typeof value !== "object" || value === null) return null; | 218 | return typeof value === "string" && value.trim().length > 0 ? Option.some(value) : Option.none(); |
| 206 | const record = value as Record<string, unknown>; | ||
| 207 | const pid = typeof record.ProcessId === "number" ? record.ProcessId : null; | ||
| 208 | const ppid = typeof record.ParentProcessId === "number" ? record.ParentProcessId : null; | ||
| 209 | const commandLine = | ||
| 210 | typeof record.CommandLine === "string" && record.CommandLine.trim().length > 0 | ||
| 211 | ? record.CommandLine | ||
| 212 | : typeof record.Name === "string" | ||
| 213 | ? record.Name | ||
| 214 | : null; | ||
| 215 | const workingSet = | ||
| 216 | typeof record.WorkingSetSize === "number" && Number.isFinite(record.WorkingSetSize) | ||
| 217 | ? Math.max(0, Math.round(record.WorkingSetSize)) | ||
| 218 | : 0; | ||
| 219 | const cpuPercent = | ||
| 220 | typeof record.PercentProcessorTime === "number" && Number.isFinite(record.PercentProcessorTime) | ||
| 221 | ? Math.max(0, record.PercentProcessorTime) | ||
| 222 | : 0; | ||
| 223 | |||
| 224 | if (!pid || pid <= 0 || ppid === null || ppid < 0 || !commandLine) return null; | ||
| 225 | return { | ||
| 226 | pid, | ||
| 227 | ppid, | ||
| 228 | pgid: null, | ||
| 229 | status: typeof record.Status === "string" && record.Status.length > 0 ? record.Status : "Live", | ||
| 230 | cpuPercent, | ||
| 231 | rssBytes: workingSet, | ||
| 232 | elapsed: "", | ||
| 233 | command: commandLine, | ||
| 234 | }; | ||
| 235 | } | 219 | } |
| 236 | 220 | ||
| 237 | function parseWindowsProcessRows(output: string): ReadonlyArray<ProcessRow> { | 221 | function normalizeWindowsProcessRow(value: unknown): Option.Option<ProcessRow> { |
| 222 | return Option.flatMap(decodeWindowsProcessRecord(value), (record: WindowsCimProcessRecord) => { | ||
| 223 | const pid = record.ProcessId; | ||
| 224 | const ppid = record.ParentProcessId; | ||
| 225 | if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(ppid) || ppid < 0) { | ||
| 226 | return Option.none(); | ||
| 227 | } | ||
| 228 | |||
| 229 | const commandLine = nonEmptyStringOption(record.CommandLine).pipe( | ||
| 230 | Option.orElse(() => nonEmptyStringOption(record.Name)), | ||
| 231 | ); | ||
| 232 | return Option.map(commandLine, (command) => ({ | ||
| 233 | pid, | ||
| 234 | ppid, | ||
| 235 | pgid: null, | ||
| 236 | status: Option.getOrElse(nonEmptyStringOption(record.Status), () => "Live"), | ||
| 237 | cpuPercent: | ||
| 238 | typeof record.PercentProcessorTime === "number" && Number.isFinite(record.PercentProcessorTime) | ||
| 239 | ? Math.max(0, record.PercentProcessorTime) | ||
| 240 | : 0, | ||
| 241 | rssBytes: | ||
| 242 | typeof record.WorkingSetSize === "number" && Number.isFinite(record.WorkingSetSize) | ||
| 243 | ? Math.max(0, Math.round(record.WorkingSetSize)) | ||
| 244 | : 0, | ||
| 245 | elapsed: "", | ||
| 246 | command, | ||
| 247 | })); | ||
| 248 | }); | ||
| 249 | } | ||
| 250 | |||
| 251 | export function parseWindowsProcessRows(output: string): ReadonlyArray<ProcessRow> { | ||
| 238 | if (output.trim().length === 0) return []; | 252 | if (output.trim().length === 0) return []; |
| 239 | try { | 253 | const parsed = decodeWindowsProcessJson(output); |
| 240 | const parsed = JSON.parse(output) as unknown; | 254 | if (Option.isNone(parsed)) { |
| 241 | const records = Array.isArray(parsed) ? parsed : [parsed]; | ||
| 242 | return records.flatMap((record) => { | ||
| 243 | const row = normalizeWindowsProcessRow(record); | ||
| 244 | return row ? [row] : []; | ||
| 245 | }); | ||
| 246 | } catch { | ||
| 247 | return []; | 255 | return []; |
| 248 | } | 256 | } |
| 257 | const records = Array.isArray(parsed.value) ? parsed.value : [parsed.value]; | ||
| 258 | return records.flatMap((record) => | ||
| 259 | Option.match(normalizeWindowsProcessRow(record), { | ||
| 260 | onNone: () => [], | ||
| 261 | onSome: (row) => [row], | ||
| 262 | }), | ||
| 263 | ); | ||
| 249 | } | 264 | } |
| 250 | 265 | ||
| 251 | export function buildDescendantEntries( | 266 | export function buildDescendantEntries( |
| @@ -381,7 +396,7 @@ const runProcess = Effect.fn("runProcess")(function* (input: { · 129 unmodified lines | |||
| 381 | } satisfies ProcessOutput; | 396 | } satisfies ProcessOutput; |
| 382 | }).pipe( | 397 | }).pipe( |
| 383 | Effect.scoped, | 398 | Effect.scoped, |
| 384 | Effect.timeoutOption(Duration.millis(PROCESS_QUERY_TIMEOUT_MS)), | 399 | Effect.timeoutOption(PROCESS_QUERY_TIMEOUT), |
| 385 | Effect.flatMap((result) => | 400 | Effect.flatMap((result) => |
| 386 | Option.match(result, { | 401 | Option.match(result, { |
| 387 | onNone: () => | 402 | onNone: () => |
| @@ -390,7 +405,7 @@ const runProcess = Effect.fn("runProcess")(function* (input: { · 2 unmodified lines | |||
| 390 | command: input.command, | 405 | command: input.command, |
| 391 | argCount: input.args.length, | 406 | argCount: input.args.length, |
| 392 | cwd, | 407 | cwd, |
| 393 | timeoutMillis: PROCESS_QUERY_TIMEOUT_MS, | 408 | timeoutMillis: Duration.toMillis(PROCESS_QUERY_TIMEOUT), |
| 394 | }), | 409 | }), |
| 395 | ), | 410 | ), |
| 396 | onSome: Effect.succeed, | 411 | onSome: Effect.succeed, |