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
80 changes: 79 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 @@ -220,6 +220,84 @@ describe("ProcessDiagnostics", () => {
}),
);

it.effect("queries Windows processes through schema-decoded PowerShell 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: JSON.stringify([
{
ProcessId: process.pid,
ParentProcessId: 1,
Name: "node.exe",
CommandLine: "t3 server",
Status: "Running",
WorkingSetSize: 1024,
PercentProcessorTime: 0,
},
{
ProcessId: 4242,
ParentProcessId: process.pid,
Name: "agent.exe",
CommandLine: "agent.exe --work",
Status: "Running",
WorkingSetSize: 4096.4,
PercentProcessorTime: 12.25,
},
{
ProcessId: "invalid",
ParentProcessId: process.pid,
Name: "ignored.exe",
},
]),
}),
);
}),
);
const layer = ProcessDiagnostics.layer.pipe(Layer.provide(spawnerLayer));

const diagnostics = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe(
Effect.flatMap((pd) => pd.read),
Effect.provide(layer),
Effect.provideService(HostProcessPlatform, "win32"),
);

assert.deepStrictEqual(
diagnostics.processes.map((process) => ({
pid: process.pid,
ppid: process.ppid,
command: process.command,
rssBytes: process.rssBytes,
cpuPercent: process.cpuPercent,
})),
[
{
pid: 4242,
ppid: process.pid,
command: "agent.exe --work",
rssBytes: 4096,
cpuPercent: 12.25,
},
],
);
assert.equal(commands[0]?.command, "powershell.exe");
assert.deepStrictEqual(commands[0]?.args.slice(0, 3), [
"-NoProfile",
"-NonInteractive",
"-Command",
]);
}),
);

it.effect("keeps bounded command diagnostics when the process query exits unsuccessfully", () =>
Effect.gen(function* () {
const spawnerLayer = Layer.succeed(
Expand Down
98 changes: 63 additions & 35 deletions apps/server/src/diagnostics/ProcessDiagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,19 @@ const PROCESS_QUERY_TIMEOUT_MS = 1_000;
const POSIX_PROCESS_QUERY_COMMAND = "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command=";
const PROCESS_QUERY_MAX_OUTPUT_BYTES = 2 * 1024 * 1024;

const WindowsProcessRecord = Schema.Struct({
ProcessId: Schema.Number.check(Schema.isFinite()),
ParentProcessId: Schema.Number.check(Schema.isFinite()),
CommandLine: Schema.optional(Schema.Unknown),
Name: Schema.optional(Schema.Unknown),
Status: Schema.optional(Schema.Unknown),
WorkingSetSize: Schema.optional(Schema.Unknown),
PercentProcessorTime: Schema.optional(Schema.Unknown),
});
const WindowsProcessDocument = Schema.fromJsonString(Schema.Unknown);
const decodeWindowsProcessDocument = Schema.decodeUnknownOption(WindowsProcessDocument);
const decodeWindowsProcessRecord = Schema.decodeUnknownOption(WindowsProcessRecord);

export class ProcessDiagnostics extends Context.Service<
ProcessDiagnostics,
{
Expand Down Expand Up @@ -136,6 +149,14 @@ function parseNumber(value: string): number | null {
return Number.isFinite(parsed) ? parsed : null;
}

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

function nonBlankString(value: unknown): 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 @@ -201,51 +222,58 @@ 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 {
function normalizeWindowsProcessRow(value: unknown): Option.Option<ProcessRow> {
const recordOption = decodeWindowsProcessRecord(value);
if (Option.isNone(recordOption)) return Option.none();
const record = recordOption.value;
const pid = record.ProcessId;
const ppid = record.ParentProcessId;
const commandLineOption = nonBlankString(record.CommandLine).pipe(
Option.orElse(() => nonBlankString(record.Name)),
);

if (
!Number.isInteger(pid) ||
pid <= 0 ||
!Number.isInteger(ppid) ||
ppid < 0 ||
Option.isNone(commandLineOption)
) {
return Option.none();
}

const workingSet = Option.getOrElse(
finiteNumber(record.WorkingSetSize).pipe(Option.map((bytes) => Math.max(0, Math.round(bytes)))),
() => 0,
);
const cpuPercent = Option.getOrElse(
finiteNumber(record.PercentProcessorTime).pipe(Option.map((percent) => Math.max(0, percent))),
() => 0,
);

return Option.some({
pid,
ppid,
pgid: null,
status: typeof record.Status === "string" && record.Status.length > 0 ? record.Status : "Live",
status: Option.getOrElse(nonBlankString(record.Status), () => "Live"),
cpuPercent,
rssBytes: workingSet,
elapsed: "",
command: commandLine,
};
command: commandLineOption.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 {
return [];
}
const document = decodeWindowsProcessDocument(output);
if (Option.isNone(document)) return [];
const records = Array.isArray(document.value) ? document.value : [document.value];
return records.flatMap((record) =>
Option.match(normalizeWindowsProcessRow(record), {
onNone: () => [],
onSome: (row) => [row],
}),
);
}

export function buildDescendantEntries(
Expand Down
Loading
Loading