Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 93 additions & 1 deletion apps/server/src/diagnostics/ProcessDiagnostics.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it } from "@effect/vitest";
import { assert, describe, expect, it } from "@effect/vitest";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
Expand Down Expand Up @@ -67,6 +67,98 @@ describe("ProcessDiagnostics", () => {
}),
);

it.effect("parses Windows CIM JSON process rows", () =>
Effect.sync(() => {
const rows = ProcessDiagnostics.parseWindowsProcessRows(`[
{
"ProcessId": 10,
"ParentProcessId": 1,
"Name": "node.exe",
"CommandLine": "node server.js",
"Status": "Running",
"WorkingSetSize": 4096,
"PercentProcessorTime": 12.5
},
{
"ProcessId": "invalid",
"ParentProcessId": 10,
"Name": "ignored.exe"
},
{
"ProcessId": 11,
"ParentProcessId": 10,
"Name": "agent.exe",
"CommandLine": null,
"Status": null,
"WorkingSetSize": -128,
"PercentProcessorTime": -1
}
]`);

assert.deepStrictEqual(rows, [
{
pid: 10,
ppid: 1,
pgid: null,
status: "Running",
cpuPercent: 12.5,
rssBytes: 4096,
elapsed: "",
command: "node server.js",
},
{
pid: 11,
ppid: 10,
pgid: null,
status: "Live",
cpuPercent: 0,
rssBytes: 0,
elapsed: "",
command: "agent.exe",
},
]);
}),
);

it.effect("parses single Windows CIM JSON process objects", () =>
Effect.sync(() => {
const rows = ProcessDiagnostics.parseWindowsProcessRows(`{
"ProcessId": 42,
"ParentProcessId": 10,
"Name": "agent.exe",
"CommandLine": "",
"WorkingSetSize": 2048,
"PercentProcessorTime": 5
}`);

assert.deepStrictEqual(rows, [
{
pid: 42,
ppid: 10,
pgid: null,
status: "Live",
cpuPercent: 5,
rssBytes: 2048,
elapsed: "",
command: "agent.exe",
},
]);
}),
);

it.effect("ignores malformed Windows CIM JSON output", () =>
Effect.sync(() => {
assert.deepStrictEqual(ProcessDiagnostics.parseWindowsProcessRows("{not json"), []);
assert.deepStrictEqual(
ProcessDiagnostics.parseWindowsProcessRows(`{
"ProcessId": 42,
"Name": "missing-parent.exe"
}`),
[],
);
}),
);

