diff --git a/packages/core/src/pty/pty.bun.ts b/packages/core/src/pty/pty.bun.ts index f2a7b489fd2..4926c70d332 100644 --- a/packages/core/src/pty/pty.bun.ts +++ b/packages/core/src/pty/pty.bun.ts @@ -1,8 +1,24 @@ +import { dlopen } from "bun:ffi" import { spawn } from "bun-pty" import type { Opts, Proc } from "./pty.js" export type { Disp, Exit, Opts, Proc } from "./pty.js" +if (process.platform === "win32") { + const library = dlopen("kernel32.dll", { + SetConsoleCtrlHandler: { args: ["ptr", "i32"], returns: "i32" }, + GetLastError: { args: [], returns: "u32" }, + }) + try { + // Detached servers start with Ctrl+C ignored, and ConPTY shells inherit it. + // Clear that attribute once before spawning shells; keep registered handlers. + if (library.symbols.SetConsoleCtrlHandler(null, 0) === 0) + throw new Error(`Failed to enable PTY Ctrl+C handling: Windows error ${library.symbols.GetLastError()}`) + } finally { + library.close() + } +} + function spawnPty(file: string, args: string[], opts: Opts): Proc { const pty = spawn(file, args, opts) return { diff --git a/packages/core/test/fixture/pty-windows.ts b/packages/core/test/fixture/pty-windows.ts new file mode 100644 index 00000000000..93eb7c13181 --- /dev/null +++ b/packages/core/test/fixture/pty-windows.ts @@ -0,0 +1,69 @@ +import assert from "node:assert/strict" +import { spawn } from "../../src/pty/pty.bun" + +const raw = process.argv[2] === "raw" +const pty = spawn( + Bun.which("pwsh") ?? "powershell.exe", + [ + "-NoLogo", + "-NoProfile", + ...(raw ? [] : ["-NoExit"]), + "-Command", + raw + ? "[Console]::TreatControlCAsInput = $true; Write-Output 'PTY_RAW_READY'; $key = [Console]::ReadKey($true); Write-Output ('PTY_KEY:' + [int]$key.KeyChar)" + : "Remove-Module PSReadLine -ErrorAction SilentlyContinue; function prompt { 'PTY_PROMPT> ' }", + ], + { name: "xterm-256color", cols: 160, rows: 24, env: { ...process.env, TERM: "xterm-256color" } }, +) +const output = { text: "", cursor: 0 } +const listeners = new Set<() => void>() +const exited = Promise.withResolvers() +pty.onExit((event) => exited.resolve(event.exitCode)) +pty.onData((text) => { + output.text += text + listeners.forEach((check) => check()) +}) + +try { + if (raw) { + await waitFor("PTY_RAW_READY") + pty.write("\x03") + await waitFor("PTY_KEY:3") + } + if (!raw) { + await waitFor("PTY_PROMPT>") + // Split markers so echoed command text cannot satisfy the output checks. + pty.write("Write-Output ('PTY_' + 'BUSY'); Start-Sleep -Seconds 60; Write-Output ('PTY_' + 'COMPLETED')\r") + await waitFor("PTY_BUSY") + pty.write("\x03") + await waitFor("PTY_PROMPT>") + assert.ok(!output.text.includes("PTY_COMPLETED")) + pty.write("Write-Output ('PTY_' + 'REUSED')\r") + await waitFor("PTY_REUSED") + await waitFor("PTY_PROMPT>") + pty.write("exit 0\r") + } + assert.equal(await exited.promise, 0) +} finally { + pty.kill() +} +process.exit(0) + +function waitFor(text: string) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + listeners.delete(check) + reject(new Error(`Timed out waiting for ${JSON.stringify(text)}: ${output.text}`)) + }, 5_000) + const check = () => { + const index = output.text.indexOf(text, output.cursor) + if (index === -1) return + output.cursor = index + text.length + listeners.delete(check) + clearTimeout(timeout) + resolve() + } + listeners.add(check) + check() + }) +} diff --git a/packages/core/test/pty/pty-windows.test.ts b/packages/core/test/pty/pty-windows.test.ts new file mode 100644 index 00000000000..49b84f1dd24 --- /dev/null +++ b/packages/core/test/pty/pty-windows.test.ts @@ -0,0 +1,41 @@ +import { expect } from "bun:test" +import { spawn } from "node:child_process" +import path from "node:path" +import { Effect } from "effect" +import { it } from "../lib/effect" + +const windowsTest = process.platform === "win32" ? it.live : it.live.skip + +Array.of("interrupt", "raw").forEach((scenario) => { + windowsTest( + scenario === "interrupt" + ? "detached PTY hosts interrupt commands without closing the shell" + : "detached PTY hosts preserve Ctrl+C input for raw-mode programs", + Effect.gen(function* () { + // Isolate the inheritable Windows console state from the test runner. + const worker = yield* Effect.acquireRelease( + Effect.sync(() => { + const child = spawn(process.execPath, [path.join(import.meta.dir, "../fixture/pty-windows.ts"), scenario], { + detached: true, + stdio: ["ignore", "ignore", "pipe"], + timeout: 30_000, + }) + const output: string[] = [] + child.stderr.on("data", (chunk: Buffer) => output.push(chunk.toString())) + const exited = new Promise((resolve, reject) => { + child.once("error", reject) + child.once("close", resolve) + }) + return { child, output, exited } + }), + (worker) => + Effect.sync(() => { + if (worker.child.exitCode === null && worker.child.signalCode === null) worker.child.kill() + }), + ) + const code = yield* Effect.promise(() => worker.exited) + expect({ code, output: worker.output.join("") }).toEqual({ code: 0, output: "" }) + }), + 35_000, + ) +})