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 | 2 | import * as DateTime from "effect/DateTime"; |
| 3 | 3 | import * as Effect from "effect/Effect"; |
| 4 | 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 | 162 | it.effect("aggregates only descendants of the server process", () => |
| 71 | 163 | Effect.sync(() => { |
| 72 | 164 | const diagnostics = ProcessDiagnostics.aggregateProcessDiagnostics({ |
| @@ -28,7 +28,7 @@ export interface ProcessRow { · 27 unmodified lines | ||
| 28 | 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 | 32 | const POSIX_PROCESS_QUERY_COMMAND = "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command="; |
| 33 | 33 | const PROCESS_QUERY_MAX_OUTPUT_BYTES = 2 * 1024 * 1024; |
| 34 | 34 | |
| @@ -121,6 +121,19 @@ const ProcessDiagnosticsError = Schema.Union([ · 86 unmodified lines | ||
| 121 | 121 | type ProcessDiagnosticsError = typeof ProcessDiagnosticsError.Type; |
| 122 | 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 | 137 | function parsePositiveInt(value: string): number | null { |
| 125 | 138 | const parsed = Number.parseInt(value, 10); |
| 126 | 139 | return Number.isInteger(parsed) && parsed > 0 ? parsed : null; |
| @@ -201,51 +214,53 @@ export function parsePosixProcessRows(output: string): ReadonlyArray<ProcessRow> · 74 unmodified lines | ||
| 201 | 214 | return rows; |
| 202 | 215 | } |
| 203 | 216 | |
| 204 | −function normalizeWindowsProcessRow(value: unknown): ProcessRow | null { | |
| 205 | − if (typeof value !== "object" || value === null) return null; | |
| 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 | − }; | |
| 217 | +function nonEmptyStringOption(value: string | null | undefined): Option.Option<string> { | |
| 218 | + return typeof value === "string" && value.trim().length > 0 ? Option.some(value) : Option.none(); | |
| 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 | 252 | if (output.trim().length === 0) return []; |
| 239 | − try { | |
| 240 | − const parsed = JSON.parse(output) as unknown; | |
| 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 { | |
| 253 | + const parsed = decodeWindowsProcessJson(output); | |
| 254 | + if (Option.isNone(parsed)) { | |
| 247 | 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 | 266 | export function buildDescendantEntries( |
| @@ -381,7 +396,7 @@ const runProcess = Effect.fn("runProcess")(function* (input: { · 129 unmodified lines | ||
| 381 | 396 | } satisfies ProcessOutput; |
| 382 | 397 | }).pipe( |
| 383 | 398 | Effect.scoped, |
| 384 | − Effect.timeoutOption(Duration.millis(PROCESS_QUERY_TIMEOUT_MS)), | |
| 399 | + Effect.timeoutOption(PROCESS_QUERY_TIMEOUT), | |
| 385 | 400 | Effect.flatMap((result) => |
| 386 | 401 | Option.match(result, { |
| 387 | 402 | onNone: () => |
| @@ -390,7 +405,7 @@ const runProcess = Effect.fn("runProcess")(function* (input: { · 2 unmodified lines | ||
| 390 | 405 | command: input.command, |
| 391 | 406 | argCount: input.args.length, |
| 392 | 407 | cwd, |
| 393 | − timeoutMillis: PROCESS_QUERY_TIMEOUT_MS, | |
| 408 | + timeoutMillis: Duration.toMillis(PROCESS_QUERY_TIMEOUT), | |
| 394 | 409 | }), |
| 395 | 410 | ), |
| 396 | 411 | onSome: Effect.succeed, |