diff --git a/apps/server/src/terminal/Layers/BunPTY.ts b/apps/server/src/terminal/Layers/BunPTY.ts index e77b822e7e789a62f809d5cca60c03c55483458a..84a116d32156e5645046745b005e28aa482e2edd 100644 --- a/apps/server/src/terminal/Layers/BunPTY.ts +++ b/apps/server/src/terminal/Layers/BunPTY.ts @@ -1,5 +1,5 @@ import { Effect, Layer } from "effect"; -import { PtyAdapter, PtyExitEvent, PtyProcess, PtySpawnInput } from "../Services/PTY"; +import { PtyAdapter, PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY"; class BunPtyProcess implements PtyProcess { private readonly dataListeners = new Set<(data: string) => void>(); @@ -93,7 +93,7 @@ export const BunPtyAdapterLive = Layer.effect( return yield* Effect.die("Bun PTY terminal support is unavailable on Windows."); } return { - spawn(input: PtySpawnInput): PtyProcess { + spawn: (input) => { let processHandle: BunPtyProcess | null = null; const command = [input.shell, ...(input.args ?? [])]; const subprocess = Bun.spawn(command, { @@ -108,8 +108,8 @@ export const BunPtyAdapterLive = Layer.effect( }, }); processHandle = new BunPtyProcess(subprocess); - return processHandle; + return Effect.succeed(processHandle); }, - }; + } satisfies PtyAdapterShape; }), ); diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index e6a57cd05f5e0a84235e196fb73bd18031cd4899..ca4ea6b196e7287df81557d6a829d5b23237e3e8 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -11,7 +11,7 @@ import { afterEach, describe, expect, it } from "vitest"; import type { PtyAdapterShape, PtyExitEvent, PtyProcess, PtySpawnInput } from "../Services/PTY"; import { TerminalManagerRuntime } from "./Manager"; -import { Encoding } from "effect"; +import { Effect, Encoding } from "effect"; class FakePtyProcess implements PtyProcess { readonly writes: string[] = []; @@ -69,7 +69,7 @@ class FakePtyAdapter implements PtyAdapterShape { readonly spawnFailures: Error[] = []; private nextPid = 9000; - spawn(input: PtySpawnInput): PtyProcess { + spawn(input: PtySpawnInput): Effect.Effect { this.spawnInputs.push(input); const failure = this.spawnFailures.shift(); if (failure) { @@ -77,7 +77,7 @@ class FakePtyAdapter implements PtyAdapterShape { } const process = new FakePtyProcess(this.nextPid++); this.processes.push(process); - return process; + return Effect.succeed(process); } } diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index ac0d63d8d44d7c910e8f5c2fd1f6184ed2c3faff..e841d33e17de0478318c5b046b362c6b3ddf5390 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -20,6 +20,7 @@ import { runProcess } from "../../processRunner"; import { ServerConfig } from "../../config"; import { ShellCandidate, + TerminalError, TerminalManager, TerminalManagerShape, TerminalSessionState, @@ -552,14 +553,16 @@ export class TerminalManagerRuntime extends EventEmitter for (const candidate of shellCandidates) { try { - ptyProcess = this.ptyAdapter.spawn({ - shell: candidate.shell, - ...(candidate.args ? { args: candidate.args } : {}), - cwd: session.cwd, - cols: session.cols, - rows: session.rows, - env: terminalEnv, - }); + ptyProcess = Effect.runSync( + this.ptyAdapter.spawn({ + shell: candidate.shell, + ...(candidate.args ? { args: candidate.args } : {}), + cwd: session.cwd, + cols: session.cols, + rows: session.rows, + env: terminalEnv, + }), + ); startedShell = formatShellCandidate(candidate); break; } catch (error) { @@ -690,7 +693,11 @@ export class TerminalManagerRuntime extends EventEmitter this.killEscalationTimers.delete(process); } - private killProcessWithEscalation(process: PtyProcess, threadId: string, terminalId: string): void { + private killProcessWithEscalation( + process: PtyProcess, + threadId: string, + terminalId: string, + ): void { this.clearKillEscalationTimer(process); try { process.kill("SIGTERM"); @@ -724,7 +731,9 @@ export class TerminalManagerRuntime extends EventEmitter } private evictInactiveSessionsIfNeeded(): void { - const inactiveSessions = [...this.sessions.values()].filter((session) => session.status !== "running"); + const inactiveSessions = [...this.sessions.values()].filter( + (session) => session.status !== "running", + ); if (inactiveSessions.length <= this.maxRetainedInactiveSessions) { return; } @@ -1110,12 +1119,36 @@ export const TerminalManagerLive = Layer.effect( ); return { - open: (input) => Effect.promise(() => runtime.open(input)), - write: (input) => Effect.promise(() => runtime.write(input)), - resize: (input) => Effect.promise(() => runtime.resize(input)), - clear: (input) => Effect.promise(() => runtime.clear(input)), - restart: (input) => Effect.promise(() => runtime.restart(input)), - close: (input) => Effect.promise(() => runtime.close(input)), + open: (input) => + Effect.tryPromise({ + try: () => runtime.open(input), + catch: (cause) => new TerminalError({ message: "Failed to open terminal", cause }), + }), + write: (input) => + Effect.tryPromise({ + try: () => runtime.write(input), + catch: (cause) => new TerminalError({ message: "Failed to write to terminal", cause }), + }), + resize: (input) => + Effect.tryPromise({ + try: () => runtime.resize(input), + catch: (cause) => new TerminalError({ message: "Failed to resize terminal", cause }), + }), + clear: (input) => + Effect.tryPromise({ + try: () => runtime.clear(input), + catch: (cause) => new TerminalError({ message: "Failed to clear terminal", cause }), + }), + restart: (input) => + Effect.tryPromise({ + try: () => runtime.restart(input), + catch: (cause) => new TerminalError({ message: "Failed to restart terminal", cause }), + }), + close: (input) => + Effect.tryPromise({ + try: () => runtime.close(input), + catch: (cause) => new TerminalError({ message: "Failed to close terminal", cause }), + }), subscribe: (listener) => Effect.sync(() => { runtime.on("event", listener); @@ -1123,10 +1156,7 @@ export const TerminalManagerLive = Layer.effect( runtime.off("event", listener); }; }), - dispose: () => - Effect.sync(() => { - runtime.dispose(); - }), + dispose: Effect.sync(() => runtime.dispose()), } satisfies TerminalManagerShape; }), ); diff --git a/apps/server/src/terminal/Layers/NodePTY.test.ts b/apps/server/src/terminal/Layers/NodePTY.test.ts index 90e349cc5d80cede3ae504e8baa4153a49049eac..71a71ffe3fa224bda533a08bfa4b330dee0c4436 100644 --- a/apps/server/src/terminal/Layers/NodePTY.test.ts +++ b/apps/server/src/terminal/Layers/NodePTY.test.ts @@ -1,65 +1,45 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { assert, it, vi } from "@effect/vitest"; -import { Effect, Layer } from "effect"; -import { afterEach } from "vitest"; +import { FileSystem, Path, Effect } from "effect"; +import { assert, it } from "@effect/vitest"; import { ensureNodePtySpawnHelperExecutable } from "./NodePTY"; +import { NodeServices } from "@effect/platform-node"; -function fileMode(filePath: string): number { - return fs.statSync(filePath).mode & 0o777; -} - -const tempDirs: string[] = []; - -afterEach(() => { - vi.unstubAllGlobals(); - for (const dir of tempDirs.splice(0, tempDirs.length)) { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - -const helperLayer = it.layer(Layer.empty); - -helperLayer("ensureNodePtySpawnHelperExecutable", (it) => { +it.layer(NodeServices.layer)("ensureNodePtySpawnHelperExecutable", (it) => { it.effect("adds executable bits when helper exists but is not executable", () => - Effect.sync(() => { + Effect.gen(function* () { if (process.platform === "win32") return; - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-helper-test-")); - tempDirs.push(dir); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "pty-helper-test-" }); const helperPath = path.join(dir, "spawn-helper"); - fs.writeFileSync(helperPath, "#!/bin/sh\nexit 0\n", "utf8"); - fs.chmodSync(helperPath, 0o644); + yield* fs.writeFileString(helperPath, "#!/bin/sh\nexit 0\n"); + yield* fs.chmod(helperPath, 0o644); - ensureNodePtySpawnHelperExecutable(helperPath); + yield* ensureNodePtySpawnHelperExecutable(helperPath); - assert.equal(fileMode(helperPath) & 0o111, 0o111); + const mode = (yield* fs.stat(helperPath)).mode & 0o777; + assert.equal(mode & 0o111, 0o111); }), ); it.effect("keeps executable helper as executable", () => - Effect.sync(() => { + Effect.gen(function* () { if (process.platform === "win32") return; - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-helper-test-")); - tempDirs.push(dir); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "pty-helper-test-" }); const helperPath = path.join(dir, "spawn-helper"); - fs.writeFileSync(helperPath, "#!/bin/sh\nexit 0\n", "utf8"); - fs.chmodSync(helperPath, 0o755); + yield* fs.writeFileString(helperPath, "#!/bin/sh\nexit 0\n"); + yield* fs.chmod(helperPath, 0o755); - ensureNodePtySpawnHelperExecutable(helperPath); + yield* ensureNodePtySpawnHelperExecutable(helperPath); - assert.equal(fileMode(helperPath) & 0o111, 0o111); - }), - ); - - it.effect("does not throw when helper path is missing", () => - Effect.sync(() => { - const missing = path.join(os.tmpdir(), `does-not-exist-${Date.now()}`); - ensureNodePtySpawnHelperExecutable(missing); + const mode = (yield* fs.stat(helperPath)).mode & 0o777; + assert.equal(mode & 0o111, 0o111); }), ); }); diff --git a/apps/server/src/terminal/Layers/NodePTY.ts b/apps/server/src/terminal/Layers/NodePTY.ts index 86557e7e93378dd8e4f1a9fe8e68939a7f50416c..c002397a2901d624665c2e668bb212751e659276 100644 --- a/apps/server/src/terminal/Layers/NodePTY.ts +++ b/apps/server/src/terminal/Layers/NodePTY.ts @@ -1,55 +1,48 @@ -import fs from "node:fs"; import { createRequire } from "node:module"; -import path from "node:path"; -import { Effect, Layer } from "effect"; -import { PtyAdapter, PtyExitEvent, PtyProcess, PtySpawnInput } from "../Services/PTY"; +import { Effect, FileSystem, Layer, Path } from "effect"; +import { PtyAdapter, PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY"; -const requireForNodePty = createRequire(import.meta.url); let didEnsureSpawnHelperExecutable = false; -function resolveNodePtySpawnHelperPath(): string | null { - try { - const packageJsonPath = requireForNodePty.resolve("node-pty/package.json"); - const packageDir = path.dirname(packageJsonPath); - const candidates = [ - path.join(packageDir, "build", "Release", "spawn-helper"), - path.join(packageDir, "build", "Debug", "spawn-helper"), - path.join(packageDir, "prebuilds", `${process.platform}-${process.arch}`, "spawn-helper"), - ]; +const resolveNodePtySpawnHelperPath = Effect.gen(function* () { + const requireForNodePty = createRequire(import.meta.url); + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; - for (const candidate of candidates) { - if (fs.existsSync(candidate)) { - return candidate; - } + const packageJsonPath = requireForNodePty.resolve("node-pty/package.json"); + const packageDir = path.dirname(packageJsonPath); + const candidates = [ + path.join(packageDir, "build", "Release", "spawn-helper"), + path.join(packageDir, "build", "Debug", "spawn-helper"), + path.join(packageDir, "prebuilds", `${process.platform}-${process.arch}`, "spawn-helper"), + ]; + + for (const candidate of candidates) { + if (yield* fs.exists(candidate)) { + return candidate; } - - return null; - } catch { - return null; } -} + return null; +}).pipe(Effect.orElseSucceed(() => null)); -export function ensureNodePtySpawnHelperExecutable(explicitPath?: string): void { +export const ensureNodePtySpawnHelperExecutable = Effect.fn(function* (explicitPath?: string) { + const fs = yield* FileSystem.FileSystem; if (process.platform === "win32") return; if (!explicitPath && didEnsureSpawnHelperExecutable) return; - const helperPath = explicitPath ?? resolveNodePtySpawnHelperPath(); + const helperPath = explicitPath ?? (yield* resolveNodePtySpawnHelperPath); if (!helperPath) return; if (!explicitPath) { didEnsureSpawnHelperExecutable = true; } - try { - const stat = fs.statSync(helperPath); - const mode = stat.mode & 0o777; - if ((mode & 0o111) === 0) { - fs.chmodSync(helperPath, mode | 0o111); - } - } catch { - // Best effort only. If chmod fails, node-pty spawn will surface the real error. + const stat = yield* fs.stat(helperPath); + const mode = stat.mode & 0o777; + if ((mode & 0o111) === 0) { + yield* fs.chmod(helperPath, mode | 0o111); } -} +}); class NodePtyProcess implements PtyProcess { constructor(private readonly process: import("node-pty").IPty) {} @@ -93,10 +86,22 @@ class NodePtyProcess implements PtyProcess { export const NodePtyAdapterLive = Layer.effect( PtyAdapter, Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const nodePty = yield* Effect.promise(() => import("node-pty")); + + const ensureNodePtySpawnHelperExecutableCached = yield* Effect.cached( + ensureNodePtySpawnHelperExecutable().pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.orElseSucceed(() => undefined), + ), + ); + return { - spawn(input: PtySpawnInput): PtyProcess { - ensureNodePtySpawnHelperExecutable(); + spawn: Effect.fn(function* (input) { + yield* ensureNodePtySpawnHelperExecutableCached; const ptyProcess = nodePty.spawn(input.shell, input.args ?? [], { cwd: input.cwd, cols: input.cols, @@ -105,7 +110,7 @@ export const NodePtyAdapterLive = Layer.effect( name: globalThis.process.platform === "win32" ? "xterm-color" : "xterm-256color", }); return new NodePtyProcess(ptyProcess); - }, - }; + }), + } satisfies PtyAdapterShape; }), ); diff --git a/apps/server/src/terminal/Services/Manager.ts b/apps/server/src/terminal/Services/Manager.ts index 22cbd4ecc164b368d4695629de20c69f0ad88605..d342a75da1fb8ed36990c2957a026e76a5877570 100644 --- a/apps/server/src/terminal/Services/Manager.ts +++ b/apps/server/src/terminal/Services/Manager.ts @@ -17,7 +17,12 @@ import { TerminalWriteInput, } from "@t3tools/contracts"; import { PtyProcess } from "./PTY"; -import { Effect, ServiceMap } from "effect"; +import { Effect, Schema, ServiceMap } from "effect"; + +export class TerminalError extends Schema.TaggedErrorClass()("TerminalError", { + message: Schema.String, + cause: Schema.optional(Schema.Defect), +}) {} export interface TerminalSessionState { threadId: string; @@ -58,36 +63,40 @@ export interface TerminalManagerShape { * Reuses an existing session for the same thread/terminal id and restores * persisted history on first open. */ - readonly open: (input: TerminalOpenInput) => Effect.Effect; + readonly open: ( + input: TerminalOpenInput, + ) => Effect.Effect; /** * Write input bytes to a terminal session. */ - readonly write: (input: TerminalWriteInput) => Effect.Effect; + readonly write: (input: TerminalWriteInput) => Effect.Effect; /** * Resize the PTY backing a terminal session. */ - readonly resize: (input: TerminalResizeInput) => Effect.Effect; + readonly resize: (input: TerminalResizeInput) => Effect.Effect; /** * Clear terminal output history. */ - readonly clear: (input: TerminalClearInput) => Effect.Effect; + readonly clear: (input: TerminalClearInput) => Effect.Effect; /** * Restart a terminal session in place. * * Always resets history before spawning the new process. */ - readonly restart: (input: TerminalOpenInput) => Effect.Effect; + readonly restart: ( + input: TerminalOpenInput, + ) => Effect.Effect; /** * Close an active terminal session. * * When `terminalId` is omitted, closes all sessions for the thread. */ - readonly close: (input: TerminalCloseInput) => Effect.Effect; + readonly close: (input: TerminalCloseInput) => Effect.Effect; /** * Subscribe to terminal runtime events. @@ -97,7 +106,7 @@ export interface TerminalManagerShape { /** * Dispose all managed terminal resources. */ - readonly dispose: () => Effect.Effect; + readonly dispose: Effect.Effect; } /** diff --git a/apps/server/src/terminal/Services/PTY.ts b/apps/server/src/terminal/Services/PTY.ts index ca9e51ea0bf6102f0d19268390994227bf853014..501eb4ee7a7da34e92446b2c7ea23707065510e8 100644 --- a/apps/server/src/terminal/Services/PTY.ts +++ b/apps/server/src/terminal/Services/PTY.ts @@ -6,7 +6,7 @@ * * @module PtyAdapter */ -import { ServiceMap } from "effect"; +import { Effect, ServiceMap } from "effect"; export interface PtyExitEvent { exitCode: number; @@ -38,7 +38,7 @@ export interface PtyAdapterShape { /** * Spawn a PTY process for a terminal session. */ - spawn(input: PtySpawnInput): PtyProcess; + spawn(input: PtySpawnInput): Effect.Effect; } /**