diff --git a/apps/server/src/diagnostics/ProcessDiagnostics.test.ts b/apps/server/src/diagnostics/ProcessDiagnostics.test.ts index 18a54326de1..8e5c2e5be7d 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, expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -66,6 +66,56 @@ describe("ProcessDiagnostics", () => { }), ); + it.effect("parses Windows process JSON through schemas", () => + Effect.sync(() => { + const rows = ProcessDiagnostics.parseWindowsProcessRows(` + [ + { + "ProcessId": 4242, + "ParentProcessId": 100, + "CommandLine": "node server.js", + "WorkingSetSize": 4096.7, + "PercentProcessorTime": 2.5, + "Status": "Running" + }, + { + "ProcessId": 0, + "ParentProcessId": 100, + "Name": "invalid.exe" + }, + { + "ProcessId": 4243, + "ParentProcessId": 4242, + "Name": "child.exe" + } + ] + `); + + assert.deepEqual(rows, [ + { + pid: 4242, + ppid: 100, + pgid: null, + status: "Running", + cpuPercent: 2.5, + rssBytes: 4097, + elapsed: "", + command: "node server.js", + }, + { + pid: 4243, + ppid: 4242, + pgid: null, + status: "Live", + cpuPercent: 0, + rssBytes: 0, + elapsed: "", + command: "child.exe", + }, + ]); + }), + ); + it.effect("aggregates only descendants of the server process", () => Effect.sync(() => { const diagnostics = ProcessDiagnostics.aggregateProcessDiagnostics({ diff --git a/apps/server/src/diagnostics/ProcessDiagnostics.ts b/apps/server/src/diagnostics/ProcessDiagnostics.ts index f56bf216513..64fe409b410 100644 --- a/apps/server/src/diagnostics/ProcessDiagnostics.ts +++ b/apps/server/src/diagnostics/ProcessDiagnostics.ts @@ -26,9 +26,23 @@ export interface ProcessRow { readonly command: string; } -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 PROCESS_QUERY_TIMEOUT = Duration.seconds(1); + +const WindowsProcessRecord = Schema.Struct({ + ProcessId: Schema.Number, + ParentProcessId: Schema.Number, + CommandLine: Schema.optional(Schema.String), + Name: Schema.optional(Schema.String), + WorkingSetSize: Schema.optional(Schema.Number), + PercentProcessorTime: Schema.optional(Schema.Number), + Status: Schema.optional(Schema.String), +}); +type WindowsProcessRecord = typeof WindowsProcessRecord.Type; +const WindowsProcessJson = Schema.fromJsonString(Schema.Unknown); +const decodeWindowsProcessRecord = Schema.decodeUnknownOption(WindowsProcessRecord); +const decodeWindowsProcessJson = Schema.decodeUnknownOption(WindowsProcessJson); export interface ProcessDiagnosticsShape { readonly read: Effect.Effect; @@ -74,6 +88,9 @@ function parseNumber(value: string): number | null { return Number.isFinite(parsed) ? parsed : null; } +const nonEmptyWindowsString = (value: string | undefined): Option.Option => + Option.fromUndefinedOr(value).pipe(Option.filter((text) => text.trim().length > 0)); + export function parsePosixProcessRows(output: string): ReadonlyArray { const rows: ProcessRow[] = []; const rowPattern = @@ -139,51 +156,61 @@ 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; +function normalizeWindowsProcessRow(record: WindowsProcessRecord): Option.Option { + const pid = Number.isInteger(record.ProcessId) ? Option.some(record.ProcessId) : Option.none(); + const ppid = Number.isInteger(record.ParentProcessId) + ? Option.some(record.ParentProcessId) + : Option.none(); + const commandLine = nonEmptyWindowsString(record.CommandLine).pipe( + Option.orElse(() => nonEmptyWindowsString(record.Name)), + ); const workingSet = - typeof record.WorkingSetSize === "number" && Number.isFinite(record.WorkingSetSize) + record.WorkingSetSize !== undefined && Number.isFinite(record.WorkingSetSize) ? Math.max(0, Math.round(record.WorkingSetSize)) : 0; const cpuPercent = - typeof record.PercentProcessorTime === "number" && Number.isFinite(record.PercentProcessorTime) + record.PercentProcessorTime !== undefined && Number.isFinite(record.PercentProcessorTime) ? Math.max(0, record.PercentProcessorTime) : 0; - if (!pid || pid <= 0 || ppid === null || ppid < 0 || !commandLine) return null; - return { - pid, - ppid, + if ( + Option.isNone(pid) || + pid.value <= 0 || + Option.isNone(ppid) || + ppid.value < 0 || + Option.isNone(commandLine) + ) { + return Option.none(); + } + return Option.some({ + pid: pid.value, + ppid: ppid.value, pgid: null, - status: typeof record.Status === "string" && record.Status.length > 0 ? record.Status : "Live", + status: record.Status !== undefined && record.Status.length > 0 ? record.Status : "Live", cpuPercent, rssBytes: workingSet, elapsed: "", - command: commandLine, - }; + command: commandLine.value, + }); } -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] : []; + const parsed = decodeWindowsProcessJson(output); + if (Option.isNone(parsed)) return []; + + const records = Array.isArray(parsed.value) ? parsed.value : [parsed.value]; + return records.flatMap((record) => { + const decoded = decodeWindowsProcessRecord(record); + return Option.match(decoded, { + onNone: () => [], + onSome: (windowsRecord) => + Option.match(normalizeWindowsProcessRow(windowsRecord), { + onNone: () => [], + onSome: (row) => [row], + }), }); - } catch { - return []; - } + }); } 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/vcs/VcsProjectConfig.test.ts b/apps/server/src/vcs/VcsProjectConfig.test.ts index aac4beb7e32..89cfc839221 100644 --- a/apps/server/src/vcs/VcsProjectConfig.test.ts +++ b/apps/server/src/vcs/VcsProjectConfig.test.ts @@ -67,4 +67,24 @@ describe("VcsProjectConfig", () => { }), ); }); + + it.layer(TestLayer)("falls back to auto when config is invalid JSON", (it) => { + it.effect("returns auto", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-vcs-config-test-", + }); + const configDir = path.join(root, ".t3code"); + yield* fileSystem.makeDirectory(configDir, { recursive: true }); + yield* fileSystem.writeFileString(path.join(configDir, "vcs.json"), "{ nope"); + + const config = yield* VcsProjectConfig.VcsProjectConfig; + const kind = yield* config.resolveKind({ cwd: root }); + + assert.equal(kind, "auto"); + }), + ); + }); }); diff --git a/apps/server/src/vcs/VcsProjectConfig.ts b/apps/server/src/vcs/VcsProjectConfig.ts index 3e5ee2347ce..09f8dc5bd05 100644 --- a/apps/server/src/vcs/VcsProjectConfig.ts +++ b/apps/server/src/vcs/VcsProjectConfig.ts @@ -2,6 +2,7 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; @@ -15,16 +16,10 @@ const ProjectVcsConfig = Schema.Struct({ ), vcsKind: Schema.optional(VcsDriverKind), }); -const isProjectVcsConfig = Schema.is(ProjectVcsConfig); +const ProjectVcsConfigJson = Schema.fromJsonString(ProjectVcsConfig); +const decodeProjectVcsConfigJson = Schema.decodeUnknownOption(ProjectVcsConfigJson); -interface ProjectVcsConfigFile { - readonly vcs?: - | { - readonly kind?: VcsDriverKindType | undefined; - } - | undefined; - readonly vcsKind?: VcsDriverKindType | undefined; -} +type ProjectVcsConfigFile = typeof ProjectVcsConfig.Type; export interface VcsProjectConfigResolveInput { readonly cwd: string; @@ -45,13 +40,8 @@ function configuredKind(config: ProjectVcsConfigFile): VcsDriverKindType | "auto return config.vcs?.kind ?? config.vcsKind ?? "auto"; } -function parseConfig(raw: string): ProjectVcsConfigFile | null { - try { - const parsed = JSON.parse(raw) as unknown; - return isProjectVcsConfig(parsed) ? parsed : null; - } catch { - return null; - } +function parseConfig(raw: string): Option.Option { + return decodeProjectVcsConfigJson(raw); } export const make = Effect.fn("makeVcsProjectConfig")(function* () { @@ -63,12 +53,12 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () { while (true) { const candidate = path.join(current, ".t3code", "vcs.json"); if (yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false))) { - return candidate; + return Option.some(candidate); } const parent = path.dirname(current); if (parent === current) { - return null; + return Option.none(); } current = parent; } @@ -78,26 +68,27 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () { configPath: string, ) { const raw = yield* fileSystem.readFileString(configPath).pipe( + Effect.map(Option.some), Effect.catch((error) => Effect.logWarning("failed to read VCS project config", { configPath, error, - }).pipe(Effect.as(null)), + }).pipe(Effect.as(Option.none())), ), ); - if (raw === null) { + if (Option.isNone(raw)) { return "auto" as const; } - const parsed = parseConfig(raw); - if (parsed === null) { + const parsed = parseConfig(raw.value); + if (Option.isNone(parsed)) { yield* Effect.logWarning("invalid VCS project config", { configPath, }); return "auto" as const; } - return configuredKind(parsed); + return configuredKind(parsed.value); }); const resolveKind: VcsProjectConfigShape["resolveKind"] = Effect.fn( @@ -108,11 +99,10 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () { } const configPath = yield* findConfigPath(input.cwd); - if (configPath === null) { - return "auto"; - } - - return yield* readConfiguredKind(configPath); + return yield* Option.match(configPath, { + onNone: () => Effect.succeed("auto" as const), + onSome: readConfiguredKind, + }); }); return VcsProjectConfig.of({