diff --git a/apps/server/src/diagnostics/ProcessDiagnostics.test.ts b/apps/server/src/diagnostics/ProcessDiagnostics.test.ts index 7d16a11c829..d672508044e 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"; @@ -42,7 +42,7 @@ describe("ProcessDiagnostics", () => { ].join("\n"), ); - expect(rows).toEqual([ + assert.deepStrictEqual(rows, [ { pid: 10, ppid: 1, @@ -67,6 +67,44 @@ describe("ProcessDiagnostics", () => { }), ); + it.effect("parses Windows process JSON through Effect Schema decoding", () => + Effect.sync(() => { + const rows = ProcessDiagnostics.parseWindowsProcessRows( + [ + "[", + '{"ProcessId":10,"ParentProcessId":1,"Name":"node.exe","CommandLine":"node server.js","Status":"Live","WorkingSetSize":1024,"PercentProcessorTime":1.5},', + '{"ProcessId":11,"ParentProcessId":10,"Name":"agent.exe","CommandLine":null,"Status":null,"WorkingSetSize":null,"PercentProcessorTime":null},', + '"not-a-process-row"', + "]", + ].join(""), + ); + + assert.deepStrictEqual(rows, [ + { + pid: 10, + ppid: 1, + pgid: null, + status: "Live", + cpuPercent: 1.5, + rssBytes: 1024, + elapsed: "", + command: "node server.js", + }, + { + pid: 11, + ppid: 10, + pgid: null, + status: "Live", + cpuPercent: 0, + rssBytes: 0, + elapsed: "", + command: "agent.exe", + }, + ]); + assert.deepStrictEqual(ProcessDiagnostics.parseWindowsProcessRows("not-json"), []); + }), + ); + it.effect("aggregates only descendants of the server process", () => Effect.sync(() => { const diagnostics = ProcessDiagnostics.aggregateProcessDiagnostics({ @@ -126,15 +164,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.deepStrictEqual( + diagnostics.processes.map((process) => process.pid), + [101, 102], + ); + assert.deepStrictEqual( + diagnostics.processes.map((process) => process.depth), + [0, 1], + ); + assert.equal(Option.getOrNull(diagnostics.processes[0]!.pgid), 100); + assert.deepStrictEqual(diagnostics.processes[0]?.childPids, [102]); }), ); @@ -177,7 +221,10 @@ describe("ProcessDiagnostics", () => { ], }); - expect(diagnostics.processes.map((process) => process.pid)).toEqual([101, 102, 103]); + assert.deepStrictEqual( + diagnostics.processes.map((process) => process.pid), + [101, 102, 103], + ); }), ); @@ -210,8 +257,11 @@ describe("ProcessDiagnostics", () => { Effect.provide(layer), ); - expect(diagnostics.processes.map((process) => process.pid)).toEqual([4242]); - expect(commands).toEqual([ + assert.deepStrictEqual( + diagnostics.processes.map((process) => process.pid), + [4242], + ); + assert.deepStrictEqual(commands, [ { command: "ps", args: ["-axo", "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command="], @@ -241,18 +291,20 @@ describe("ProcessDiagnostics", () => { Effect.flip, ); - expect(error).toMatchObject({ - _tag: "ProcessDiagnosticsQueryFailedError", - command: "ps", - argCount: 2, - cwd: process.cwd(), - exitCode: 17, - stdoutBytes: 22, - stderrBytes: 21, - stdoutTruncated: false, - stderrTruncated: false, - }); - expect(error.message).toBe( + assert.equal(error._tag, "ProcessDiagnosticsQueryFailedError"); + if (error._tag !== "ProcessDiagnosticsQueryFailedError") { + assert.fail(`Expected ProcessDiagnosticsQueryFailedError, got ${error._tag}`); + } + assert.equal(error.command, "ps"); + assert.equal(error.argCount, 2); + assert.equal(error.cwd, process.cwd()); + assert.equal(error.exitCode, 17); + assert.equal(error.stdoutBytes, 22); + assert.equal(error.stderrBytes, 21); + assert.equal(error.stdoutTruncated, false); + assert.equal(error.stderrTruncated, false); + assert.equal( + error.message, `Process diagnostics query 'ps' failed with exit code 17 in '${process.cwd()}'.`, ); }), @@ -280,7 +332,7 @@ describe("ProcessDiagnostics", () => { Effect.provide(layer), ); - expect(result).toEqual({ + assert.deepStrictEqual(result, { pid: 4242, signal: "SIGINT", signaled: false, diff --git a/apps/server/src/diagnostics/ProcessDiagnostics.ts b/apps/server/src/diagnostics/ProcessDiagnostics.ts index b39d560a228..ae630f21442 100644 --- a/apps/server/src/diagnostics/ProcessDiagnostics.ts +++ b/apps/server/src/diagnostics/ProcessDiagnostics.ts @@ -121,6 +121,18 @@ const ProcessDiagnosticsError = Schema.Union([ type ProcessDiagnosticsError = typeof ProcessDiagnosticsError.Type; const isProcessDiagnosticsError = Schema.is(ProcessDiagnosticsError); +const WindowsProcessRecord = Schema.Struct({ + ProcessId: Schema.optionalKey(Schema.NullOr(Schema.Number)), + ParentProcessId: Schema.optionalKey(Schema.NullOr(Schema.Number)), + CommandLine: Schema.optionalKey(Schema.NullOr(Schema.String)), + Name: Schema.optionalKey(Schema.NullOr(Schema.String)), + Status: Schema.optionalKey(Schema.NullOr(Schema.String)), + WorkingSetSize: Schema.optionalKey(Schema.NullOr(Schema.Number)), + PercentProcessorTime: Schema.optionalKey(Schema.NullOr(Schema.Number)), +}); +const decodeWindowsProcessJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString); +const decodeWindowsProcessRecord = Schema.decodeUnknownOption(WindowsProcessRecord); + function parsePositiveInt(value: string): number | null { const parsed = Number.parseInt(value, 10); return Number.isInteger(parsed) && parsed > 0 ? parsed : null; @@ -202,8 +214,9 @@ export function parsePosixProcessRows(output: string): ReadonlyArray } function normalizeWindowsProcessRow(value: unknown): ProcessRow | null { - if (typeof value !== "object" || value === null) return null; - const record = value as Record; + const recordOption = decodeWindowsProcessRecord(value); + if (Option.isNone(recordOption)) return null; + const record = recordOption.value; const pid = typeof record.ProcessId === "number" ? record.ProcessId : null; const ppid = typeof record.ParentProcessId === "number" ? record.ParentProcessId : null; const commandLine = @@ -234,18 +247,15 @@ function normalizeWindowsProcessRow(value: unknown): ProcessRow | null { }; } -function parseWindowsProcessRows(output: string): ReadonlyArray { +export 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 { - return []; - } + const parsedOption = decodeWindowsProcessJson(output); + if (Option.isNone(parsedOption)) return []; + const records = Array.isArray(parsedOption.value) ? parsedOption.value : [parsedOption.value]; + return records.flatMap((record) => { + const row = normalizeWindowsProcessRow(record); + return row ? [row] : []; + }); } export function buildDescendantEntries( diff --git a/apps/server/src/diagnostics/TraceDiagnostics.test.ts b/apps/server/src/diagnostics/TraceDiagnostics.test.ts index 70bb4dc815c..c18e1489049 100644 --- a/apps/server/src/diagnostics/TraceDiagnostics.test.ts +++ b/apps/server/src/diagnostics/TraceDiagnostics.test.ts @@ -59,6 +59,7 @@ describe("TraceDiagnostics", () => { durationMs: 50, }), "not-json", + "42", ].join("\n"), }, { @@ -123,7 +124,7 @@ describe("TraceDiagnostics", () => { }), "1970-01-01T00:00:05.025Z", ); - assert.equal(diagnostics.parseErrorCount, 1); + assert.equal(diagnostics.parseErrorCount, 2); assert.equal(diagnostics.failureCount, 2); assert.equal(diagnostics.interruptionCount, 1); assert.equal(diagnostics.slowSpanCount, 1); diff --git a/apps/server/src/diagnostics/TraceDiagnostics.ts b/apps/server/src/diagnostics/TraceDiagnostics.ts index d54e033380c..2be71b38f44 100644 --- a/apps/server/src/diagnostics/TraceDiagnostics.ts +++ b/apps/server/src/diagnostics/TraceDiagnostics.ts @@ -34,6 +34,24 @@ interface TraceEventLike { readonly attributes?: unknown; } +const TraceEventRecord = Schema.Struct({ + name: Schema.optionalKey(Schema.Unknown), + timeUnixNano: Schema.optionalKey(Schema.Unknown), + attributes: Schema.optionalKey(Schema.Unknown), +}); +const TraceRecord = Schema.Struct({ + name: Schema.optionalKey(Schema.Unknown), + traceId: Schema.optionalKey(Schema.Unknown), + spanId: Schema.optionalKey(Schema.Unknown), + startTimeUnixNano: Schema.optionalKey(Schema.Unknown), + endTimeUnixNano: Schema.optionalKey(Schema.Unknown), + durationMs: Schema.optionalKey(Schema.Unknown), + exit: Schema.optionalKey(Schema.Unknown), + events: Schema.optionalKey(Schema.Unknown), +}); +const decodeTraceRecordLine = Schema.decodeUnknownOption(Schema.fromJsonString(TraceRecord)); +const decodeTraceEventRecord = Schema.decodeUnknownOption(TraceEventRecord); + export interface TraceDiagnosticsOptions { readonly traceFilePath: string; readonly maxFiles: number; @@ -123,10 +141,6 @@ function readExitCause(exit: unknown): string { return toStringValue(exit.cause)?.trim() ?? "Failure"; } -function isTraceEvent(value: unknown): value is TraceEventLike { - return typeof value === "object" && value !== null; -} - function readEventAttributes(event: TraceEventLike): Readonly> { return typeof event.attributes === "object" && event.attributes !== null ? (event.attributes as Readonly>) @@ -229,18 +243,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 parsedOption = decodeTraceRecordLine(line); + if (Option.isNone(parsedOption)) { parseErrorCount += 1; continue; } - if (!isRecordObject(parsed)) { - parseErrorCount += 1; - continue; - } + const parsed = parsedOption.value; const name = toStringValue(parsed.name); const traceId = toStringValue(parsed.traceId); @@ -305,8 +314,10 @@ export function aggregateTraceDiagnostics( if (Array.isArray(parsed.events)) { for (const rawEvent of parsed.events) { - if (!isTraceEvent(rawEvent)) continue; - const attributes = readEventAttributes(rawEvent); + const eventOption = decodeTraceEventRecord(rawEvent); + if (Option.isNone(eventOption)) continue; + const event = eventOption.value; + const attributes = readEventAttributes(event); const level = toStringValue(attributes["effect.logLevel"]); if (!level) continue; @@ -321,8 +332,8 @@ export function aggregateTraceDiagnostics( continue; } - const seenAt = unixNanoToDateTime(rawEvent.timeUnixNano) ?? endedAt; - const message = toStringValue(rawEvent.name)?.trim() ?? "Log event"; + const seenAt = unixNanoToDateTime(event.timeUnixNano) ?? endedAt; + const message = toStringValue(event.name)?.trim() ?? "Log event"; latestWarningAndErrorLogs.push({ spanName: name, level,