Skip to content
Source
Sign inSign up

NodePtyAdapter.ts

297 lines9.5 KB

1import * as NodeModule from "node:module";
2import * as NodeNet from "node:net";
3​
4import * as Context from "effect/Context";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as Path from "effect/Path";
9import * as Schema from "effect/Schema";
10import * as HostProcess from "@t3tools/shared/HostProcess";
11​
12import * as PtyAdapter from "@t3tools/shared/PtyAdapter";
13​
14export class NodePtyModuleLoadError extends Schema.TaggedError<NodePtyModuleLoadError>()(
15 "NodePtyModuleLoadError",
16 {
17 platform: Schema.String,
18 architecture: Schema.String,
19 cause: Schema.Defect(),
20 },
21) {
22 override get message(): string {
23 return `Failed to load node-pty for ${this.platform}-${this.architecture}.`;
24 }
26​
27class NodePtyCancelError extends Schema.TaggedError<NodePtyCancelError>()("NodePtyCancelError", {
28 terminalPid: Schema.Number,
29 cause: Schema.Defect(),
30}) {
31 override get message(): string {
32 return `Failed to cancel Windows terminal startup for ${this.terminalPid}.`;
33 }
35​
36type NodePtyModuleLoader = () => Promise<typeof import("node-pty")>;
37​
38// node-pty's native addon stays external; single-executables need require to resolve disk modules and spawn helpers.
39const requireForNodePty = NodeModule.createRequire(import.meta.url);
40​
41const loadNodePty: NodePtyModuleLoader = () =>
42 Promise.resolve().then(() => requireForNodePty("node-pty") as typeof import("node-pty"));
43​
44export const NodePtyModuleLoaderRef = Context.Reference<NodePtyModuleLoader>(
45 "server/terminal/NodePtyModuleLoader",
46 { defaultValue: () => loadNodePty },
47);
48​
49let didEnsureSpawnHelperExecutable = false;
50​
51const resolveNodePtySpawnHelperPath = Effect.gen(function* () {
52 const path = yield* Path.Path;
53 const fs = yield* FileSystem.FileSystem;
54 const platform = yield* HostProcess.Platform;
55 const architecture = yield* HostProcess.Architecture;
56​
57 const packageJsonPath = requireForNodePty.resolve("node-pty/package.json");
58 const packageDir = path.dirname(packageJsonPath);
59 const candidates = [
60 path.join(packageDir, "build", "Release", "spawn-helper"),
61 path.join(packageDir, "build", "Debug", "spawn-helper"),
62 path.join(packageDir, "prebuilds", `${platform}-${architecture}`, "spawn-helper"),
63 ];
64​
65 for (const candidate of candidates) {
66 if (yield* fs.exists(candidate)) {
67 return candidate;
68 }
69 }
70 return null;
71}).pipe(Effect.orElseSucceed(() => null));
72​
73const ensureNodePtySpawnHelperExecutable = Effect.fn(function* () {
74 const fs = yield* FileSystem.FileSystem;
75 const platform = yield* HostProcess.Platform;
76 if (platform === "win32") return;
77 if (didEnsureSpawnHelperExecutable) return;
78​
79 const helperPath = yield* resolveNodePtySpawnHelperPath;
80 if (!helperPath) return;
81 didEnsureSpawnHelperExecutable = true;
82​
83 if (!(yield* fs.exists(helperPath))) {
84 return;
85 }
86​
87 yield* fs.chmod(helperPath, 0o755).pipe(Effect.orElseSucceed(() => undefined));
88});
89​
90// node-pty has no public readiness event; ready_datapipe sets pid before our listener.
91// Deferred pipe creation: https://github.com/microsoft/node-pty/pull/885
92const waitForWindowsPid = (
93 process: import("node-pty").IPty,
94 trackedProcess: NodePtyProcess,
95 shell: string,
96) =>
97 Effect.callback<void, PtyAdapter.PtySpawnError>((resume) => {
98 const hasPid = () => Number.isInteger(process.pid) && process.pid > 0;
99 const failure = (cause: unknown) =>
100 Effect.fail(new PtyAdapter.PtySpawnError({ adapter: "node-pty", shell, cause }));
101​
102 if (hasPid()) {
103 resume(Effect.void);
104 return;
105 }
106​
107 if (!("_socket" in process) || !(process._socket instanceof NodeNet.Socket)) {
108 resume(failure(new Error("Windows PTY readiness socket is unavailable.")));
109 return;
110 }
111​
112 const socket = process._socket;
113 const onReady = () => {
114 cleanup();
115 resume(
116 hasPid()
117 ? Effect.void
118 : failure(new Error("Windows PTY became ready without a valid PID.")),
119 );
120 };
121 const onError = (cause: Error) => {
122 cleanup();
123 resume(failure(cause));
124 };
125 const onClose = () => onError(new Error("Windows PTY closed before its PID was available."));
126 let stopExit = () => {};
127 const cleanup = () => {
128 socket.off("ready_datapipe", onReady);
129 socket.off("error", onError);
130 socket.off("close", onClose);
131 stopExit();
132 };
133 socket.once("ready_datapipe", onReady);
134 socket.once("error", onError);
135 socket.once("close", onClose);
136 stopExit = trackedProcess.onExit(({ exitCode }) =>
137 onError(
138 new Error(`Windows PTY exited before its PID was available (exit code ${exitCode}).`),
139 ),
140 );
141 return Effect.sync(cleanup);
142 });
143​
144// The private agent cancels pending connections before a child exists; public kill waits for first output.
145const killStartingWindowsPty = (process: import("node-pty").IPty) =>
146 Effect.try({
147 try: () => {
148 if (
149 "_agent" in process &&
150 typeof process._agent === "object" &&
151 process._agent !== null &&
152 "kill" in process._agent &&
153 typeof process._agent.kill === "function"
154 ) {
155 process._agent.kill();
156 } else {
157 process.kill();
158 }
159 },
160 catch: (cause) => new NodePtyCancelError({ terminalPid: process.pid, cause }),
161 }).pipe(
162 Effect.catch((error) =>
163 Effect.logWarning("failed to cancel Windows terminal startup", {
164 terminalPid: process.pid,
165 cause: error,
166 }),
167 ),
168 );
169​
170class NodePtyProcess implements PtyAdapter.PtyProcess {
171 private readonly process: import("node-pty").IPty;
172 private readonly platform: NodeJS.Platform;
173 private exitEvent: PtyAdapter.PtyExitEvent | undefined;
174 private readonly exitListeners = new Set<(event: PtyAdapter.PtyExitEvent) => void>();
175 private readonly exitSubscription: import("node-pty").IDisposable;
176​
177 constructor(process: import("node-pty").IPty, platform: NodeJS.Platform) {
178 this.process = process;
179 this.platform = platform;
180 this.exitSubscription = process.onExit((event) => {
181 if (this.exitEvent) return;
182 this.exitEvent = { exitCode: event.exitCode, signal: event.signal ?? null };
183 this.exitSubscription.dispose();
184 for (const listener of this.exitListeners) listener(this.exitEvent);
185 this.exitListeners.clear();
186 });
187 }
188​
189 get pid(): number {
190 return this.process.pid;
191 }
192​
193 write(data: string): void {
194 this.process.write(data);
195 }
196​
197 resize(cols: number, rows: number): void {
198 this.process.resize(cols, rows);
199 }
200​
201 kill(signal?: string): void {
202 // node-pty terminates the Windows process tree without a POSIX signal.
203 this.process.kill(this.platform === "win32" ? undefined : signal);
204 }
205​
206 onData(callback: (data: string) => void): () => void {
207 const disposable = this.process.onData(callback);
208 return () => {
209 disposable.dispose();
210 };
211 }
212​
213 onExit(callback: (event: PtyAdapter.PtyExitEvent) => void): () => void {
214 if (this.exitEvent) {
215 callback(this.exitEvent);
216 return () => {};
217 }
218 this.exitListeners.add(callback);
219 return () => {
220 this.exitListeners.delete(callback);
221 };
222 }
223​
224 disposeExitSubscription(): void {
225 this.exitSubscription.dispose();
226 this.exitListeners.clear();
227 }
229​
230const make = Effect.fn("NodePtyAdapter.make")(function* () {
231 const loadNodePtyModule = yield* NodePtyModuleLoaderRef;
232 const fs = yield* FileSystem.FileSystem;
233 const path = yield* Path.Path;
234 const platform = yield* HostProcess.Platform;
235 const architecture = yield* HostProcess.Architecture;
236​
237 const nodePty = yield* Effect.tryPromise({
238 try: loadNodePtyModule,
239 catch: (cause) =>
240 new NodePtyModuleLoadError({
241 platform,
242 architecture,
243 cause,
244 }),
245 }).pipe(Effect.orDie);
246​
247 const ensureNodePtySpawnHelperExecutableCached = yield* Effect.cached(
248 ensureNodePtySpawnHelperExecutable().pipe(
249 Effect.provideService(FileSystem.FileSystem, fs),
250 Effect.provideService(Path.Path, path),
251 Effect.provideService(HostProcess.Platform, platform),
252 Effect.provideService(HostProcess.Architecture, architecture),
253 Effect.orElseSucceed(() => undefined),
254 ),
255 );
256​
257 return PtyAdapter.PtyAdapter.of({
258 spawn: Effect.fn("NodePtyAdapter.spawn")(function* (input) {
259 yield* ensureNodePtySpawnHelperExecutableCached;
260 // ConPTY ignores node-pty's name option for TERM; set it explicitly to avoid missing/16-color values.
261 const env =
262 platform === "win32" && input.env["TERM"] === undefined
263 ? { ...input.env, TERM: "xterm-256color" }
264 : input.env;
265 const ptyProcess = yield* Effect.try({
266 try: () => {
267 const nativeProcess = nodePty.spawn(input.shell, input.args ?? [], {
268 cwd: input.cwd,
269 cols: input.cols,
270 rows: input.rows,
271 env,
272 name: "xterm-256color",
273 });
274 return { nativeProcess, process: new NodePtyProcess(nativeProcess, platform) };
275 },
276 catch: (cause) =>
277 new PtyAdapter.PtySpawnError({
278 adapter: "node-pty",
279 shell: input.shell,
280 cause,
281 }),
282 });
283 if (platform === "win32") {
284 yield* waitForWindowsPid(ptyProcess.nativeProcess, ptyProcess.process, input.shell).pipe(
285 Effect.onError(() =>
286 Effect.sync(() => ptyProcess.process.disposeExitSubscription()).pipe(
287 Effect.andThen(killStartingWindowsPty(ptyProcess.nativeProcess)),
288 ),
289 ),
290 );
291 }
292 return ptyProcess.process;
293 }),
294 });
295});
296​
297export const layer = Layer.effect(PtyAdapter.PtyAdapter, make());