Unified · Split
| @@ -1,4 +1,4 @@ | ||
| 1 | −import { describe, expect, it } from "@effect/vitest"; | |
| 1 | +import { assert, describe, expect, it } from "@effect/vitest"; | |
| 2 | 2 | import * as DateTime from "effect/DateTime"; |
| 3 | 3 | import * as Effect from "effect/Effect"; |
| 4 | 4 | import * as Layer from "effect/Layer"; |
| @@ -66,6 +66,56 @@ describe("ProcessDiagnostics", () => { · 61 unmodified lines | ||
| 66 | 66 | }), |
| 67 | 67 | ); |
| 68 | 68 | |
| 69 | + it.effect("parses Windows process JSON through schemas", () => | |
| 70 | + Effect.sync(() => { | |
| 71 | + const rows = ProcessDiagnostics.parseWindowsProcessRows(` | |
| 72 | + [ | |
| 73 | + { | |
| 74 | + "ProcessId": 4242, | |
| 75 | + "ParentProcessId": 100, | |
| 76 | + "CommandLine": "node server.js", | |
| 77 | + "WorkingSetSize": 4096.7, | |
| 78 | + "PercentProcessorTime": 2.5, | |
| 79 | + "Status": "Running" | |
| 80 | + }, | |
| 81 | + { | |
| 82 | + "ProcessId": 0, | |
| 83 | + "ParentProcessId": 100, | |
| 84 | + "Name": "invalid.exe" | |
| 85 | + }, | |
| 86 | + { | |
| 87 | + "ProcessId": 4243, | |
| 88 | + "ParentProcessId": 4242, | |
| 89 | + "Name": "child.exe" | |
| 90 | + } | |
| 91 | + ] | |
| 92 | + `); | |
| 93 | + | |
| 94 | + assert.deepEqual(rows, [ | |
| 95 | + { | |
| 96 | + pid: 4242, | |
| 97 | + ppid: 100, | |
| 98 | + pgid: null, | |
| 99 | + status: "Running", | |
| 100 | + cpuPercent: 2.5, | |
| 101 | + rssBytes: 4097, | |
| 102 | + elapsed: "", | |
| 103 | + command: "node server.js", | |
| 104 | + }, | |
| 105 | + { | |
| 106 | + pid: 4243, | |
| 107 | + ppid: 4242, | |
| 108 | + pgid: null, | |
| 109 | + status: "Live", | |
| 110 | + cpuPercent: 0, | |
| 111 | + rssBytes: 0, | |
| 112 | + elapsed: "", | |
| 113 | + command: "child.exe", | |
| 114 | + }, | |
| 115 | + ]); | |
| 116 | + }), | |
| 117 | + ); | |
| 118 | + | |
| 69 | 119 | it.effect("aggregates only descendants of the server process", () => |
| 70 | 120 | Effect.sync(() => { |
| 71 | 121 | const diagnostics = ProcessDiagnostics.aggregateProcessDiagnostics({ |
| @@ -26,9 +26,23 @@ export interface ProcessRow { · 25 unmodified lines | ||
| 26 | 26 | readonly command: string; |
| 27 | 27 | } |
| 28 | 28 | |
| 29 | −const PROCESS_QUERY_TIMEOUT_MS = 1_000; | |
| 30 | 29 | const POSIX_PROCESS_QUERY_COMMAND = "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command="; |
| 31 | 30 | const PROCESS_QUERY_MAX_OUTPUT_BYTES = 2 * 1024 * 1024; |
| 31 | +const PROCESS_QUERY_TIMEOUT = Duration.seconds(1); | |
| 32 | + | |
| 33 | +const WindowsProcessRecord = Schema.Struct({ | |
| 34 | + ProcessId: Schema.Number, | |
| 35 | + ParentProcessId: Schema.Number, | |
| 36 | + CommandLine: Schema.optional(Schema.String), | |
| 37 | + Name: Schema.optional(Schema.String), | |
| 38 | + WorkingSetSize: Schema.optional(Schema.Number), | |
| 39 | + PercentProcessorTime: Schema.optional(Schema.Number), | |
| 40 | + Status: Schema.optional(Schema.String), | |
| 41 | +}); | |
| 42 | +type WindowsProcessRecord = typeof WindowsProcessRecord.Type; | |
| 43 | +const WindowsProcessJson = Schema.fromJsonString(Schema.Unknown); | |
| 44 | +const decodeWindowsProcessRecord = Schema.decodeUnknownOption(WindowsProcessRecord); | |
| 45 | +const decodeWindowsProcessJson = Schema.decodeUnknownOption(WindowsProcessJson); | |
| 32 | 46 | |
| 33 | 47 | export interface ProcessDiagnosticsShape { |
| 34 | 48 | readonly read: Effect.Effect<ServerProcessDiagnosticsResult>; |
| @@ -74,6 +88,9 @@ function parseNumber(value: string): number | null { · 39 unmodified lines | ||
| 74 | 88 | return Number.isFinite(parsed) ? parsed : null; |
| 75 | 89 | } |
| 76 | 90 | |
| 91 | +const nonEmptyWindowsString = (value: string | undefined): Option.Option<string> => | |
| 92 | + Option.fromUndefinedOr(value).pipe(Option.filter((text) => text.trim().length > 0)); | |
| 93 | + | |
| 77 | 94 | export function parsePosixProcessRows(output: string): ReadonlyArray<ProcessRow> { |
| 78 | 95 | const rows: ProcessRow[] = []; |
| 79 | 96 | const rowPattern = |
| @@ -139,51 +156,61 @@ export function parsePosixProcessRows(output: string): ReadonlyArray<ProcessRow> · 59 unmodified lines | ||
| 139 | 156 | return rows; |
| 140 | 157 | } |
| 141 | 158 | |
| 142 | −function normalizeWindowsProcessRow(value: unknown): ProcessRow | null { | |
| 143 | − if (typeof value !== "object" || value === null) return null; | |
| 144 | − const record = value as Record<string, unknown>; | |
| 145 | − const pid = typeof record.ProcessId === "number" ? record.ProcessId : null; | |
| 146 | − const ppid = typeof record.ParentProcessId === "number" ? record.ParentProcessId : null; | |
| 147 | − const commandLine = | |
| 148 | − typeof record.CommandLine === "string" && record.CommandLine.trim().length > 0 | |
| 149 | − ? record.CommandLine | |
| 150 | − : typeof record.Name === "string" | |
| 151 | − ? record.Name | |
| 152 | − : null; | |
| 159 | +function normalizeWindowsProcessRow(record: WindowsProcessRecord): Option.Option<ProcessRow> { | |
| 160 | + const pid = Number.isInteger(record.ProcessId) ? Option.some(record.ProcessId) : Option.none(); | |
| 161 | + const ppid = Number.isInteger(record.ParentProcessId) | |
| 162 | + ? Option.some(record.ParentProcessId) | |
| 163 | + : Option.none(); | |
| 164 | + const commandLine = nonEmptyWindowsString(record.CommandLine).pipe( | |
| 165 | + Option.orElse(() => nonEmptyWindowsString(record.Name)), | |
| 166 | + ); | |
| 153 | 167 | const workingSet = |
| 154 | − typeof record.WorkingSetSize === "number" && Number.isFinite(record.WorkingSetSize) | |
| 168 | + record.WorkingSetSize !== undefined && Number.isFinite(record.WorkingSetSize) | |
| 155 | 169 | ? Math.max(0, Math.round(record.WorkingSetSize)) |
| 156 | 170 | : 0; |
| 157 | 171 | const cpuPercent = |
| 158 | − typeof record.PercentProcessorTime === "number" && Number.isFinite(record.PercentProcessorTime) | |
| 172 | + record.PercentProcessorTime !== undefined && Number.isFinite(record.PercentProcessorTime) | |
| 159 | 173 | ? Math.max(0, record.PercentProcessorTime) |
| 160 | 174 | : 0; |
| 161 | 175 | |
| 162 | − if (!pid || pid <= 0 || ppid === null || ppid < 0 || !commandLine) return null; | |
| 163 | − return { | |
| 164 | − pid, | |
| 165 | − ppid, | |
| 176 | + if ( | |
| 177 | + Option.isNone(pid) || | |
| 178 | + pid.value <= 0 || | |
| 179 | + Option.isNone(ppid) || | |
| 180 | + ppid.value < 0 || | |
| 181 | + Option.isNone(commandLine) | |
| 182 | + ) { | |
| 183 | + return Option.none<ProcessRow>(); | |
| 184 | + } | |
| 185 | + return Option.some({ | |
| 186 | + pid: pid.value, | |
| 187 | + ppid: ppid.value, | |
| 166 | 188 | pgid: null, |
| 167 | − status: typeof record.Status === "string" && record.Status.length > 0 ? record.Status : "Live", | |
| 189 | + status: record.Status !== undefined && record.Status.length > 0 ? record.Status : "Live", | |
| 168 | 190 | cpuPercent, |
| 169 | 191 | rssBytes: workingSet, |
| 170 | 192 | elapsed: "", |
| 171 | − command: commandLine, | |
| 172 | − }; | |
| 193 | + command: commandLine.value, | |
| 194 | + }); | |
| 173 | 195 | } |
| 174 | 196 | |
| 175 | −function parseWindowsProcessRows(output: string): ReadonlyArray<ProcessRow> { | |
| 197 | +export function parseWindowsProcessRows(output: string): ReadonlyArray<ProcessRow> { | |
| 176 | 198 | if (output.trim().length === 0) return []; |
| 177 | − try { | |
| 178 | − const parsed = JSON.parse(output) as unknown; | |
| 179 | − const records = Array.isArray(parsed) ? parsed : [parsed]; | |
| 180 | − return records.flatMap((record) => { | |
| 181 | − const row = normalizeWindowsProcessRow(record); | |
| 182 | − return row ? [row] : []; | |
| 199 | + const parsed = decodeWindowsProcessJson(output); | |
| 200 | + if (Option.isNone(parsed)) return []; | |
| 201 | + | |
| 202 | + const records = Array.isArray(parsed.value) ? parsed.value : [parsed.value]; | |
| 203 | + return records.flatMap((record) => { | |
| 204 | + const decoded = decodeWindowsProcessRecord(record); | |
| 205 | + return Option.match(decoded, { | |
| 206 | + onNone: () => [], | |
| 207 | + onSome: (windowsRecord) => | |
| 208 | + Option.match(normalizeWindowsProcessRow(windowsRecord), { | |
| 209 | + onNone: () => [], | |
| 210 | + onSome: (row) => [row], | |
| 211 | + }), | |
| 183 | 212 | }); |
| 184 | − } catch { | |
| 185 | − return []; | |
| 186 | − } | |
| 213 | + }); | |
| 187 | 214 | } |
| 188 | 215 | |
| 189 | 216 | export function buildDescendantEntries( |
| @@ -309,7 +336,7 @@ const runProcess = Effect.fn("runProcess")( · 119 unmodified lines | ||
| 309 | 336 | (effect, input) => |
| 310 | 337 | effect.pipe( |
| 311 | 338 | Effect.scoped, |
| 312 | − Effect.timeoutOption(Duration.millis(PROCESS_QUERY_TIMEOUT_MS)), | |
| 339 | + Effect.timeoutOption(PROCESS_QUERY_TIMEOUT), | |
| 313 | 340 | Effect.flatMap((result) => |
| 314 | 341 | Option.match(result, { |
| 315 | 342 | onNone: () => Effect.fail(toProcessDiagnosticsError(`${input.errorMessage} timed out.`)), |
| @@ -67,4 +67,24 @@ describe("VcsProjectConfig", () => { · 66 unmodified lines | ||
| 67 | 67 | }), |
| 68 | 68 | ); |
| 69 | 69 | }); |
| 70 | + | |
| 71 | + it.layer(TestLayer)("falls back to auto when config is invalid JSON", (it) => { | |
| 72 | + it.effect("returns auto", () => | |
| 73 | + Effect.gen(function* () { | |
| 74 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 75 | + const path = yield* Path.Path; | |
| 76 | + const root = yield* fileSystem.makeTempDirectoryScoped({ | |
| 77 | + prefix: "t3-vcs-config-test-", | |
| 78 | + }); | |
| 79 | + const configDir = path.join(root, ".t3code"); | |
| 80 | + yield* fileSystem.makeDirectory(configDir, { recursive: true }); | |
| 81 | + yield* fileSystem.writeFileString(path.join(configDir, "vcs.json"), "{ nope"); | |
| 82 | + | |
| 83 | + const config = yield* VcsProjectConfig.VcsProjectConfig; | |
| 84 | + const kind = yield* config.resolveKind({ cwd: root }); | |
| 85 | + | |
| 86 | + assert.equal(kind, "auto"); | |
| 87 | + }), | |
| 88 | + ); | |
| 89 | + }); | |
| 70 | 90 | }); |
| @@ -2,6 +2,7 @@ import * as Context from "effect/Context"; · 1 unmodified line | ||
| 2 | 2 | import * as Effect from "effect/Effect"; |
| 3 | 3 | import * as FileSystem from "effect/FileSystem"; |
| 4 | 4 | import * as Layer from "effect/Layer"; |
| 5 | +import * as Option from "effect/Option"; | |
| 5 | 6 | import * as Path from "effect/Path"; |
| 6 | 7 | import * as Schema from "effect/Schema"; |
| 7 | 8 | |
| @@ -15,16 +16,10 @@ const ProjectVcsConfig = Schema.Struct({ · 7 unmodified lines | ||
| 15 | 16 | ), |
| 16 | 17 | vcsKind: Schema.optional(VcsDriverKind), |
| 17 | 18 | }); |
| 18 | −const isProjectVcsConfig = Schema.is(ProjectVcsConfig); | |
| 19 | +const ProjectVcsConfigJson = Schema.fromJsonString(ProjectVcsConfig); | |
| 20 | +const decodeProjectVcsConfigJson = Schema.decodeUnknownOption(ProjectVcsConfigJson); | |
| 19 | 21 | |
| 20 | −interface ProjectVcsConfigFile { | |
| 21 | − readonly vcs?: | |
| 22 | − | { | |
| 23 | − readonly kind?: VcsDriverKindType | undefined; | |
| 24 | − } | |
| 25 | − | undefined; | |
| 26 | − readonly vcsKind?: VcsDriverKindType | undefined; | |
| 27 | −} | |
| 22 | +type ProjectVcsConfigFile = typeof ProjectVcsConfig.Type; | |
| 28 | 23 | |
| 29 | 24 | export interface VcsProjectConfigResolveInput { |
| 30 | 25 | readonly cwd: string; |
| @@ -45,13 +40,8 @@ function configuredKind(config: ProjectVcsConfigFile): VcsDriverKindType | "auto · 14 unmodified lines | ||
| 45 | 40 | return config.vcs?.kind ?? config.vcsKind ?? "auto"; |
| 46 | 41 | } |
| 47 | 42 | |
| 48 | −function parseConfig(raw: string): ProjectVcsConfigFile | null { | |
| 49 | − try { | |
| 50 | − const parsed = JSON.parse(raw) as unknown; | |
| 51 | − return isProjectVcsConfig(parsed) ? parsed : null; | |
| 52 | − } catch { | |
| 53 | − return null; | |
| 54 | − } | |
| 43 | +function parseConfig(raw: string): Option.Option<ProjectVcsConfigFile> { | |
| 44 | + return decodeProjectVcsConfigJson(raw); | |
| 55 | 45 | } |
| 56 | 46 | |
| 57 | 47 | export const make = Effect.fn("makeVcsProjectConfig")(function* () { |
| @@ -63,12 +53,12 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () { · 5 unmodified lines | ||
| 63 | 53 | while (true) { |
| 64 | 54 | const candidate = path.join(current, ".t3code", "vcs.json"); |
| 65 | 55 | if (yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false))) { |
| 66 | − return candidate; | |
| 56 | + return Option.some(candidate); | |
| 67 | 57 | } |
| 68 | 58 | |
| 69 | 59 | const parent = path.dirname(current); |
| 70 | 60 | if (parent === current) { |
| 71 | − return null; | |
| 61 | + return Option.none<string>(); | |
| 72 | 62 | } |
| 73 | 63 | current = parent; |
| 74 | 64 | } |
| @@ -78,26 +68,27 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () { · 3 unmodified lines | ||
| 78 | 68 | configPath: string, |
| 79 | 69 | ) { |
| 80 | 70 | const raw = yield* fileSystem.readFileString(configPath).pipe( |
| 71 | + Effect.map(Option.some), | |
| 81 | 72 | Effect.catch((error) => |
| 82 | 73 | Effect.logWarning("failed to read VCS project config", { |
| 83 | 74 | configPath, |
| 84 | 75 | error, |
| 85 | − }).pipe(Effect.as(null)), | |
| 76 | + }).pipe(Effect.as(Option.none<string>())), | |
| 86 | 77 | ), |
| 87 | 78 | ); |
| 88 | − if (raw === null) { | |
| 79 | + if (Option.isNone(raw)) { | |
| 89 | 80 | return "auto" as const; |
| 90 | 81 | } |
| 91 | 82 | |
| 92 | − const parsed = parseConfig(raw); | |
| 93 | − if (parsed === null) { | |
| 83 | + const parsed = parseConfig(raw.value); | |
| 84 | + if (Option.isNone(parsed)) { | |
| 94 | 85 | yield* Effect.logWarning("invalid VCS project config", { |
| 95 | 86 | configPath, |
| 96 | 87 | }); |
| 97 | 88 | return "auto" as const; |
| 98 | 89 | } |
| 99 | 90 | |
| 100 | − return configuredKind(parsed); | |
| 91 | + return configuredKind(parsed.value); | |
| 101 | 92 | }); |
| 102 | 93 | |
| 103 | 94 | const resolveKind: VcsProjectConfigShape["resolveKind"] = Effect.fn( |
| @@ -108,11 +99,10 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () { · 4 unmodified lines | ||
| 108 | 99 | } |
| 109 | 100 | |
| 110 | 101 | const configPath = yield* findConfigPath(input.cwd); |
| 111 | − if (configPath === null) { | |
| 112 | − return "auto"; | |
| 113 | − } | |
| 114 | − | |
| 115 | − return yield* readConfiguredKind(configPath); | |
| 102 | + return yield* Option.match(configPath, { | |
| 103 | + onNone: () => Effect.succeed("auto" as const), | |
| 104 | + onSome: readConfiguredKind, | |
| 105 | + }); | |
| 116 | 106 | }); |
| 117 | 107 | |
| 118 | 108 | return VcsProjectConfig.of({ |