575 lines · 17.8 KB · TypeScript
RawDownload
import type {  ServerProcessDiagnosticsEntry,  ServerProcessDiagnosticsResult,  ServerProcessSignal,  ServerSignalProcessResult,} from "@t3tools/contracts";import { HostProcessPlatform } from "@t3tools/shared/hostProcess";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 * as ChildProcess from "effect/unstable/process/ChildProcess";import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; 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 class ProcessDiagnostics extends Context.Service<  ProcessDiagnostics,  {    readonly read: Effect.Effect<ServerProcessDiagnosticsResult>;    readonly signal: (input: {      readonly pid: number;      readonly signal: ServerProcessSignal;    }) => Effect.Effect<ServerSignalProcessResult>;  }>()("t3/diagnostics/ProcessDiagnostics") {} class ProcessDiagnosticsQueryTimeoutError extends Schema.TaggedErrorClass<ProcessDiagnosticsQueryTimeoutError>()(  "ProcessDiagnosticsQueryTimeoutError",  {    command: Schema.String,    argCount: Schema.Number,    cwd: Schema.String,    timeoutMillis: Schema.Number,  },) {  override get message(): string {    return `Process diagnostics query '${this.command}' timed out after ${this.timeoutMillis}ms in '${this.cwd}'.`;  }} class ProcessDiagnosticsQueryFailedError extends Schema.TaggedErrorClass<ProcessDiagnosticsQueryFailedError>()(  "ProcessDiagnosticsQueryFailedError",  {    command: Schema.String,    argCount: Schema.Number,    cwd: Schema.String,    exitCode: Schema.optional(Schema.Number),    stdoutBytes: Schema.optional(Schema.Number),    stderrBytes: Schema.optional(Schema.Number),    stdoutTruncated: Schema.optional(Schema.Boolean),    stderrTruncated: Schema.optional(Schema.Boolean),    cause: Schema.optional(Schema.Defect()),  },) {  override get message(): string {    const exitCode = this.exitCode === undefined ? "" : ` with exit code ${this.exitCode}`;    return `Process diagnostics query '${this.command}' failed${exitCode} in '${this.cwd}'.`;  }} class ProcessDiagnosticsServerProcessSignalError extends Schema.TaggedErrorClass<ProcessDiagnosticsServerProcessSignalError>()(  "ProcessDiagnosticsServerProcessSignalError",  { pid: Schema.Number },) {  override get message(): string {    return "Refusing to signal the T3 server process.";  }} class ProcessDiagnosticsNotDescendantError extends Schema.TaggedErrorClass<ProcessDiagnosticsNotDescendantError>()(  "ProcessDiagnosticsNotDescendantError",  {    pid: Schema.Number,    serverPid: Schema.Number,  },) {  override get message(): string {    return `Process ${this.pid} is not a live descendant of the T3 server.`;  }} class ProcessDiagnosticsSignalFailedError extends Schema.TaggedErrorClass<ProcessDiagnosticsSignalFailedError>()(  "ProcessDiagnosticsSignalFailedError",  {    pid: Schema.Number,    signal: Schema.String,    cause: Schema.Defect(),  },) {  override get message(): string {    return `Failed to signal process ${this.pid} with ${this.signal}.`;  }} const ProcessDiagnosticsError = Schema.Union([  ProcessDiagnosticsQueryTimeoutError,  ProcessDiagnosticsQueryFailedError,  ProcessDiagnosticsServerProcessSignalError,  ProcessDiagnosticsNotDescendantError,  ProcessDiagnosticsSignalFailedError,]);type ProcessDiagnosticsError = typeof ProcessDiagnosticsError.Type;const isProcessDiagnosticsError = Schema.is(ProcessDiagnosticsError); 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<ProcessRow> {  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<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;  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<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] : [];    });  } catch {    return [];  }} export function buildDescendantEntries(  rows: ReadonlyArray<ProcessRow>,  serverPid: number,): ReadonlyArray<ServerProcessDiagnosticsEntry> {  const childrenByParent = new Map<number, ProcessRow[]>();  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<number>();  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<ProcessRow>;  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 cwd: string;  readonly exitCode: number;  readonly stdout: string;  readonly stdoutBytes: number;  readonly stdoutTruncated: boolean;  readonly stderr: string;  readonly stderrBytes: number;  readonly stderrTruncated: boolean;} const runProcess = Effect.fn("runProcess")(function* (input: {  readonly command: string;  readonly args: ReadonlyArray<string>;}) {  const cwd = process.cwd();  return yield* Effect.gen(function* () {    const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;    // `ps` and `powershell.exe` are real executables; spawning through cmd.exe    // shell mode would re-tokenize the PowerShell `-Command` payload (which    // contains pipes) before PowerShell ever sees it.    const child = yield* spawner.spawn(      ChildProcess.make(input.command, input.args, {        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 {      cwd,      exitCode,      stdout: stdout.text,      stdoutBytes: stdout.bytes,      stdoutTruncated: stdout.truncated,      stderr: stderr.text,      stderrBytes: stderr.bytes,      stderrTruncated: stderr.truncated,    } satisfies ProcessOutput;  }).pipe(    Effect.scoped,    Effect.timeoutOption(Duration.millis(PROCESS_QUERY_TIMEOUT_MS)),    Effect.flatMap((result) =>      Option.match(result, {        onNone: () =>          Effect.fail(            new ProcessDiagnosticsQueryTimeoutError({              command: input.command,              argCount: input.args.length,              cwd,              timeoutMillis: PROCESS_QUERY_TIMEOUT_MS,            }),          ),        onSome: Effect.succeed,      }),    ),    Effect.mapError((cause) =>      isProcessDiagnosticsError(cause)        ? cause        : new ProcessDiagnosticsQueryFailedError({            command: input.command,            argCount: input.args.length,            cwd,            cause,          }),    ),  );}); function readPosixProcessRows(): Effect.Effect<  ReadonlyArray<ProcessRow>,  ProcessDiagnosticsError,  ChildProcessSpawner.ChildProcessSpawner> {  return runProcess({    command: "ps",    args: ["-axo", POSIX_PROCESS_QUERY_COMMAND],  }).pipe(    Effect.flatMap((result) =>      result.exitCode !== 0        ? Effect.fail(            new ProcessDiagnosticsQueryFailedError({              command: "ps",              argCount: 2,              cwd: result.cwd,              exitCode: result.exitCode,              stdoutBytes: result.stdoutBytes,              stderrBytes: result.stderrBytes,              stdoutTruncated: result.stdoutTruncated,              stderrTruncated: result.stderrTruncated,            }),          )        : Effect.succeed(parsePosixProcessRows(result.stdout)),    ),  );} function readWindowsProcessRows(): Effect.Effect<  ReadonlyArray<ProcessRow>,  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],  }).pipe(    Effect.flatMap((result) =>      result.exitCode !== 0        ? Effect.fail(            new ProcessDiagnosticsQueryFailedError({              command: "powershell.exe",              argCount: 4,              cwd: result.cwd,              exitCode: result.exitCode,              stdoutBytes: result.stdoutBytes,              stderrBytes: result.stderrBytes,              stdoutTruncated: result.stdoutTruncated,              stderrTruncated: result.stderrTruncated,            }),          )        : Effect.succeed(parseWindowsProcessRows(result.stdout)),    ),  );} export const readProcessRows = Effect.gen(function* () {  const platform = yield* HostProcessPlatform;  return yield* platform === "win32" ? readWindowsProcessRows() : readPosixProcessRows();}); export function aggregateProcessDiagnostics(input: {  readonly serverPid: number;  readonly rows: ReadonlyArray<ProcessRow>;  readonly readAt: DateTime.Utc;}): ServerProcessDiagnosticsResult {  return makeResult(input);} function assertDescendantPid(  pid: number,): Effect.Effect<void, ProcessDiagnosticsError, ChildProcessSpawner.ChildProcessSpawner> {  if (pid === process.pid) {    return Effect.fail(      new ProcessDiagnosticsServerProcessSignalError({        pid,      }),    );  }   return readProcessRows.pipe(

Lines 1–500 of 575.