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
99 changes: 84 additions & 15 deletions 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, 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 @@ -41,7 +41,7 @@ describe("ProcessDiagnostics", () => {
].join("\n"),
);

expect(rows).toEqual([
assert.deepEqual(rows, [
{
pid: 10,
ppid: 1,
Expand Down Expand Up @@ -125,15 +125,21 @@ describe("ProcessDiagnostics", () => {
],
});

expect(diagnostics.serverPid).toBe(100);
expect(DateTime.formatIso(diagnostics.readAt)).toBe("2026-05-05T10:00:00.000Z");
expect(diagnostics.processCount).toBe(2);
expect(diagnostics.totalRssBytes).toBe(6_000);
expect(diagnostics.totalCpuPercent).toBe(4.75);
expect(diagnostics.processes.map((process) => process.pid)).toEqual([101, 102]);
expect(diagnostics.processes.map((process) => process.depth)).toEqual([0, 1]);
expect(Option.getOrNull(diagnostics.processes[0]!.pgid)).toBe(100);
expect(diagnostics.processes[0]?.childPids).toEqual([102]);
assert.equal(diagnostics.serverPid, 100);
assert.equal(DateTime.formatIso(diagnostics.readAt), "2026-05-05T10:00:00.000Z");
assert.equal(diagnostics.processCount, 2);
assert.equal(diagnostics.totalRssBytes, 6_000);
assert.equal(diagnostics.totalCpuPercent, 4.75);
assert.deepEqual(
diagnostics.processes.map((process) => process.pid),
[101, 102],
);
assert.deepEqual(
diagnostics.processes.map((process) => process.depth),
[0, 1],
);
assert.equal(Option.getOrNull(diagnostics.processes[0]!.pgid), 100);
assert.deepEqual(diagnostics.processes[0]?.childPids, [102]);
}),
);

Expand Down Expand Up @@ -176,7 +182,10 @@ describe("ProcessDiagnostics", () => {
],
});

expect(diagnostics.processes.map((process) => process.pid)).toEqual([101, 102, 103]);
assert.deepEqual(
diagnostics.processes.map((process) => process.pid),
[101, 102, 103],
);
}),
);

