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 @@
1−import { describe, expect, it } from "@effect/vitest";
1+import { assert, describe, expect, it } from "@effect/vitest";
22 import * as DateTime from "effect/DateTime";
33 import * as Effect from "effect/Effect";
44 import * as Layer from "effect/Layer";
@@ -67,6 +67,98 @@ describe("ProcessDiagnostics", () => { · 62 unmodified lines
6767 }),
6868 );
6969
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+
70162 it.effect("aggregates only descendants of the server process", () =>
71163 Effect.sync(() => {
72164 const diagnostics = ProcessDiagnostics.aggregateProcessDiagnostics({
apps/server/src/diagnostics/ProcessDiagnostics.ts+58−43
@@ -28,7 +28,7 @@ export interface ProcessRow { · 27 unmodified lines
2828 readonly command: string;
2929 }
3030
31−const PROCESS_QUERY_TIMEOUT_MS = 1_000;
31+const PROCESS_QUERY_TIMEOUT = Duration.seconds(1);
3232 const POSIX_PROCESS_QUERY_COMMAND = "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command=";
3333 const PROCESS_QUERY_MAX_OUTPUT_BYTES = 2 * 1024 * 1024;
3434
@@ -121,6 +121,19 @@ const ProcessDiagnosticsError = Schema.Union([ · 86 unmodified lines
121121 type ProcessDiagnosticsError = typeof ProcessDiagnosticsError.Type;
122122 const isProcessDiagnosticsError = Schema.is(ProcessDiagnosticsError);
123123
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+
124137 function parsePositiveInt(value: string): number | null {
125138 const parsed = Number.parseInt(value, 10);
126139 return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
@@ -201,51 +214,53 @@ export function parsePosixProcessRows(output: string): ReadonlyArray<ProcessRow> · 74 unmodified lines
201214 return rows;
202215 }
203216
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();
235219 }
236220
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> {
238252 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)) {
247255 return [];
248256 }
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+ );
249264 }
250265
251266 export function buildDescendantEntries(
@@ -381,7 +396,7 @@ const runProcess = Effect.fn("runProcess")(function* (input: { · 129 unmodified lines
381396 } satisfies ProcessOutput;
382397 }).pipe(
383398 Effect.scoped,
384− Effect.timeoutOption(Duration.millis(PROCESS_QUERY_TIMEOUT_MS)),
399+ Effect.timeoutOption(PROCESS_QUERY_TIMEOUT),
385400 Effect.flatMap((result) =>
386401 Option.match(result, {
387402 onNone: () =>
@@ -390,7 +405,7 @@ const runProcess = Effect.fn("runProcess")(function* (input: { · 2 unmodified lines
390405 command: input.command,
391406 argCount: input.args.length,
392407 cwd,
393− timeoutMillis: PROCESS_QUERY_TIMEOUT_MS,
408+ timeoutMillis: Duration.toMillis(PROCESS_QUERY_TIMEOUT),
394409 }),
395410 ),
396411 onSome: Effect.succeed,