import type { ServerProcessDiagnosticsEntry, ServerProcessDiagnosticsResult, ServerProcessSignal, ServerSignalProcessResult, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; export interface ProcessRow { readonly pid: number; readonly ppid: number; readonly pgid: number | null; readonly status: string; readonly cpuPercent: number; readonly rssBytes: number; readonly elapsed: string; 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; export interface ProcessDiagnosticsShape { readonly read: Effect.Effect; readonly signal: (input: { readonly pid: number; readonly signal: ServerProcessSignal; }) => Effect.Effect; } export class ProcessDiagnostics extends Context.Service< ProcessDiagnostics, ProcessDiagnosticsShape >()("t3/diagnostics/ProcessDiagnostics") {} class ProcessDiagnosticsError extends Schema.TaggedErrorClass()( "ProcessDiagnosticsError", { message: Schema.String, cause: Schema.optional(Schema.Defect()), }, ) {} const isProcessDiagnosticsError = Schema.is(ProcessDiagnosticsError); function toProcessDiagnosticsError(message: string, cause?: unknown): ProcessDiagnosticsError { return new ProcessDiagnosticsError({ message, ...(cause === undefined ? {} : { cause }), }); } function parsePositiveInt(value: string): number | null { const parsed = Number.parseInt(value, 10); return Number.isInteger(parsed) && parsed > 0 ? parsed : null; } function parseNonNegativeInt(value: string): number | null { const parsed = Number.parseInt(value, 10); return Number.isInteger(parsed) && parsed >= 0 ? parsed : null; } function parseNumber(value: string): number | null { const parsed = Number.parseFloat(value); return Number.isFinite(parsed) ? parsed : null; } export function parsePosixProcessRows(output: string): ReadonlyArray { const rows: ProcessRow[] = []; const rowPattern = /^\s*(\d+)\s+(\d+)\s+(-?\d+)\s+(\S+)\s+([+-]?(?:\d+\.?\d*|\.\d+))\s+(\d+)\s+(\S+)\s+(.+)$/; for (const line of output.split(/\r?\n/)) { if (line.trim().length === 0) continue; const match = rowPattern.exec(line); if (!match) continue; const pidText = match[1]; const ppidText = match[2]; const pgidText = match[3]; const status = match[4]; const cpuText = match[5]; const rssText = match[6]; const elapsed = match[7]; const command = match[8]; if ( pidText === undefined || ppidText === undefined || pgidText === undefined || status === undefined || cpuText === undefined || rssText === undefined || elapsed === undefined || command === undefined ) { continue; } const pid = parsePositiveInt(pidText); const ppid = parseNonNegativeInt(ppidText); const pgid = Number.parseInt(pgidText, 10); const cpuPercent = parseNumber(cpuText); const rssKiB = parseNonNegativeInt(rssText); if ( pid === null || ppid === null || !Number.isInteger(pgid) || cpuPercent === null || rssKiB === null || !status || !elapsed || !command ) { continue; } rows.push({ pid, ppid, pgid, status, cpuPercent, rssBytes: rssKiB * 1024, elapsed, command, }); } 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, pgid: null, status: typeof record.Status === "string" && record.Status.length > 0 ? record.Status : "Live", cpuPercent, rssBytes: workingSet, elapsed: "", command: commandLine, }; } 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 []; } } export function buildDescendantEntries( rows: ReadonlyArray, serverPid: number, ): ReadonlyArray { const childrenByParent = new Map(); for (const row of rows) { const children = childrenByParent.get(row.ppid) ?? []; children.push(row); childrenByParent.set(row.ppid, children); } const entries: ServerProcessDiagnosticsEntry[] = []; const visited = new Set(); const stack = [...(childrenByParent.get(serverPid) ?? [])] .toSorted((left, right) => left.pid - right.pid) .map((row) => ({ row, depth: 0 })); while (stack.length > 0) { const item = stack.shift(); if (!item || visited.has(item.row.pid)) continue; visited.add(item.row.pid); const children = [...(childrenByParent.get(item.row.pid) ?? [])].toSorted( (left, right) => left.pid - right.pid, ); entries.push({ pid: item.row.pid, ppid: item.row.ppid, pgid: Option.fromNullishOr(item.row.pgid), status: item.row.status, cpuPercent: item.row.cpuPercent, rssBytes: item.row.rssBytes, elapsed: item.row.elapsed || "n/a", command: item.row.command, depth: item.depth, childPids: children.map((child) => child.pid), }); stack.unshift(...children.map((row) => ({ row, depth: item.depth + 1 }))); } return entries; } export function isDiagnosticsQueryProcess(row: ProcessRow, serverPid: number): boolean { if (row.ppid !== serverPid) return false; const command = row.command.trim(); return ( /(?:^|[/\\])ps\s+-axo\s+pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command=/.test(command) || (/\bpowershell(?:\.exe)?\b/i.test(command) && /\bGet-CimInstance\s+Win32_Process\b/i.test(command)) ); } function makeResult(input: { readonly serverPid: number; readonly rows: ReadonlyArray; readonly readAt: DateTime.Utc; readonly error?: string; }): ServerProcessDiagnosticsResult { const readAt = input.readAt; const rows = input.rows.filter((row) => !isDiagnosticsQueryProcess(row, input.serverPid)); const processes = buildDescendantEntries(rows, input.serverPid); const totalRssBytes = processes.reduce((total, process) => total + process.rssBytes, 0); const totalCpuPercent = processes.reduce((total, process) => total + process.cpuPercent, 0); return { serverPid: input.serverPid, readAt, processCount: processes.length, totalRssBytes, totalCpuPercent, processes, error: input.error ? Option.some({ message: input.error }) : Option.none(), }; } interface ProcessOutput { readonly exitCode: number; readonly stdout: string; readonly stderr: string; } const runProcess = Effect.fn("runProcess")( function* (input: { readonly command: string; readonly args: ReadonlyArray; readonly errorMessage: string; }) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const child = yield* spawner.spawn( ChildProcess.make(input.command, input.args, { cwd: process.cwd(), }), ); const [stdout, stderr, exitCode] = yield* Effect.all( [ collectUint8StreamText({ stream: child.stdout, maxBytes: PROCESS_QUERY_MAX_OUTPUT_BYTES, truncatedMarker: "\n\n[truncated]", }), collectUint8StreamText({ stream: child.stderr, maxBytes: PROCESS_QUERY_MAX_OUTPUT_BYTES, truncatedMarker: "\n\n[truncated]", }), child.exitCode, ], { concurrency: "unbounded" }, ); return { exitCode, stdout: stdout.text, stderr: stderr.text, } satisfies ProcessOutput; }, (effect, input) => effect.pipe( Effect.scoped, Effect.timeoutOption(Duration.millis(PROCESS_QUERY_TIMEOUT_MS)), Effect.flatMap((result) => Option.match(result, { onNone: () => Effect.fail(toProcessDiagnosticsError(`${input.errorMessage} timed out.`)), onSome: Effect.succeed, }), ), Effect.mapError((cause) => isProcessDiagnosticsError(cause) ? cause : toProcessDiagnosticsError(input.errorMessage, cause), ), ), ); function readPosixProcessRows(): Effect.Effect< ReadonlyArray, ProcessDiagnosticsError, ChildProcessSpawner.ChildProcessSpawner > { return runProcess({ command: "ps", args: ["-axo", POSIX_PROCESS_QUERY_COMMAND], errorMessage: "Failed to query process diagnostics.", }).pipe( Effect.flatMap((result) => result.exitCode !== 0 ? Effect.fail(toProcessDiagnosticsError(result.stderr.trim() || "ps failed.")) : Effect.succeed(parsePosixProcessRows(result.stdout)), ), ); } function readWindowsProcessRows(): Effect.Effect< ReadonlyArray, ProcessDiagnosticsError, ChildProcessSpawner.ChildProcessSpawner > { const command = [ "$processes = Get-CimInstance Win32_Process | ForEach-Object {", '$perf = Get-CimInstance Win32_PerfFormattedData_PerfProc_Process -Filter "IDProcess = $($_.ProcessId)" -ErrorAction SilentlyContinue;', "[pscustomobject]@{ ProcessId = $_.ProcessId; ParentProcessId = $_.ParentProcessId; Name = $_.Name; CommandLine = $_.CommandLine; Status = $_.Status; WorkingSetSize = $_.WorkingSetSize; PercentProcessorTime = if ($perf) { $perf.PercentProcessorTime } else { 0 } }", "};", "$processes | ConvertTo-Json -Compress -Depth 3", ].join(" "); return runProcess({ command: "powershell.exe", args: ["-NoProfile", "-NonInteractive", "-Command", command], errorMessage: "Failed to query process diagnostics.", }).pipe( Effect.flatMap((result) => result.exitCode !== 0 ? Effect.fail( toProcessDiagnosticsError(result.stderr.trim() || "PowerShell process query failed."), ) : Effect.succeed(parseWindowsProcessRows(result.stdout)), ), ); } export const readProcessRows = (platform = process.platform) => platform === "win32" ? readWindowsProcessRows() : readPosixProcessRows(); export function aggregateProcessDiagnostics(input: { readonly serverPid: number; readonly rows: ReadonlyArray; readonly readAt: DateTime.Utc; }): ServerProcessDiagnosticsResult { return makeResult(input); } function assertDescendantPid( pid: number, ): Effect.Effect { if (pid === process.pid) { return Effect.fail(toProcessDiagnosticsError("Refusing to signal the T3 server process.")); } return readProcessRows().pipe( Effect.flatMap((rows) => { const filteredRows = rows.filter((row) => !isDiagnosticsQueryProcess(row, process.pid)); const descendant = buildDescendantEntries(filteredRows, process.pid).some( (entry) => entry.pid === pid, ); return descendant ? Effect.void : Effect.fail( toProcessDiagnosticsError(`Process ${pid} is not a live descendant of the T3 server.`), ); }), ); } export const make = Effect.fn("makeProcessDiagnostics")(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const read: ProcessDiagnosticsShape["read"] = Effect.gen(function* () { const readAt = yield* DateTime.now; const rows = yield* readProcessRows().pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); return makeResult({ serverPid: process.pid, rows, readAt }); }).pipe( Effect.catch((error: ProcessDiagnosticsError) => DateTime.now.pipe( Effect.map((readAt) => makeResult({ serverPid: process.pid, rows: [], readAt, error: error.message }), ), ), ), ); const signal: ProcessDiagnosticsShape["signal"] = Effect.fn("ProcessDiagnostics.signal")( function* (input) { return yield* assertDescendantPid(input.pid).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.flatMap(() => Effect.try({ try: () => { process.kill(input.pid, input.signal); return { pid: input.pid, signal: input.signal, signaled: true, message: Option.none(), }; }, catch: (cause) => toProcessDiagnosticsError( `Failed to signal process ${input.pid} with ${input.signal}.`, cause, ), }), ), Effect.catch((error: ProcessDiagnosticsError) => Effect.succeed({ pid: input.pid, signal: input.signal, signaled: false, message: Option.some(error.message), }), ), ); }, ); return ProcessDiagnostics.of({ read, signal }); }); export const layer = Layer.effect(ProcessDiagnostics, make());