History

Use Effect Schema for Windows process diagnostics

Cursor Agent committed 4 months ago

Commit f3da91a1 parent a9b11902 files · +151 −44Browse files

Unified · Split

2 files
  1. apps/server/src/diagnostics/ProcessDiagnostics.test.ts +93 −1
  2. apps/server/src/diagnostics/ProcessDiagnostics.ts +58 −43
apps/server/src/diagnostics/ProcessDiagnostics.test.ts+93−1
@@ -1,4 +1,4 @@
1import { describe, expect, it } from "@effect/vitest";1import { assert, describe, expect, it } from "@effect/vitest";
2import * as DateTime from "effect/DateTime";2import * as DateTime from "effect/DateTime";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";4import * 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({
apps/server/src/diagnostics/ProcessDiagnostics.ts+58−43
@@ -28,7 +28,7 @@ export interface ProcessRow { · 27 unmodified lines
28 readonly command: string;28 readonly command: string;
29}29}
30 30
31const PROCESS_QUERY_TIMEOUT_MS = 1_000;31const PROCESS_QUERY_TIMEOUT = Duration.seconds(1);
32const POSIX_PROCESS_QUERY_COMMAND = "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command=";32const POSIX_PROCESS_QUERY_COMMAND = "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command=";
33const PROCESS_QUERY_MAX_OUTPUT_BYTES = 2 * 1024 * 1024;33const PROCESS_QUERY_MAX_OUTPUT_BYTES = 2 * 1024 * 1024;
34 34
@@ -121,6 +121,19 @@ const ProcessDiagnosticsError = Schema.Union([ · 86 unmodified lines
121type ProcessDiagnosticsError = typeof ProcessDiagnosticsError.Type;121type ProcessDiagnosticsError = typeof ProcessDiagnosticsError.Type;
122const isProcessDiagnosticsError = Schema.is(ProcessDiagnosticsError);122const isProcessDiagnosticsError = Schema.is(ProcessDiagnosticsError);
123 123
124const 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});
133type WindowsCimProcessRecord = typeof WindowsCimProcessRecord.Type;
134const decodeWindowsProcessJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Json));
135const decodeWindowsProcessRecord = Schema.decodeUnknownOption(WindowsCimProcessRecord);
136
124function parsePositiveInt(value: string): number | null {137function 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
204function normalizeWindowsProcessRow(value: unknown): ProcessRow | null {217function 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
237function parseWindowsProcessRows(output: string): ReadonlyArray<ProcessRow> {221function 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
251export 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
251export function buildDescendantEntries(266export 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,