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
52 changes: 51 additions & 1 deletion apps/server/src/diagnostics/ProcessDiagnostics.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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({
Expand Down
91 changes: 59 additions & 32 deletions apps/server/src/diagnostics/ProcessDiagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ServerProcessDiagnosticsResult>;
Expand Down Expand Up @@ -74,6 +88,9 @@ function parseNumber(value: string): number | null {
return Number.isFinite(parsed) ? parsed : null;
}

const nonEmptyWindowsString = (value: string | undefined): Option.Option<string> =>
Option.fromUndefinedOr(value).pipe(Option.filter((text) => text.trim().length > 0));

export function parsePosixProcessRows(output: string): ReadonlyArray<ProcessRow> {
const rows: ProcessRow[] = [];
const rowPattern =
Expand Down Expand Up @@ -139,51 +156,61 @@ 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;
function normalizeWindowsProcessRow(record: WindowsProcessRecord): Option.Option<ProcessRow> {
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<ProcessRow>();
}
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<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] : [];
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(
Expand Down Expand Up @@ -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.`)),
Expand Down
20 changes: 20 additions & 0 deletions apps/server/src/vcs/VcsProjectConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}),
);
});
});
46 changes: 18 additions & 28 deletions apps/server/src/vcs/VcsProjectConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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;
Expand All @@ -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<ProjectVcsConfigFile> {
return decodeProjectVcsConfigJson(raw);
}

export const make = Effect.fn("makeVcsProjectConfig")(function* () {
Expand All @@ -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<string>();
}
current = parent;
}
Expand All @@ -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<string>())),
),
);
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(
Expand All @@ -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({
Expand Down
Loading