diff --git a/apps/server/src/diagnostics/ProcessDiagnostics.test.ts b/apps/server/src/diagnostics/ProcessDiagnostics.test.ts index 18a54326de..f8f0b223f9 100644 --- a/apps/server/src/diagnostics/ProcessDiagnostics.test.ts +++ b/apps/server/src/diagnostics/ProcessDiagnostics.test.ts @@ -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"; @@ -41,7 +41,7 @@ describe("ProcessDiagnostics", () => { ].join("\n"), ); - expect(rows).toEqual([ + assert.deepEqual(rows, [ { pid: 10, ppid: 1, @@ -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]); }), ); @@ -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], + ); }), ); @@ -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="], @@ -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 }> = + []; + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const childProcess = command as unknown as { + readonly command: string; + readonly args: ReadonlyArray; + }; + 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( @@ -241,7 +310,7 @@ describe("ProcessDiagnostics", () => { Effect.provide(layer), ); - expect(result).toEqual({ + assert.deepEqual(result, { pid: 4242, signal: "SIGINT", signaled: false, diff --git a/apps/server/src/diagnostics/ProcessDiagnostics.ts b/apps/server/src/diagnostics/ProcessDiagnostics.ts index f56bf21651..b7054fe754 100644 --- a/apps/server/src/diagnostics/ProcessDiagnostics.ts +++ b/apps/server/src/diagnostics/ProcessDiagnostics.ts @@ -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; @@ -52,6 +52,24 @@ class ProcessDiagnosticsError extends Schema.TaggedErrorClass { + return typeof value === "number" && Number.isFinite(value) ? Option.some(value) : Option.none(); +} + +function nonEmptyStringOption(value: string | undefined): Option.Option { + return typeof value === "string" && value.trim().length > 0 ? Option.some(value) : Option.none(); +} + export function parsePosixProcessRows(output: string): ReadonlyArray { const rows: ProcessRow[] = []; const rowPattern = @@ -139,51 +165,52 @@ export function parsePosixProcessRows(output: string): ReadonlyArray return rows; } -function normalizeWindowsProcessRow(value: unknown): ProcessRow | null { - if (typeof value !== "object" || value === null) return null; - const record = value as Record; - 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 { + 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 { 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( @@ -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.`)), diff --git a/apps/server/src/diagnostics/TraceDiagnostics.ts b/apps/server/src/diagnostics/TraceDiagnostics.ts index ff63410b9b..0d743fdf33 100644 --- a/apps/server/src/diagnostics/TraceDiagnostics.ts +++ b/apps/server/src/diagnostics/TraceDiagnostics.ts @@ -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; @@ -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 { const backupCount = Math.max(0, Math.floor(maxFiles)); const backups = Array.from( @@ -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;