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
106 changes: 105 additions & 1 deletion apps/server/src/diagnostics/ProcessDiagnostics.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
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";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";
import * as Sink from "effect/Sink";
import * as Stream from "effect/Stream";
import { ChildProcessSpawner } from "effect/unstable/process";
Expand All @@ -11,6 +12,7 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import * as ProcessDiagnostics from "./ProcessDiagnostics.ts";

const encoder = new TextEncoder();
const encodeJsonFixture = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));

function mockHandle(result: {
readonly stdout?: string;
Expand Down Expand Up @@ -67,6 +69,108 @@ describe("ProcessDiagnostics", () => {
}),
);

it.effect("parses Windows CIM JSON arrays with command fallback defaults", () =>
Effect.sync(() => {
const rows = ProcessDiagnostics.parseWindowsProcessRows(
encodeJsonFixture([
{
ProcessId: 10,
ParentProcessId: 1,
CommandLine: "node server.js",
Name: "node.exe",
Status: "Running",
WorkingSetSize: 1234.4,
PercentProcessorTime: 2.5,
},
{
ProcessId: 11,
ParentProcessId: 10,
CommandLine: "",
Name: "agent.exe",
},
]),
);

assert.deepEqual(rows, [
{
pid: 10,
ppid: 1,
pgid: null,
status: "Running",
cpuPercent: 2.5,
rssBytes: 1234,
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 objects", () =>
Effect.sync(() => {
const rows = ProcessDiagnostics.parseWindowsProcessRows(
encodeJsonFixture({
ProcessId: 20,
ParentProcessId: 10,
CommandLine: "codex app-server",
WorkingSetSize: 4096,
PercentProcessorTime: 1,
}),
);

assert.deepEqual(rows, [
{
pid: 20,
ppid: 10,
pgid: null,
status: "Live",
cpuPercent: 1,
rssBytes: 4096,
elapsed: "",
command: "codex app-server",
},
]);
}),
);

it.effect("ignores malformed Windows CIM output and invalid records", () =>
Effect.sync(() => {
assert.deepEqual(ProcessDiagnostics.parseWindowsProcessRows("{not-json"), []);
assert.deepEqual(
ProcessDiagnostics.parseWindowsProcessRows(
encodeJsonFixture([
{ ProcessId: 0, ParentProcessId: 1, CommandLine: "missing-pid" },
{ ProcessId: 30, ParentProcessId: -1, CommandLine: "missing-parent" },
{ ProcessId: 31, ParentProcessId: 1, CommandLine: "" },
{ ProcessId: 32, ParentProcessId: 1, CommandLine: "valid" },
]),
),
[
{
pid: 32,
ppid: 1,
pgid: null,
status: "Live",
cpuPercent: 0,
rssBytes: 0,
elapsed: "",
command: "valid",
},
],
);
}),
);

it.effect("aggregates only descendants of the server process", () =>
Effect.sync(() => {
const diagnostics = ProcessDiagnostics.aggregateProcessDiagnostics({
Expand Down
105 changes: 63 additions & 42 deletions apps/server/src/diagnostics/ProcessDiagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,21 @@ const ProcessDiagnosticsError = Schema.Union([
type ProcessDiagnosticsError = typeof ProcessDiagnosticsError.Type;
const isProcessDiagnosticsError = Schema.is(ProcessDiagnosticsError);

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

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 +216,57 @@ 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 optionalNonNegativeNumber(value: number | null | undefined): number {
return typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : 0;
}

function normalizeWindowsProcessRow(value: unknown): Option.Option<ProcessRow> {
return Option.flatMap(decodeWindowsCimProcessRecord(value), (record) => {
if (
!Number.isInteger(record.ProcessId) ||
record.ProcessId <= 0 ||
!Number.isInteger(record.ParentProcessId) ||
record.ParentProcessId < 0
) {
return Option.none();
}

return Option.map(
nonEmptyStringOption(record.CommandLine).pipe(
Option.orElse(() => nonEmptyStringOption(record.Name)),
),
(command) => ({
pid: record.ProcessId,
ppid: record.ParentProcessId,
pgid: null,
status: Option.getOrElse(nonEmptyStringOption(record.Status), () => "Live"),
cpuPercent: optionalNonNegativeNumber(record.PercentProcessorTime),
rssBytes: Math.round(optionalNonNegativeNumber(record.WorkingSetSize)),
elapsed: "",
command,
}),
);
});
}

function parseWindowsProcessRows(output: string): ReadonlyArray<ProcessRow> {
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 {
return [];
}
return Option.match(decodeWindowsCimProcessJson(output), {
onNone: () => [],
onSome: (parsed) => {
const rows: ProcessRow[] = [];
const records = Array.isArray(parsed) ? parsed : [parsed];
for (const record of records) {
const row = normalizeWindowsProcessRow(record);
if (Option.isSome(row)) rows.push(row.value);
}
return rows;
},
});
}

export function buildDescendantEntries(
Expand Down
11 changes: 9 additions & 2 deletions apps/server/src/workspace/WorkspaceFileSystem.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,15 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i
operationPath: resolvedPath,
operation: "realpath-target",
});
expect(error.cause).toBeInstanceOf(Error);
expect((error.cause as NodeJS.ErrnoException).code).toBe("ENOENT");
expect(error.cause).toMatchObject({
_tag: "PlatformError",
reason: {
_tag: "NotFound",
module: "FileSystem",
method: "realPath",
pathOrDescriptor: resolvedPath,
},
});
}),
);
});
Expand Down
Loading
Loading