it.effect("aggregates only descendants of the server process", () =>
Effect.sync(() => {
const diagnostics = ProcessDiagnostics.aggregateProcessDiagnostics({
Expand Down
102 changes: 59 additions & 43 deletions apps/server/src/diagnostics/ProcessDiagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export interface ProcessRow {
readonly command: string;
}

const PROCESS_QUERY_TIMEOUT_MS = 1_000;
const PROCESS_QUERY_TIMEOUT = Duration.seconds(1);
const POSIX_PROCESS_QUERY_COMMAND = "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command=";
const PROCESS_QUERY_MAX_OUTPUT_BYTES = 2 * 1024 * 1024;

Expand Down Expand Up @@ -121,6 +121,19 @@ const ProcessDiagnosticsError = Schema.Union([
type ProcessDiagnosticsError = typeof ProcessDiagnosticsError.Type;
const isProcessDiagnosticsError = Schema.is(ProcessDiagnosticsError);

const WindowsCimProcessRecord = Schema.Struct({
ProcessId: Schema.Number,
ParentProcessId: Schema.Number,
Name: Schema.optional(Schema.NullOr(Schema.String)),
CommandLine: Schema.optional(Schema.NullOr(Schema.String)),
Status: Schema.optional(Schema.NullOr(Schema.String)),
WorkingSetSize: Schema.optional(Schema.Number),
PercentProcessorTime: Schema.optional(Schema.Number),
});
type WindowsCimProcessRecord = typeof WindowsCimProcessRecord.Type;
const decodeWindowsProcessJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Json));
const decodeWindowsProcessRecord = Schema.decodeUnknownOption(WindowsCimProcessRecord);

function parsePositiveInt(value: string): number | null {
const parsed = Number.parseInt(value, 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
Expand Down Expand Up @@ -201,51 +214,54 @@ export function parsePosixProcessRows(output: string): ReadonlyArray<ProcessRow>
return rows;
}

function normalizeWindowsProcessRow(value: unknown): ProcessRow | null {
if (typeof value !== "object" || value === null) return null;
const record = value as Record<string, unknown>;
const pid = typeof record.ProcessId === "number" ? record.ProcessId : null;
const ppid = typeof record.ParentProcessId === "number" ? record.ParentProcessId : null;
const commandLine =
typeof record.CommandLine === "string" && record.CommandLine.trim().length > 0
? record.CommandLine
: typeof record.Name === "string"
? record.Name
: null;
const workingSet =
typeof record.WorkingSetSize === "number" && Number.isFinite(record.WorkingSetSize)
? Math.max(0, Math.round(record.WorkingSetSize))
: 0;
const cpuPercent =
typeof record.PercentProcessorTime === "number" && Number.isFinite(record.PercentProcessorTime)
? Math.max(0, record.PercentProcessorTime)
: 0;

if (!pid || pid <= 0 || ppid === null || ppid < 0 || !commandLine) return null;
return {
pid,
ppid,
pgid: null,
status: typeof record.Status === "string" && record.Status.length > 0 ? record.Status : "Live",
cpuPercent,
rssBytes: workingSet,
elapsed: "",
command: commandLine,
};
function nonEmptyStringOption(value: string | null | undefined): Option.Option<string> {
return typeof value === "string" && value.trim().length > 0 ? Option.some(value) : Option.none();
}

function parseWindowsProcessRows(output: string): ReadonlyArray<ProcessRow> {
function normalizeWindowsProcessRow(value: unknown): Option.Option<ProcessRow> {
return Option.flatMap(decodeWindowsProcessRecord(value), (record: WindowsCimProcessRecord) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium diagnostics/ProcessDiagnostics.ts:222

normalizeWindowsProcessRow runs the whole record through decodeWindowsProcessRecord, whose schema treats WorkingSetSize, PercentProcessorTime, and Status as typed optional fields. A record like { "ProcessId": 42, "ParentProcessId": 1, "Name": "node.exe", "WorkingSetSize": "4096" } previously produced a row with rssBytes: 0, but now decodeWindowsProcessRecord returns None because WorkingSetSize is a string, so the entire process row is silently dropped. Consider decoding only the required fields (ProcessId, ParentProcessId) strictly and reading the optional fields defensively, as the previous implementation did.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/diagnostics/ProcessDiagnostics.ts around line 222:

`normalizeWindowsProcessRow` runs the whole record through `decodeWindowsProcessRecord`, whose schema treats `WorkingSetSize`, `PercentProcessorTime`, and `Status` as typed optional fields. A record like `{ "ProcessId": 42, "ParentProcessId": 1, "Name": "node.exe", "WorkingSetSize": "4096" }` previously produced a row with `rssBytes: 0`, but now `decodeWindowsProcessRecord` returns `None` because `WorkingSetSize` is a string, so the entire process row is silently dropped. Consider decoding only the required fields (`ProcessId`, `ParentProcessId`) strictly and reading the optional fields defensively, as the previous implementation did.

const pid = record.ProcessId;
const ppid = record.ParentProcessId;
if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(ppid) || ppid < 0) {
return Option.none();
}

const commandLine = nonEmptyStringOption(record.CommandLine).pipe(
Option.orElse(() => nonEmptyStringOption(record.Name)),
);
return Option.map(commandLine, (command) => ({
pid,
ppid,
pgid: null,
status: Option.getOrElse(nonEmptyStringOption(record.Status), () => "Live"),
cpuPercent:
typeof record.PercentProcessorTime === "number" &&
Number.isFinite(record.PercentProcessorTime)
? Math.max(0, record.PercentProcessorTime)
: 0,
rssBytes:
typeof record.WorkingSetSize === "number" && Number.isFinite(record.WorkingSetSize)
? Math.max(0, Math.round(record.WorkingSetSize))
: 0,
elapsed: "",
command,
}));
});
}

export function parseWindowsProcessRows(output: string): ReadonlyArray<ProcessRow> {
if (output.trim().length === 0) return [];
try {
const parsed = JSON.parse(output) as unknown;
const records = Array.isArray(parsed) ? parsed : [parsed];
return records.flatMap((record) => {
const row = normalizeWindowsProcessRow(record);
return row ? [row] : [];
});
} catch {
const parsed = decodeWindowsProcessJson(output);
if (Option.isNone(parsed)) {
return [];
}
const records = Array.isArray(parsed.value) ? parsed.value : [parsed.value];
return records.flatMap((record) =>
Option.match(normalizeWindowsProcessRow(record), {
onNone: () => [],
onSome: (row) => [row],
}),
);
}

export function buildDescendantEntries(
Expand Down Expand Up @@ -381,7 +397,7 @@ const runProcess = Effect.fn("runProcess")(function* (input: {
} satisfies ProcessOutput;
}).pipe(
Effect.scoped,
Effect.timeoutOption(Duration.millis(PROCESS_QUERY_TIMEOUT_MS)),
Effect.timeoutOption(PROCESS_QUERY_TIMEOUT),
Effect.flatMap((result) =>
Option.match(result, {
onNone: () =>
Expand All @@ -390,7 +406,7 @@ const runProcess = Effect.fn("runProcess")(function* (input: {
command: input.command,
argCount: input.args.length,
cwd,
timeoutMillis: PROCESS_QUERY_TIMEOUT_MS,
timeoutMillis: Duration.toMillis(PROCESS_QUERY_TIMEOUT),
}),
),
onSome: Effect.succeed,
Expand Down
Loading