| 1 | import * as NodeModule from "node:module"; |
| 2 | import * as NodeNet from "node:net"; |
| 3 | |
| 4 | import * as Context from "effect/Context"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as FileSystem from "effect/FileSystem"; |
| 7 | import * as Layer from "effect/Layer"; |
| 8 | import * as Path from "effect/Path"; |
| 9 | import * as Schema from "effect/Schema"; |
| 10 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 11 | |
| 12 | import * as PtyAdapter from "@t3tools/shared/PtyAdapter"; |
| 13 | |
| 14 | export 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 | |
| 27 | type 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. |
| 33 | const requireForNodePty = NodeModule.createRequire(import.meta.url); |
| 34 | |
| 35 | const 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. */ |
| 39 | export const NodePtyModuleLoaderRef = Context.Reference<NodePtyModuleLoader>( |
| 40 | "server/terminal/NodePtyModuleLoader", |
| 41 | { defaultValue: () => loadNodePty }, |
| 42 | ); |
| 43 | |
| 44 | let didEnsureSpawnHelperExecutable = false; |
| 45 | |
| 46 | const 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 | |
| 68 | const 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 | */ |
| 95 | const 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 | */ |
| 152 | const 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 | |
| 174 | class 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 | |
| 235 | export 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 | |
| 304 | export const layer = Layer.effect(PtyAdapter.PtyAdapter, make()); |