authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-26 09:43:06-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 13:20:36-08:00
log8ab72e0964d95ca0ef139fba9994e3fb925343c9
tree991bb078087550e3672e51bb169462b412777db8
parent7ad5d03948a27e64bb11f6862d2f23333a0a2bd1

terminal nits


7 files changed, 144 insertions(+), 120 deletions(-)

apps/server/src/terminal/Layers/BunPTY.ts+4-4
......@@ -1,5 +1,5 @@
11import { Effect, Layer } from "effect";
2import { PtyAdapter, PtyExitEvent, PtyProcess, PtySpawnInput } from "../Services/PTY";
2import { PtyAdapter, PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY";
33
44class BunPtyProcess implements PtyProcess {
55 private readonly dataListeners = new Set<(data: string) => void>();
......@@ -93,7 +93,7 @@ export const BunPtyAdapterLive = Layer.effect(
9393 return yield* Effect.die("Bun PTY terminal support is unavailable on Windows.");
9494 }
9595 return {
96 spawn(input: PtySpawnInput): PtyProcess {
96 spawn: (input) => {
9797 let processHandle: BunPtyProcess | null = null;
9898 const command = [input.shell, ...(input.args ?? [])];
9999 const subprocess = Bun.spawn(command, {
......@@ -108,8 +108,8 @@ export const BunPtyAdapterLive = Layer.effect(
108108 },
109109 });
110110 processHandle = new BunPtyProcess(subprocess);
111 return processHandle;
111 return Effect.succeed(processHandle);
112112 },
113 };
113 } satisfies PtyAdapterShape;
114114 }),
115115);
apps/server/src/terminal/Layers/Manager.test.ts+3-3
......@@ -11,7 +11,7 @@ import { afterEach, describe, expect, it } from "vitest";
1111
1212import type { PtyAdapterShape, PtyExitEvent, PtyProcess, PtySpawnInput } from "../Services/PTY";
1313import { TerminalManagerRuntime } from "./Manager";
14import { Encoding } from "effect";
14import { Effect, Encoding } from "effect";
1515
1616class FakePtyProcess implements PtyProcess {
1717 readonly writes: string[] = [];
......@@ -69,7 +69,7 @@ class FakePtyAdapter implements PtyAdapterShape {
6969 readonly spawnFailures: Error[] = [];
7070 private nextPid = 9000;
7171
72 spawn(input: PtySpawnInput): PtyProcess {
72 spawn(input: PtySpawnInput): Effect.Effect<PtyProcess> {
7373 this.spawnInputs.push(input);
7474 const failure = this.spawnFailures.shift();
7575 if (failure) {
......@@ -77,7 +77,7 @@ class FakePtyAdapter implements PtyAdapterShape {
7777 }
7878 const process = new FakePtyProcess(this.nextPid++);
7979 this.processes.push(process);
80 return process;
80 return Effect.succeed(process);
8181 }
8282}
8383
apps/server/src/terminal/Layers/Manager.ts+50-20
......@@ -20,6 +20,7 @@ import { runProcess } from "../../processRunner";
2020import { ServerConfig } from "../../config";
2121import {
2222 ShellCandidate,
23 TerminalError,
2324 TerminalManager,
2425 TerminalManagerShape,
2526 TerminalSessionState,
......@@ -552,14 +553,16 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
552553
553554 for (const candidate of shellCandidates) {
554555 try {
555 ptyProcess = this.ptyAdapter.spawn({
556 shell: candidate.shell,
557 ...(candidate.args ? { args: candidate.args } : {}),
558 cwd: session.cwd,
559 cols: session.cols,
560 rows: session.rows,
561 env: terminalEnv,
562 });
556 ptyProcess = Effect.runSync(
557 this.ptyAdapter.spawn({
558 shell: candidate.shell,
559 ...(candidate.args ? { args: candidate.args } : {}),
560 cwd: session.cwd,
561 cols: session.cols,
562 rows: session.rows,
563 env: terminalEnv,
564 }),
565 );
563566 startedShell = formatShellCandidate(candidate);
564567 break;
565568 } catch (error) {
......@@ -690,7 +693,11 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
690693 this.killEscalationTimers.delete(process);
691694 }
692695
693 private killProcessWithEscalation(process: PtyProcess, threadId: string, terminalId: string): void {
696 private killProcessWithEscalation(
697 process: PtyProcess,
698 threadId: string,
699 terminalId: string,
700 ): void {
694701 this.clearKillEscalationTimer(process);
695702 try {
696703 process.kill("SIGTERM");
......@@ -724,7 +731,9 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
724731 }
725732
726733 private evictInactiveSessionsIfNeeded(): void {
727 const inactiveSessions = [...this.sessions.values()].filter((session) => session.status !== "running");
734 const inactiveSessions = [...this.sessions.values()].filter(
735 (session) => session.status !== "running",
736 );
728737 if (inactiveSessions.length <= this.maxRetainedInactiveSessions) {
729738 return;
730739 }
......@@ -1110,12 +1119,36 @@ export const TerminalManagerLive = Layer.effect(
11101119 );
11111120
11121121 return {
1113 open: (input) => Effect.promise(() => runtime.open(input)),
1114 write: (input) => Effect.promise(() => runtime.write(input)),
1115 resize: (input) => Effect.promise(() => runtime.resize(input)),
1116 clear: (input) => Effect.promise(() => runtime.clear(input)),
1117 restart: (input) => Effect.promise(() => runtime.restart(input)),
1118 close: (input) => Effect.promise(() => runtime.close(input)),
1122 open: (input) =>
1123 Effect.tryPromise({
1124 try: () => runtime.open(input),
1125 catch: (cause) => new TerminalError({ message: "Failed to open terminal", cause }),
1126 }),
1127 write: (input) =>
1128 Effect.tryPromise({
1129 try: () => runtime.write(input),
1130 catch: (cause) => new TerminalError({ message: "Failed to write to terminal", cause }),
1131 }),
1132 resize: (input) =>
1133 Effect.tryPromise({
1134 try: () => runtime.resize(input),
1135 catch: (cause) => new TerminalError({ message: "Failed to resize terminal", cause }),
1136 }),
1137 clear: (input) =>
1138 Effect.tryPromise({
1139 try: () => runtime.clear(input),
1140 catch: (cause) => new TerminalError({ message: "Failed to clear terminal", cause }),
1141 }),
1142 restart: (input) =>
1143 Effect.tryPromise({
1144 try: () => runtime.restart(input),
1145 catch: (cause) => new TerminalError({ message: "Failed to restart terminal", cause }),
1146 }),
1147 close: (input) =>
1148 Effect.tryPromise({
1149 try: () => runtime.close(input),
1150 catch: (cause) => new TerminalError({ message: "Failed to close terminal", cause }),
1151 }),
11191152 subscribe: (listener) =>
11201153 Effect.sync(() => {
11211154 runtime.on("event", listener);
......@@ -1123,10 +1156,7 @@ export const TerminalManagerLive = Layer.effect(
11231156 runtime.off("event", listener);
11241157 };
11251158 }),
1126 dispose: () =>
1127 Effect.sync(() => {
1128 runtime.dispose();
1129 }),
1159 dispose: Effect.sync(() => runtime.dispose()),
11301160 } satisfies TerminalManagerShape;
11311161 }),
11321162);
apps/server/src/terminal/Layers/NodePTY.test.ts+24-44
......@@ -1,65 +1,45 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
5import { assert, it, vi } from "@effect/vitest";
6import { Effect, Layer } from "effect";
7import { afterEach } from "vitest";
1import { FileSystem, Path, Effect } from "effect";
2import { assert, it } from "@effect/vitest";
83
94import { ensureNodePtySpawnHelperExecutable } from "./NodePTY";
5import { NodeServices } from "@effect/platform-node";
106
11function fileMode(filePath: string): number {
12 return fs.statSync(filePath).mode & 0o777;
13}
14
15const tempDirs: string[] = [];
16
17afterEach(() => {
18 vi.unstubAllGlobals();
19 for (const dir of tempDirs.splice(0, tempDirs.length)) {
20 fs.rmSync(dir, { recursive: true, force: true });
21 }
22});
23
24const helperLayer = it.layer(Layer.empty);
25
26helperLayer("ensureNodePtySpawnHelperExecutable", (it) => {
7it.layer(NodeServices.layer)("ensureNodePtySpawnHelperExecutable", (it) => {
278 it.effect("adds executable bits when helper exists but is not executable", () =>
28 Effect.sync(() => {
9 Effect.gen(function* () {
2910 if (process.platform === "win32") return;
3011
31 const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-helper-test-"));
32 tempDirs.push(dir);
12 const fs = yield* FileSystem.FileSystem;
13 const path = yield* Path.Path;
14
15 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "pty-helper-test-" });
3316 const helperPath = path.join(dir, "spawn-helper");
34 fs.writeFileSync(helperPath, "#!/bin/sh\nexit 0\n", "utf8");
35 fs.chmodSync(helperPath, 0o644);
17 yield* fs.writeFileString(helperPath, "#!/bin/sh\nexit 0\n");
18 yield* fs.chmod(helperPath, 0o644);
3619
37 ensureNodePtySpawnHelperExecutable(helperPath);
20 yield* ensureNodePtySpawnHelperExecutable(helperPath);
3821
39 assert.equal(fileMode(helperPath) & 0o111, 0o111);
22 const mode = (yield* fs.stat(helperPath)).mode & 0o777;
23 assert.equal(mode & 0o111, 0o111);
4024 }),
4125 );
4226
4327 it.effect("keeps executable helper as executable", () =>
44 Effect.sync(() => {
28 Effect.gen(function* () {
4529 if (process.platform === "win32") return;
4630
47 const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-helper-test-"));
48 tempDirs.push(dir);
49 const helperPath = path.join(dir, "spawn-helper");
50 fs.writeFileSync(helperPath, "#!/bin/sh\nexit 0\n", "utf8");
51 fs.chmodSync(helperPath, 0o755);
31 const fs = yield* FileSystem.FileSystem;
32 const path = yield* Path.Path;
5233
53 ensureNodePtySpawnHelperExecutable(helperPath);
34 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "pty-helper-test-" });
35 const helperPath = path.join(dir, "spawn-helper");
36 yield* fs.writeFileString(helperPath, "#!/bin/sh\nexit 0\n");
37 yield* fs.chmod(helperPath, 0o755);
5438
55 assert.equal(fileMode(helperPath) & 0o111, 0o111);
56 }),
57 );
39 yield* ensureNodePtySpawnHelperExecutable(helperPath);
5840
59 it.effect("does not throw when helper path is missing", () =>
60 Effect.sync(() => {
61 const missing = path.join(os.tmpdir(), `does-not-exist-${Date.now()}`);
62 ensureNodePtySpawnHelperExecutable(missing);
41 const mode = (yield* fs.stat(helperPath)).mode & 0o777;
42 assert.equal(mode & 0o111, 0o111);
6343 }),
6444 );
6545});
apps/server/src/terminal/Layers/NodePTY.ts+44-39
......@@ -1,55 +1,48 @@
1import fs from "node:fs";
21import { createRequire } from "node:module";
3import path from "node:path";
42
5import { Effect, Layer } from "effect";
6import { PtyAdapter, PtyExitEvent, PtyProcess, PtySpawnInput } from "../Services/PTY";
3import { Effect, FileSystem, Layer, Path } from "effect";
4import { PtyAdapter, PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY";
75
8const requireForNodePty = createRequire(import.meta.url);
96let didEnsureSpawnHelperExecutable = false;
107
11function resolveNodePtySpawnHelperPath(): string | null {
12 try {
13 const packageJsonPath = requireForNodePty.resolve("node-pty/package.json");
14 const packageDir = path.dirname(packageJsonPath);
15 const candidates = [
16 path.join(packageDir, "build", "Release", "spawn-helper"),
17 path.join(packageDir, "build", "Debug", "spawn-helper"),
18 path.join(packageDir, "prebuilds", `${process.platform}-${process.arch}`, "spawn-helper"),
19 ];
20
21 for (const candidate of candidates) {
22 if (fs.existsSync(candidate)) {
23 return candidate;
24 }
8const resolveNodePtySpawnHelperPath = Effect.gen(function* () {
9 const requireForNodePty = createRequire(import.meta.url);
10 const path = yield* Path.Path;
11 const fs = yield* FileSystem.FileSystem;
12
13 const packageJsonPath = requireForNodePty.resolve("node-pty/package.json");
14 const packageDir = path.dirname(packageJsonPath);
15 const candidates = [
16 path.join(packageDir, "build", "Release", "spawn-helper"),
17 path.join(packageDir, "build", "Debug", "spawn-helper"),
18 path.join(packageDir, "prebuilds", `${process.platform}-${process.arch}`, "spawn-helper"),
19 ];
20
21 for (const candidate of candidates) {
22 if (yield* fs.exists(candidate)) {
23 return candidate;
2524 }
26
27 return null;
28 } catch {
29 return null;
3025 }
31}
26 return null;
27}).pipe(Effect.orElseSucceed(() => null));
3228
33export function ensureNodePtySpawnHelperExecutable(explicitPath?: string): void {
29export const ensureNodePtySpawnHelperExecutable = Effect.fn(function* (explicitPath?: string) {
30 const fs = yield* FileSystem.FileSystem;
3431 if (process.platform === "win32") return;
3532 if (!explicitPath && didEnsureSpawnHelperExecutable) return;
3633
37 const helperPath = explicitPath ?? resolveNodePtySpawnHelperPath();
34 const helperPath = explicitPath ?? (yield* resolveNodePtySpawnHelperPath);
3835 if (!helperPath) return;
3936 if (!explicitPath) {
4037 didEnsureSpawnHelperExecutable = true;
4138 }
4239
43 try {
44 const stat = fs.statSync(helperPath);
45 const mode = stat.mode & 0o777;
46 if ((mode & 0o111) === 0) {
47 fs.chmodSync(helperPath, mode | 0o111);
48 }
49 } catch {
50 // Best effort only. If chmod fails, node-pty spawn will surface the real error.
40 const stat = yield* fs.stat(helperPath);
41 const mode = stat.mode & 0o777;
42 if ((mode & 0o111) === 0) {
43 yield* fs.chmod(helperPath, mode | 0o111);
5144 }
52}
45});
5346
5447class NodePtyProcess implements PtyProcess {
5548 constructor(private readonly process: import("node-pty").IPty) {}
......@@ -93,10 +86,22 @@ class NodePtyProcess implements PtyProcess {
9386export const NodePtyAdapterLive = Layer.effect(
9487 PtyAdapter,
9588 Effect.gen(function* () {
89 const fs = yield* FileSystem.FileSystem;
90 const path = yield* Path.Path;
91
9692 const nodePty = yield* Effect.promise(() => import("node-pty"));
93
94 const ensureNodePtySpawnHelperExecutableCached = yield* Effect.cached(
95 ensureNodePtySpawnHelperExecutable().pipe(
96 Effect.provideService(FileSystem.FileSystem, fs),
97 Effect.provideService(Path.Path, path),
98 Effect.orElseSucceed(() => undefined),
99 ),
100 );
101
97102 return {
98 spawn(input: PtySpawnInput): PtyProcess {
99 ensureNodePtySpawnHelperExecutable();
103 spawn: Effect.fn(function* (input) {
104 yield* ensureNodePtySpawnHelperExecutableCached;
100105 const ptyProcess = nodePty.spawn(input.shell, input.args ?? [], {
101106 cwd: input.cwd,
102107 cols: input.cols,
......@@ -105,7 +110,7 @@ export const NodePtyAdapterLive = Layer.effect(
105110 name: globalThis.process.platform === "win32" ? "xterm-color" : "xterm-256color",
106111 });
107112 return new NodePtyProcess(ptyProcess);
108 },
109 };
113 }),
114 } satisfies PtyAdapterShape;
110115 }),
111116);
apps/server/src/terminal/Services/Manager.ts+17-8
......@@ -17,7 +17,12 @@ import {
1717 TerminalWriteInput,
1818} from "@t3tools/contracts";
1919import { PtyProcess } from "./PTY";
20import { Effect, ServiceMap } from "effect";
20import { Effect, Schema, ServiceMap } from "effect";
21
22export class TerminalError extends Schema.TaggedErrorClass<TerminalError>()("TerminalError", {
23 message: Schema.String,
24 cause: Schema.optional(Schema.Defect),
25}) {}
2126
2227export interface TerminalSessionState {
2328 threadId: string;
......@@ -58,36 +63,40 @@ export interface TerminalManagerShape {
5863 * Reuses an existing session for the same thread/terminal id and restores
5964 * persisted history on first open.
6065 */
61 readonly open: (input: TerminalOpenInput) => Effect.Effect<TerminalSessionSnapshot, unknown>;
66 readonly open: (
67 input: TerminalOpenInput,
68 ) => Effect.Effect<TerminalSessionSnapshot, TerminalError>;
6269
6370 /**
6471 * Write input bytes to a terminal session.
6572 */
66 readonly write: (input: TerminalWriteInput) => Effect.Effect<void, unknown>;
73 readonly write: (input: TerminalWriteInput) => Effect.Effect<void, TerminalError>;
6774
6875 /**
6976 * Resize the PTY backing a terminal session.
7077 */
71 readonly resize: (input: TerminalResizeInput) => Effect.Effect<void, unknown>;
78 readonly resize: (input: TerminalResizeInput) => Effect.Effect<void, TerminalError>;
7279
7380 /**
7481 * Clear terminal output history.
7582 */
76 readonly clear: (input: TerminalClearInput) => Effect.Effect<void, unknown>;
83 readonly clear: (input: TerminalClearInput) => Effect.Effect<void, TerminalError>;
7784
7885 /**
7986 * Restart a terminal session in place.
8087 *
8188 * Always resets history before spawning the new process.
8289 */
83 readonly restart: (input: TerminalOpenInput) => Effect.Effect<TerminalSessionSnapshot, unknown>;
90 readonly restart: (
91 input: TerminalOpenInput,
92 ) => Effect.Effect<TerminalSessionSnapshot, TerminalError>;
8493
8594 /**
8695 * Close an active terminal session.
8796 *
8897 * When `terminalId` is omitted, closes all sessions for the thread.
8998 */
90 readonly close: (input: TerminalCloseInput) => Effect.Effect<void, unknown>;
99 readonly close: (input: TerminalCloseInput) => Effect.Effect<void, TerminalError>;
91100
92101 /**
93102 * Subscribe to terminal runtime events.
......@@ -97,7 +106,7 @@ export interface TerminalManagerShape {
97106 /**
98107 * Dispose all managed terminal resources.
99108 */
100 readonly dispose: () => Effect.Effect<void>;
109 readonly dispose: Effect.Effect<void>;
101110}
102111
103112/**
apps/server/src/terminal/Services/PTY.ts+2-2
......@@ -6,7 +6,7 @@
66 *
77 * @module PtyAdapter
88 */
9import { ServiceMap } from "effect";
9import { Effect, ServiceMap } from "effect";
1010
1111export interface PtyExitEvent {
1212 exitCode: number;
......@@ -38,7 +38,7 @@ export interface PtyAdapterShape {
3838 /**
3939 * Spawn a PTY process for a terminal session.
4040 */
41 spawn(input: PtySpawnInput): PtyProcess;
41 spawn(input: PtySpawnInput): Effect.Effect<PtyProcess>;
4242}
4343
4444/**