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 }
25}
26
27type NodePtyModuleLoader = () => Promise<typeof import("node-pty")>;
28
29// node-pty stays external to the CLI bundle because it dlopens a native
30// addon. Inside a Node single-executable, `import()` cannot load files from
31// disk (only built-ins resolve), while `require` always reads the real
32// filesystem, so both the module and its spawn-helper resolve through it.
33const requireForNodePty = NodeModule.createRequire(import.meta.url);
34
35const loadNodePty: NodePtyModuleLoader = () =>
36 Promise.resolve().then(() => requireForNodePty("node-pty") as typeof import("node-pty"));
37
38/** Injectable so tests can substitute a fake module; `require` bypasses module mocks. */
39export const NodePtyModuleLoaderRef = Context.Reference<NodePtyModuleLoader>(
40 "server/terminal/NodePtyModuleLoader",
41 { defaultValue: () => loadNodePty },
42);
43
44let didEnsureSpawnHelperExecutable = false;
45
46const resolveNodePtySpawnHelperPath = Effect.gen(function* () {
47 const path = yield* Path.Path;
48 const fs = yield* FileSystem.FileSystem;
49 const platform = yield* HostProcess.Platform;
50 const architecture = yield* HostProcess.Architecture;
51
52 const packageJsonPath = requireForNodePty.resolve("node-pty/package.json");
53 const packageDir = path.dirname(packageJsonPath);
54 const candidates = [
55 path.join(packageDir, "build", "Release", "spawn-helper"),
56 path.join(packageDir, "build", "Debug", "spawn-helper"),
57 path.join(packageDir, "prebuilds", `${platform}-${architecture}`, "spawn-helper"),
58 ];
59
60 for (const candidate of candidates) {
61 if (yield* fs.exists(candidate)) {
62 return candidate;
63 }
64 }
65 return null;
66}).pipe(Effect.orElseSucceed(() => null));
67
68const ensureNodePtySpawnHelperExecutable = Effect.fn(function* () {
69 const fs = yield* FileSystem.FileSystem;
70 const platform = yield* HostProcess.Platform;
71 if (platform === "win32") return;
72 if (didEnsureSpawnHelperExecutable) return;
73
74 const helperPath = yield* resolveNodePtySpawnHelperPath;
75 if (!helperPath) return;
76 didEnsureSpawnHelperExecutable = true;
77
78 if (!(yield* fs.exists(helperPath))) {
79 return;
80 }
81
82 // Best-effort: avoid FileSystem.stat in packaged mode where some fs metadata can be missing.
83 yield* fs.chmod(helperPath, 0o755).pipe(Effect.orElseSucceed(() => undefined));
84});
85
86/**
87 * Waits for Windows process creation so the manager receives a valid PID.
88 * node-pty defers creation to avoid blocking on named pipes:
89 * https://github.com/microsoft/node-pty/pull/885
90 * T3 adopted that behavior when upgrading from 1.1.0 to 1.2.0-beta.15:
91 * https://github.com/pingdotgg/t3code/pull/13748
92 * Its public API has no readiness event. The private ready_datapipe handler sets
93 * pid before our listener runs.
94 */
95const waitForWindowsPid = (
96 process: import("node-pty").IPty,
97 trackedProcess: NodePtyProcess,
98 shell: string,
99) =>
100 Effect.callback<void, PtyAdapter.PtySpawnError>((resume) => {
101 const hasPid = () => Number.isInteger(process.pid) && process.pid > 0;
102 const failure = (cause: unknown) =>
103 Effect.fail(new PtyAdapter.PtySpawnError({ adapter: "node-pty", shell, cause }));
104
105 if (hasPid()) {
106 resume(Effect.void);
107 return;
108 }
109
110 if (!("_socket" in process) || !(process._socket instanceof NodeNet.Socket)) {
111 resume(failure(new Error("Windows PTY readiness socket is unavailable.")));
112 return;
113 }
114
115 const socket = process._socket;
116 const onReady = () => {
117 cleanup();
118 resume(
119 hasPid()
120 ? Effect.void
121 : failure(new Error("Windows PTY became ready without a valid PID.")),
122 );
123 };
124 const onError = (cause: Error) => {
125 cleanup();
126 resume(failure(cause));
127 };
128 const onClose = () => onError(new Error("Windows PTY closed before its PID was available."));
129 let stopExit = () => {};
130 const cleanup = () => {
131 socket.off("ready_datapipe", onReady);
132 socket.off("error", onError);
133 socket.off("close", onClose);
134 stopExit();
135 };
136 socket.once("ready_datapipe", onReady);
137 socket.once("error", onError);
138 socket.once("close", onClose);
139 stopExit = trackedProcess.onExit(({ exitCode }) =>
140 onError(
141 new Error(`Windows PTY exited before its PID was available (exit code ${exitCode}).`),
142 ),
143 );
144 return Effect.sync(cleanup);
145 });
146
147/**
148 * Cancels Windows startup without waiting for the first output, unlike public kill().
149 * The private agent can cancel the pending connection before a child exists.
150 * Cleanup failures are logged without replacing the startup failure.
151 */
152const killStartingWindowsPty = (process: import("node-pty").IPty) =>
153 Effect.try(() => {
154 if (
155 "_agent" in process &&
156 typeof process._agent === "object" &&
157 process._agent !== null &&
158 "kill" in process._agent &&
159 typeof process._agent.kill === "function"
160 ) {
161 process._agent.kill();
162 } else {
163 process.kill();
164 }
165 }).pipe(
166 Effect.catch((error) =>
167 Effect.logWarning("failed to cancel Windows terminal startup", {
168 terminalPid: process.pid,
169 cause: error,
170 }),
171 ),
172 );
173
174class NodePtyProcess implements PtyAdapter.PtyProcess {
175 private readonly process: import("node-pty").IPty;
176 private readonly platform: NodeJS.Platform;
177 private exitEvent: PtyAdapter.PtyExitEvent | undefined;
178 private readonly exitListeners = new Set<(event: PtyAdapter.PtyExitEvent) => void>();
179 private readonly exitSubscription: import("node-pty").IDisposable;
180
181 constructor(process: import("node-pty").IPty, platform: NodeJS.Platform) {
182 this.process = process;
183 this.platform = platform;
184 // Retain exits while Windows readiness and the manager hand off the process.
185 this.exitSubscription = process.onExit((event) => {
186 if (this.exitEvent) return;
187 this.exitEvent = { exitCode: event.exitCode, signal: event.signal ?? null };
188 this.exitSubscription.dispose();
189 for (const listener of this.exitListeners) listener(this.exitEvent);
190 this.exitListeners.clear();
191 });
192 }
193
194 get pid(): number {
195 return this.process.pid;
196 }
197
198 write(data: string): void {
199 this.process.write(data);
200 }
201
202 resize(cols: number, rows: number): void {
203 this.process.resize(cols, rows);
204 }
205
206 kill(signal?: string): void {
207 // node-pty terminates the Windows process tree without a POSIX signal.
208 this.process.kill(this.platform === "win32" ? undefined : signal);
209 }
210
211 onData(callback: (data: string) => void): () => void {
212 const disposable = this.process.onData(callback);
213 return () => {
214 disposable.dispose();
215 };
216 }
217
218 onExit(callback: (event: PtyAdapter.PtyExitEvent) => void): () => void {
219 if (this.exitEvent) {
220 callback(this.exitEvent);
221 return () => {};
222 }
223 this.exitListeners.add(callback);
224 return () => {
225 this.exitListeners.delete(callback);
226 };
227 }
228
229 disposeExitSubscription(): void {
230 this.exitSubscription.dispose();
231 this.exitListeners.clear();
232 }
233}
234
235export const make = Effect.fn("NodePtyAdapter.make")(function* () {
236 const loadNodePtyModule = yield* NodePtyModuleLoaderRef;
237 const fs = yield* FileSystem.FileSystem;
238 const path = yield* Path.Path;
239 const platform = yield* HostProcess.Platform;
240 const architecture = yield* HostProcess.Architecture;
241
242 const nodePty = yield* Effect.tryPromise({
243 try: loadNodePtyModule,
244 catch: (cause) =>
245 new NodePtyModuleLoadError({
246 platform,
247 architecture,
248 cause,
249 }),
250 }).pipe(Effect.orDie);
251
252 const ensureNodePtySpawnHelperExecutableCached = yield* Effect.cached(
253 ensureNodePtySpawnHelperExecutable().pipe(
254 Effect.provideService(FileSystem.FileSystem, fs),
255 Effect.provideService(Path.Path, path),
256 Effect.provideService(HostProcess.Platform, platform),
257 Effect.provideService(HostProcess.Architecture, architecture),
258 Effect.orElseSucceed(() => undefined),
259 ),
260 );
261
262 return PtyAdapter.PtyAdapter.of({
263 spawn: Effect.fn("NodePtyAdapter.spawn")(function* (input) {
264 yield* ensureNodePtySpawnHelperExecutableCached;
265 // node-pty only writes `name` into the child's TERM on the Unix path;
266 // the ConPTY path leaves the environment untouched, so Windows children
267 // inherit a missing or 16-color TERM unless it is set here.
268 const env =
269 platform === "win32" && input.env["TERM"] === undefined
270 ? { ...input.env, TERM: "xterm-256color" }
271 : input.env;
272 const ptyProcess = yield* Effect.try({
273 try: () => {
274 const nativeProcess = nodePty.spawn(input.shell, input.args ?? [], {
275 cwd: input.cwd,
276 cols: input.cols,
277 rows: input.rows,
278 env,
279 name: "xterm-256color",
280 });
281 return { nativeProcess, process: new NodePtyProcess(nativeProcess, platform) };
282 },
283 catch: (cause) =>
284 new PtyAdapter.PtySpawnError({
285 adapter: "node-pty",
286 shell: input.shell,
287 cause,
288 }),
289 });
290 if (platform === "win32") {
291 yield* waitForWindowsPid(ptyProcess.nativeProcess, ptyProcess.process, input.shell).pipe(
292 Effect.onError(() =>
293 Effect.sync(() => ptyProcess.process.disposeExitSubscription()).pipe(
294 Effect.andThen(killStartingWindowsPty(ptyProcess.nativeProcess)),
295 ),
296 ),
297 );
298 }
299 return ptyProcess.process;
300 }),
301 });
302});
303
304export const layer = Layer.effect(PtyAdapter.PtyAdapter, make());