1972 lines · 63.4 KB · TypeScript
RawDownload
import {  DEFAULT_TERMINAL_ID,  type TerminalEvent,  type TerminalSessionSnapshot,  type TerminalSessionStatus,} from "@t3tools/contracts";import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker";import * as DateTime from "effect/DateTime";import * as Effect from "effect/Effect";import * as Encoding from "effect/Encoding";import * as Equal from "effect/Equal";import * as Exit from "effect/Exit";import * as Fiber from "effect/Fiber";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";import * as Scope from "effect/Scope";import * as Semaphore from "effect/Semaphore";import * as SynchronizedRef from "effect/SynchronizedRef"; import { ServerConfig } from "../../config.ts";import {  increment,  terminalRestartsTotal,  terminalSessionsTotal,} from "../../observability/Metrics.ts";import * as ProcessRunner from "../../processRunner.ts";import {  TerminalCwdError,  TerminalHistoryError,  TerminalManager,  TerminalNotRunningError,  TerminalSessionLookupError,  type TerminalManagerShape,} from "../Services/Manager.ts";import {  PtyAdapter,  PtySpawnError,  type PtyAdapterShape,  type PtyExitEvent,  type PtyProcess,} from "../Services/PTY.ts"; const DEFAULT_HISTORY_LINE_LIMIT = 5_000;const DEFAULT_PERSIST_DEBOUNCE_MS = 40;const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000;const DEFAULT_PROCESS_KILL_GRACE_MS = 1_000;const DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS = 128;const DEFAULT_OPEN_COLS = 120;const DEFAULT_OPEN_ROWS = 30;const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]);const nowIso = Effect.map(DateTime.now, DateTime.formatIso); class TerminalSubprocessCheckError extends Schema.TaggedErrorClass<TerminalSubprocessCheckError>()(  "TerminalSubprocessCheckError",  {    message: Schema.String,    cause: Schema.optional(Schema.Defect),    terminalPid: Schema.Number,    command: Schema.Literals(["powershell", "pgrep", "ps"]),  },) {} class TerminalProcessSignalError extends Schema.TaggedErrorClass<TerminalProcessSignalError>()(  "TerminalProcessSignalError",  {    message: Schema.String,    cause: Schema.optional(Schema.Defect),    signal: Schema.Literals(["SIGTERM", "SIGKILL"]),  },) {} interface TerminalSubprocessChecker {  (    terminalPid: number,  ): Effect.Effect<boolean, TerminalSubprocessCheckError, ProcessRunner.ProcessRunner>;} interface ShellCandidate {  shell: string;  args?: string[];} interface TerminalStartInput {  threadId: string;  terminalId: string;  cwd: string;  worktreePath?: string | null;  cols: number;  rows: number;  env?: Record<string, string>;} interface TerminalSessionState {  threadId: string;  terminalId: string;  cwd: string;  worktreePath: string | null;  status: TerminalSessionStatus;  pid: number | null;  history: string;  pendingHistoryControlSequence: string;  pendingProcessEvents: Array<PendingProcessEvent>;  pendingProcessEventIndex: number;  processEventDrainRunning: boolean;  exitCode: number | null;  exitSignal: number | null;  updatedAt: string;  cols: number;  rows: number;  process: PtyProcess | null;  unsubscribeData: (() => void) | null;  unsubscribeExit: (() => void) | null;  hasRunningSubprocess: boolean;  runtimeEnv: Record<string, string> | null;} interface PersistHistoryRequest {  history: string;  immediate: boolean;} type PendingProcessEvent = { type: "output"; data: string } | { type: "exit"; event: PtyExitEvent }; type DrainProcessEventAction =  | { type: "idle" }  | {      type: "output";      threadId: string;      terminalId: string;      history: string | null;      data: string;    }  | {      type: "exit";      process: PtyProcess | null;      threadId: string;      terminalId: string;      exitCode: number | null;      exitSignal: number | null;    }; interface TerminalManagerState {  sessions: Map<string, TerminalSessionState>;  killFibers: Map<PtyProcess, Fiber.Fiber<void, never>>;} function snapshot(session: TerminalSessionState): TerminalSessionSnapshot {  return {    threadId: session.threadId,    terminalId: session.terminalId,    cwd: session.cwd,    worktreePath: session.worktreePath,    status: session.status,    pid: session.pid,    history: session.history,    exitCode: session.exitCode,    exitSignal: session.exitSignal,    updatedAt: session.updatedAt,  };} function cleanupProcessHandles(session: TerminalSessionState): void {  session.unsubscribeData?.();  session.unsubscribeData = null;  session.unsubscribeExit?.();  session.unsubscribeExit = null;} function enqueueProcessEvent(  session: TerminalSessionState,  expectedPid: number,  event: PendingProcessEvent,): boolean {  if (!session.process || session.status !== "running" || session.pid !== expectedPid) {    return false;  }   session.pendingProcessEvents.push(event);  if (session.processEventDrainRunning) {    return false;  }   session.processEventDrainRunning = true;  return true;} function defaultShellResolver(  platform: NodeJS.Platform = process.platform,  env: NodeJS.ProcessEnv = process.env,): string {  if (platform === "win32") {    return "pwsh.exe";  }  return env.SHELL ?? "bash";} function normalizeShellCommand(  value: string | undefined,  platform: NodeJS.Platform = process.platform,): string | null {  if (!value) return null;  const trimmed = value.trim();  if (trimmed.length === 0) return null;   if (platform === "win32") {    return trimmed;  }   const firstToken = trimmed.split(/\s+/g)[0]?.trim();  if (!firstToken) return null;  return firstToken.replace(/^['"]|['"]$/g, "");} function basenameForPlatform(command: string, platform: NodeJS.Platform): string {  const normalized =    platform === "win32" ? command.replaceAll("/", "\\") : command.replaceAll("\\", "/");  const parts = normalized    .split(platform === "win32" ? /\\+/ : /\/+/)    .filter((part) => part.length > 0);  return parts.at(-1) ?? normalized;} function joinWindowsPath(...parts: ReadonlyArray<string>): string {  return parts    .map((part, index) => {      if (index === 0) return part.replace(/[\\/]+$/g, "");      return part.replace(/^[\\/]+|[\\/]+$/g, "");    })    .filter((part) => part.length > 0)    .join("\\");} function shellCandidateFromCommand(  command: string | null,  platform: NodeJS.Platform = process.platform,): ShellCandidate | null {  if (!command || command.length === 0) return null;  const shellName = basenameForPlatform(command, platform).toLowerCase();  if (platform === "win32" && (shellName === "pwsh.exe" || shellName === "powershell.exe")) {    return { shell: command, args: ["-NoLogo"] };  }  if (platform !== "win32" && shellName === "zsh") {    return { shell: command, args: ["-o", "nopromptsp"] };  }  return { shell: command };} function windowsSystemRoot(env: NodeJS.ProcessEnv): string {  return env.SystemRoot?.trim() || env.windir?.trim() || "C:\\Windows";} function windowsPowerShellPath(env: NodeJS.ProcessEnv): string {  return joinWindowsPath(    windowsSystemRoot(env),    "System32",    "WindowsPowerShell",    "v1.0",    "powershell.exe",  );} function windowsCmdPath(env: NodeJS.ProcessEnv): string {  return joinWindowsPath(windowsSystemRoot(env), "System32", "cmd.exe");} function formatShellCandidate(candidate: ShellCandidate): string {  if (!candidate.args || candidate.args.length === 0) return candidate.shell;  return `${candidate.shell} ${candidate.args.join(" ")}`;} function uniqueShellCandidates(candidates: Array<ShellCandidate | null>): ShellCandidate[] {  const seen = new Set<string>();  const ordered: ShellCandidate[] = [];  for (const candidate of candidates) {    if (!candidate) continue;    const key = formatShellCandidate(candidate);    if (seen.has(key)) continue;    seen.add(key);    ordered.push(candidate);  }  return ordered;} function resolveShellCandidates(  shellResolver: () => string,  platform: NodeJS.Platform = process.platform,  env: NodeJS.ProcessEnv = process.env,): ShellCandidate[] {  const requested = shellCandidateFromCommand(    normalizeShellCommand(shellResolver(), platform),    platform,  );   if (platform === "win32") {    return uniqueShellCandidates([      requested,      shellCandidateFromCommand("pwsh.exe", platform),      shellCandidateFromCommand(windowsPowerShellPath(env), platform),      shellCandidateFromCommand("powershell.exe", platform),      shellCandidateFromCommand(env.ComSpec ?? null, platform),      shellCandidateFromCommand(windowsCmdPath(env), platform),      shellCandidateFromCommand("cmd.exe", platform),    ]);  }   return uniqueShellCandidates([    requested,    shellCandidateFromCommand(normalizeShellCommand(env.SHELL, platform), platform),    shellCandidateFromCommand("/bin/zsh", platform),    shellCandidateFromCommand("/bin/bash", platform),    shellCandidateFromCommand("/bin/sh", platform),    shellCandidateFromCommand("zsh", platform),    shellCandidateFromCommand("bash", platform),    shellCandidateFromCommand("sh", platform),  ]);} function isRetryableShellSpawnError(error: PtySpawnError): boolean {  const queue: unknown[] = [error];  const seen = new Set<unknown>();  const messages: string[] = [];   while (queue.length > 0) {    const current = queue.shift();    if (!current || seen.has(current)) {      continue;    }    seen.add(current);     if (typeof current === "string") {      messages.push(current);      continue;    }     if (current instanceof Error) {      messages.push(current.message);      if (current.cause) {        queue.push(current.cause);      }      continue;    }     if (typeof current === "object") {      const value = current as { message?: unknown; cause?: unknown };      if (typeof value.message === "string") {        messages.push(value.message);      }      if (value.cause) {        queue.push(value.cause);      }    }  }   const message = messages.join(" ").toLowerCase();  return (    message.includes("posix_spawnp failed") ||    message.includes("enoent") ||    message.includes("not found") ||    message.includes("file not found") ||    message.includes("no such file")  );} function checkWindowsSubprocessActivity(  terminalPid: number,): Effect.Effect<boolean, TerminalSubprocessCheckError, ProcessRunner.ProcessRunner> {  const command = [    `$children = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue`,    "if ($children) { exit 0 }",    "exit 1",  ].join("; ");  return Effect.gen(function* () {    const processRunner = yield* ProcessRunner.ProcessRunner;    return yield* processRunner.run({      command: "powershell.exe",      args: ["-NoProfile", "-NonInteractive", "-Command", command],      timeout: "1500 millis",      maxOutputBytes: 32_768,      outputMode: "truncate",      shell: process.platform === "win32",      timeoutBehavior: "timedOutResult",    });  }).pipe(    Effect.map((result) => result.code === 0),    Effect.mapError(      (cause) =>        new TerminalSubprocessCheckError({          message: "Failed to check Windows terminal subprocess activity.",          cause,          terminalPid,          command: "powershell",        }),    ),  );} const checkPosixSubprocessActivity = Effect.fn("terminal.checkPosixSubprocessActivity")(function* (  terminalPid: number,): Effect.fn.Return<boolean, TerminalSubprocessCheckError, ProcessRunner.ProcessRunner> {  const processRunner = yield* ProcessRunner.ProcessRunner;  const runPgrep = processRunner    .run({      command: "pgrep",      args: ["-P", String(terminalPid)],      timeout: "1 second",      maxOutputBytes: 32_768,      outputMode: "truncate",      timeoutBehavior: "timedOutResult",    })    .pipe(      Effect.mapError(        (cause) =>          new TerminalSubprocessCheckError({            message: "Failed to inspect terminal subprocesses with pgrep.",            cause,            terminalPid,            command: "pgrep",          }),      ),    );   const runPs = processRunner    .run({      command: "ps",      args: ["-eo", "pid=,ppid="],      timeout: "1 second",      maxOutputBytes: 262_144,      outputMode: "truncate",      timeoutBehavior: "timedOutResult",    })    .pipe(      Effect.mapError(        (cause) =>          new TerminalSubprocessCheckError({            message: "Failed to inspect terminal subprocesses with ps.",            cause,            terminalPid,            command: "ps",          }),      ),    );   const pgrepResult = yield* Effect.exit(runPgrep);  if (pgrepResult._tag === "Success") {    if (pgrepResult.value.code === 0) {      return pgrepResult.value.stdout.trim().length > 0;    }    if (pgrepResult.value.code === 1) {      return false;    }  }   const psResult = yield* Effect.exit(runPs);  if (psResult._tag === "Failure" || psResult.value.code !== 0) {    return false;  }   for (const line of psResult.value.stdout.split(/\r?\n/g)) {    const [pidRaw, ppidRaw] = line.trim().split(/\s+/g);    const pid = Number(pidRaw);    const ppid = Number(ppidRaw);    if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue;    if (ppid === terminalPid) {      return true;    }  }  return false;}); const defaultSubprocessChecker = Effect.fn("terminal.defaultSubprocessChecker")(function* (  terminalPid: number,): Effect.fn.Return<boolean, TerminalSubprocessCheckError, ProcessRunner.ProcessRunner> {  if (!Number.isInteger(terminalPid) || terminalPid <= 0) {    return false;  }  if (process.platform === "win32") {    return yield* checkWindowsSubprocessActivity(terminalPid);  }  return yield* checkPosixSubprocessActivity(terminalPid);}); function capHistory(history: string, maxLines: number): string {  if (history.length === 0) return history;  const hasTrailingNewline = history.endsWith("\n");  const lines = history.split("\n");  if (hasTrailingNewline) {    lines.pop();  }  if (lines.length <= maxLines) return history;  const capped = lines.slice(lines.length - maxLines).join("\n");  return hasTrailingNewline ? `${capped}\n` : capped;} function isCsiFinalByte(codePoint: number): boolean {  return codePoint >= 0x40 && codePoint <= 0x7e;} 

Lines 1–500 of 1972.