Expand Down Expand Up @@ -209,8 +218,11 @@ describe("ProcessDiagnostics", () => {
Effect.provide(layer),
);

expect(diagnostics.processes.map((process) => process.pid)).toEqual([4242]);
expect(commands).toEqual([
assert.deepEqual(
diagnostics.processes.map((process) => process.pid),
[4242],
);
assert.deepEqual(commands, [
{
command: "ps",
args: ["-axo", "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command="],
Expand All @@ -219,6 +231,63 @@ describe("ProcessDiagnostics", () => {
}),
);

it.effect("decodes Windows process rows from schema-backed JSON", () =>
Effect.gen(function* () {
const commands: Array<{ readonly command: string; readonly args: ReadonlyArray<string> }> =
[];
const spawnerLayer = Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make((command) => {
const childProcess = command as unknown as {
readonly command: string;
readonly args: ReadonlyArray<string>;
};
commands.push({ command: childProcess.command, args: childProcess.args });
return Effect.succeed(
mockHandle({
stdout: [
"[",
'{"ProcessId":4242,"ParentProcessId":1,"Name":"node.exe","CommandLine":"node server.js","Status":"Running","WorkingSetSize":2048,"PercentProcessorTime":12.5},',
'{"ProcessId":4243,"ParentProcessId":4242,"Name":"fallback.exe","CommandLine":"","Status":"","WorkingSetSize":-5,"PercentProcessorTime":-1},',
'{"ProcessId":0,"ParentProcessId":4242,"Name":"ignored.exe"}',
"]",
].join(""),
}),
);
}),
);

const rows = yield* ProcessDiagnostics.readProcessRows("win32").pipe(
Effect.provide(spawnerLayer),
);

assert.deepEqual(rows, [
{
pid: 4242,
ppid: 1,
pgid: null,
status: "Running",
cpuPercent: 12.5,
rssBytes: 2048,
elapsed: "",
command: "node server.js",
},
{
pid: 4243,
ppid: 4242,
pgid: null,
status: "Live",
cpuPercent: 0,
rssBytes: 0,
elapsed: "",
command: "fallback.exe",
},
]);
assert.equal(commands[0]?.command, "powershell.exe");
assert.match(commands[0]?.args.join(" ") ?? "", /Get-CimInstance Win32_Process/);
}),
);

it.effect("does not allow signaling the diagnostics query process", () =>
Effect.gen(function* () {
const spawnerLayer = Layer.succeed(
Expand All @@ -241,7 +310,7 @@ describe("ProcessDiagnostics", () => {
Effect.provide(layer),
);

expect(result).toEqual({
assert.deepEqual(result, {
pid: 4242,
signal: "SIGINT",
signaled: false,
Expand Down
101 changes: 64 additions & 37 deletions apps/server/src/diagnostics/ProcessDiagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,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 All @@ -52,6 +52,24 @@ class ProcessDiagnosticsError extends Schema.TaggedErrorClass<ProcessDiagnostics
) {}
const isProcessDiagnosticsError = Schema.is(ProcessDiagnosticsError);

const WindowsProcessRecordJson = Schema.Struct({
ProcessId: Schema.optional(Schema.Number),
ParentProcessId: Schema.optional(Schema.Number),
Name: Schema.optional(Schema.String),
CommandLine: Schema.optional(Schema.String),
Status: Schema.optional(Schema.String),
WorkingSetSize: Schema.optional(Schema.Number),
PercentProcessorTime: Schema.optional(Schema.Number),
});
Comment on lines +55 to +63
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.

🟠 High diagnostics/ProcessDiagnostics.ts:55

WindowsProcessRecordJson uses Schema.optional, which accepts string | undefined, but PowerShell outputs JSON null for fields like Status and CommandLine. JavaScript null fails validation, causing decodeWindowsProcessJson to return Option.none() and parseWindowsProcessRows to silently return an empty array instead of the process list. Consider using Schema.NullOr(Schema.String) for fields that can be null.

  const WindowsProcessRecordJson = Schema.Struct({
-  ProcessId: Schema.optional(Schema.Number),
-  ParentProcessId: Schema.optional(Schema.Number),
-  Name: Schema.optional(Schema.String),
-  CommandLine: Schema.optional(Schema.String),
-  Status: Schema.optional(Schema.String),
-  WorkingSetSize: Schema.optional(Schema.Number),
-  PercentProcessorTime: Schema.optional(Schema.Number),
+  ProcessId: Schema.NullOr(Schema.Number),
+  ParentProcessId: Schema.NullOr(Schema.Number),
+  Name: Schema.NullOr(Schema.String),
+  CommandLine: Schema.NullOr(Schema.String),
+  Status: Schema.NullOr(Schema.String),
+  WorkingSetSize: Schema.NullOr(Schema.Number),
+  PercentProcessorTime: Schema.NullOr(Schema.Number),
  });
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/server/src/diagnostics/ProcessDiagnostics.ts around lines 55-63:

`WindowsProcessRecordJson` uses `Schema.optional`, which accepts `string | undefined`, but PowerShell outputs JSON `null` for fields like `Status` and `CommandLine`. JavaScript `null` fails validation, causing `decodeWindowsProcessJson` to return `Option.none()` and `parseWindowsProcessRows` to silently return an empty array instead of the process list. Consider using `Schema.NullOr(Schema.String)` for fields that can be null.

Evidence trail:
- apps/server/src/diagnostics/ProcessDiagnostics.ts lines 55-63 (WindowsProcessRecordJson schema using Schema.optional)
- apps/server/src/diagnostics/ProcessDiagnostics.ts lines 69-71 (decodeWindowsProcessJson using Schema.decodeUnknownOption)
- apps/server/src/diagnostics/ProcessDiagnostics.ts lines 203-208 (parseWindowsProcessRows returning [] on Option.none)
- apps/server/src/diagnostics/ProcessDiagnostics.ts lines 377-383 (PowerShell command using Get-CimInstance Win32_Process and ConvertTo-Json)
- package.json line 12: effect version 4.0.0-beta.59
- Effect Schema v4 migration guide (https://github.com/Effect-TS/effect-smol/blob/main/migration/schema.md): `{ nullable: true }` → `optional(NullOr(schema))` confirms Schema.optional does not accept null

type WindowsProcessRecordJson = typeof WindowsProcessRecordJson.Type;
const WindowsProcessJson = Schema.Union([
WindowsProcessRecordJson,
Schema.Array(WindowsProcessRecordJson),
]);
const decodeWindowsProcessJson = Schema.decodeUnknownOption(
Schema.fromJsonString(WindowsProcessJson),
);

function toProcessDiagnosticsError(message: string, cause?: unknown): ProcessDiagnosticsError {
return new ProcessDiagnosticsError({
message,
Expand All @@ -74,6 +92,14 @@ function parseNumber(value: string): number | null {
return Number.isFinite(parsed) ? parsed : null;
}

function finiteNumberOption(value: number | undefined): Option.Option<number> {
return typeof value === "number" && Number.isFinite(value) ? Option.some(value) : Option.none();
}

function nonEmptyStringOption(value: string | undefined): Option.Option<string> {
return typeof value === "string" && value.trim().length > 0 ? Option.some(value) : Option.none();
}

export function parsePosixProcessRows(output: string): ReadonlyArray<ProcessRow> {
const rows: ProcessRow[] = [];
const rowPattern =
Expand Down Expand Up @@ -139,51 +165,52 @@ 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,
function normalizeWindowsProcessRow(record: WindowsProcessRecordJson): Option.Option<ProcessRow> {
const pid = finiteNumberOption(record.ProcessId).pipe(Option.filter((value) => value > 0));
const ppid = finiteNumberOption(record.ParentProcessId).pipe(
Option.filter((value) => value >= 0),
);
const commandLine = nonEmptyStringOption(record.CommandLine).pipe(
Option.orElse(() => nonEmptyStringOption(record.Name)),
);

if (Option.isNone(pid) || Option.isNone(ppid) || Option.isNone(commandLine)) {
return Option.none();
}

const workingSet = finiteNumberOption(record.WorkingSetSize).pipe(
Option.map((value) => Math.max(0, Math.round(value))),
Option.getOrElse(() => 0),
);
const cpuPercent = finiteNumberOption(record.PercentProcessorTime).pipe(
Option.map((value) => Math.max(0, value)),
Option.getOrElse(() => 0),
);
const status = nonEmptyStringOption(record.Status).pipe(Option.getOrElse(() => "Live"));

return Option.some({
pid: pid.value,
ppid: ppid.value,
pgid: null,
status: typeof record.Status === "string" && record.Status.length > 0 ? record.Status : "Live",
status,
cpuPercent,
rssBytes: workingSet,
elapsed: "",
command: commandLine,
};
command: commandLine.value,
});
}

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 decoded = decodeWindowsProcessJson(output);
if (Option.isNone(decoded)) {
return [];
}
const records = Array.isArray(decoded.value) ? decoded.value : [decoded.value];
return records.flatMap((record) => {
const row = normalizeWindowsProcessRow(record);
return Option.isSome(row) ? [row.value] : [];
});
}

export function buildDescendantEntries(
Expand Down Expand Up @@ -309,7 +336,7 @@ const runProcess = Effect.fn("runProcess")(
(effect, input) =>
effect.pipe(
Effect.scoped,
Effect.timeoutOption(Duration.millis(PROCESS_QUERY_TIMEOUT_MS)),
Effect.timeoutOption(PROCESS_QUERY_TIMEOUT),
Effect.flatMap((result) =>
Option.match(result, {
onNone: () => Effect.fail(toProcessDiagnosticsError(`${input.errorMessage} timed out.`)),
Expand Down
10 changes: 6 additions & 4 deletions apps/server/src/diagnostics/TraceDiagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as PlatformError from "effect/PlatformError";
import * as Schema from "effect/Schema";

interface TraceRecordLike {
readonly name?: unknown;
Expand Down Expand Up @@ -65,6 +66,8 @@ interface TraceDiagnosticsErrorSummary {
const DEFAULT_SLOW_SPAN_THRESHOLD_MS = 1_000;
const TOP_LIMIT = 10;
const RECENT_LIMIT = 20;
const decodeTraceRecordJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown));

function toRotatedTracePaths(traceFilePath: string, maxFiles: number): ReadonlyArray<string> {
const backupCount = Math.max(0, Math.floor(maxFiles));
const backups = Array.from(
Expand Down Expand Up @@ -217,14 +220,13 @@ export function aggregateTraceDiagnostics(
for (const line of lines) {
if (line.trim().length === 0) continue;

let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch {
const decoded = decodeTraceRecordJson(line);
if (Option.isNone(decoded)) {
parseErrorCount += 1;
continue;
}

const parsed = decoded.value;
if (!isRecordObject(parsed)) {
parseErrorCount += 1;
continue;
Expand Down
Loading